first commit

This commit is contained in:
Muhammad Ibrahim
2025-09-16 15:36:42 +01:00
commit c5332ce6b0
61 changed files with 21858 additions and 0 deletions

117
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,117 @@
import React from 'react'
import { Routes, Route } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext'
import { ThemeProvider } from './contexts/ThemeContext'
import ProtectedRoute from './components/ProtectedRoute'
import Layout from './components/Layout'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Hosts from './pages/Hosts'
import HostGroups from './pages/HostGroups'
import Packages from './pages/Packages'
import Repositories from './pages/Repositories'
import RepositoryDetail from './pages/RepositoryDetail'
import Users from './pages/Users'
import Permissions from './pages/Permissions'
import Settings from './pages/Settings'
import Profile from './pages/Profile'
import HostDetail from './pages/HostDetail'
import PackageDetail from './pages/PackageDetail'
function App() {
return (
<ThemeProvider>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<ProtectedRoute requirePermission="canViewDashboard">
<Layout>
<Dashboard />
</Layout>
</ProtectedRoute>
} />
<Route path="/hosts" element={
<ProtectedRoute requirePermission="canViewHosts">
<Layout>
<Hosts />
</Layout>
</ProtectedRoute>
} />
<Route path="/hosts/:hostId" element={
<ProtectedRoute requirePermission="canViewHosts">
<Layout>
<HostDetail />
</Layout>
</ProtectedRoute>
} />
<Route path="/host-groups" element={
<ProtectedRoute requirePermission="canManageHosts">
<Layout>
<HostGroups />
</Layout>
</ProtectedRoute>
} />
<Route path="/packages" element={
<ProtectedRoute requirePermission="canViewPackages">
<Layout>
<Packages />
</Layout>
</ProtectedRoute>
} />
<Route path="/repositories" element={
<ProtectedRoute requirePermission="canViewHosts">
<Layout>
<Repositories />
</Layout>
</ProtectedRoute>
} />
<Route path="/repositories/:repositoryId" element={
<ProtectedRoute requirePermission="canViewHosts">
<Layout>
<RepositoryDetail />
</Layout>
</ProtectedRoute>
} />
<Route path="/users" element={
<ProtectedRoute requirePermission="canViewUsers">
<Layout>
<Users />
</Layout>
</ProtectedRoute>
} />
<Route path="/permissions" element={
<ProtectedRoute requirePermission="canManageSettings">
<Layout>
<Permissions />
</Layout>
</ProtectedRoute>
} />
<Route path="/settings" element={
<ProtectedRoute requirePermission="canManageSettings">
<Layout>
<Settings />
</Layout>
</ProtectedRoute>
} />
<Route path="/profile" element={
<ProtectedRoute>
<Layout>
<Profile />
</Layout>
</ProtectedRoute>
} />
<Route path="/packages/:packageId" element={
<ProtectedRoute requirePermission="canViewPackages">
<Layout>
<PackageDetail />
</Layout>
</ProtectedRoute>
} />
</Routes>
</AuthProvider>
</ThemeProvider>
)
}
export default App

View File

@@ -0,0 +1,306 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import {
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
X,
GripVertical,
Eye,
EyeOff,
Save,
RotateCcw,
Settings as SettingsIcon
} from 'lucide-react';
import { dashboardPreferencesAPI } from '../utils/api';
// Sortable Card Item Component
const SortableCardItem = ({ card, onToggle }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: card.cardId });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center justify-between p-3 bg-white border border-secondary-200 rounded-lg ${
isDragging ? 'shadow-lg' : 'shadow-sm'
}`}
>
<div className="flex items-center gap-3">
<button
{...attributes}
{...listeners}
className="text-secondary-400 hover:text-secondary-600 cursor-grab active:cursor-grabbing"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-secondary-900">
{card.title}
</div>
</div>
</div>
<button
onClick={() => onToggle(card.cardId)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
card.enabled
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
}`}
>
{card.enabled ? (
<>
<Eye className="h-3 w-3" />
Visible
</>
) : (
<>
<EyeOff className="h-3 w-3" />
Hidden
</>
)}
</button>
</div>
);
};
const DashboardSettingsModal = ({ isOpen, onClose }) => {
const [cards, setCards] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const queryClient = useQueryClient();
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
// Fetch user's dashboard preferences
const { data: preferences, isLoading } = useQuery({
queryKey: ['dashboardPreferences'],
queryFn: () => dashboardPreferencesAPI.get().then(res => res.data),
enabled: isOpen
});
// Fetch default card configuration
const { data: defaultCards } = useQuery({
queryKey: ['dashboardDefaultCards'],
queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data),
enabled: isOpen
});
// Update preferences mutation
const updatePreferencesMutation = useMutation({
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
onSuccess: (response) => {
// Optimistically update the query cache with the correct data structure
queryClient.setQueryData(['dashboardPreferences'], response.data.preferences);
// Also invalidate to ensure fresh data
queryClient.invalidateQueries(['dashboardPreferences']);
setHasChanges(false);
onClose();
},
onError: (error) => {
console.error('Failed to update dashboard preferences:', error);
}
});
// Initialize cards when preferences or defaults are loaded
useEffect(() => {
if (preferences && defaultCards) {
// Merge user preferences with default cards
const mergedCards = defaultCards.map(defaultCard => {
const userPreference = preferences.find(p => p.cardId === defaultCard.cardId);
return {
...defaultCard,
enabled: userPreference ? userPreference.enabled : defaultCard.enabled,
order: userPreference ? userPreference.order : defaultCard.order
};
}).sort((a, b) => a.order - b.order);
setCards(mergedCards);
}
}, [preferences, defaultCards]);
const handleDragEnd = (event) => {
const { active, over } = event;
if (active.id !== over.id) {
setCards((items) => {
const oldIndex = items.findIndex(item => item.cardId === active.id);
const newIndex = items.findIndex(item => item.cardId === over.id);
const newItems = arrayMove(items, oldIndex, newIndex);
// Update order values
return newItems.map((item, index) => ({
...item,
order: index
}));
});
setHasChanges(true);
}
};
const handleToggle = (cardId) => {
setCards(prevCards =>
prevCards.map(card =>
card.cardId === cardId
? { ...card, enabled: !card.enabled }
: card
)
);
setHasChanges(true);
};
const handleSave = () => {
const preferences = cards.map(card => ({
cardId: card.cardId,
enabled: card.enabled,
order: card.order
}));
updatePreferencesMutation.mutate(preferences);
};
const handleReset = () => {
if (defaultCards) {
const resetCards = defaultCards.map(card => ({
...card,
enabled: true,
order: card.order
}));
setCards(resetCards);
setHasChanges(true);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity" onClick={onClose} />
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<SettingsIcon className="h-5 w-5 text-primary-600" />
<h3 className="text-lg font-medium text-secondary-900">
Dashboard Settings
</h3>
</div>
<button
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600"
>
<X className="h-5 w-5" />
</button>
</div>
<p className="text-sm text-secondary-600 mb-6">
Customize your dashboard by reordering cards and toggling their visibility.
Drag cards to reorder them, and click the visibility toggle to show/hide cards.
</p>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={cards.map(card => card.cardId)} strategy={verticalListSortingStrategy}>
<div className="space-y-2 max-h-96 overflow-y-auto">
{cards.map((card) => (
<SortableCardItem
key={card.cardId}
card={card}
onToggle={handleToggle}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
<div className="bg-secondary-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
onClick={handleSave}
disabled={!hasChanges || updatePreferencesMutation.isPending}
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm ${
!hasChanges || updatePreferencesMutation.isPending
? 'bg-secondary-400 cursor-not-allowed'
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
}`}
>
{updatePreferencesMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</button>
<button
onClick={handleReset}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-secondary-700 hover:bg-secondary-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
</button>
<button
onClick={onClose}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-secondary-700 hover:bg-secondary-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
};
export default DashboardSettingsModal;

View File

@@ -0,0 +1,439 @@
import React from 'react'
import { Link, useLocation } from 'react-router-dom'
import {
Home,
Server,
Package,
Shield,
BarChart3,
Menu,
X,
LogOut,
User,
Users,
Settings,
UserCircle,
ChevronLeft,
ChevronRight,
Clock,
RefreshCw,
GitBranch,
Wrench
} from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../contexts/AuthContext'
import { dashboardAPI, formatRelativeTime } from '../utils/api'
const Layout = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
// Load sidebar state from localStorage, default to false
const saved = localStorage.getItem('sidebarCollapsed')
return saved ? JSON.parse(saved) : false
})
const [userMenuOpen, setUserMenuOpen] = useState(false)
const location = useLocation()
const { user, logout, canViewHosts, canManageHosts, canViewPackages, canViewUsers, canManageUsers, canManageSettings } = useAuth()
const userMenuRef = useRef(null)
// Fetch dashboard stats for the "Last updated" info
const { data: stats, refetch } = useQuery({
queryKey: ['dashboardStats'],
queryFn: () => dashboardAPI.getStats().then(res => res.data),
refetchInterval: 60000, // Refresh every minute
staleTime: 30000, // Consider data stale after 30 seconds
})
const navigation = [
{ name: 'Dashboard', href: '/', icon: Home },
{
section: 'Inventory',
items: [
...(canViewHosts() ? [{ name: 'Hosts', href: '/hosts', icon: Server }] : []),
...(canManageHosts() ? [{ name: 'Host Groups', href: '/host-groups', icon: Users }] : []),
...(canViewPackages() ? [{ name: 'Packages', href: '/packages', icon: Package }] : []),
...(canViewHosts() ? [{ name: 'Repos', href: '/repositories', icon: GitBranch }] : []),
{ name: 'Services', href: '/services', icon: Wrench, comingSoon: true },
{ name: 'Reporting', href: '/reporting', icon: BarChart3, comingSoon: true },
]
},
{
section: 'Users',
items: [
...(canViewUsers() ? [{ name: 'Users', href: '/users', icon: Users }] : []),
...(canManageSettings() ? [{ name: 'Permissions', href: '/permissions', icon: Shield }] : []),
]
},
{
section: 'Settings',
items: [
...(canManageSettings() ? [{ name: 'Settings', href: '/settings', icon: Settings }] : []),
]
}
]
const isActive = (path) => location.pathname === path
// Get page title based on current route
const getPageTitle = () => {
const path = location.pathname
if (path === '/') return 'Dashboard'
if (path === '/hosts') return 'Hosts'
if (path === '/host-groups') return 'Host Groups'
if (path === '/packages') return 'Packages'
if (path === '/repositories' || path.startsWith('/repositories/')) return 'Repositories'
if (path === '/services') return 'Services'
if (path === '/users') return 'Users'
if (path === '/permissions') return 'Permissions'
if (path === '/settings') return 'Settings'
if (path === '/profile') return 'My Profile'
if (path.startsWith('/hosts/')) return 'Host Details'
if (path.startsWith('/packages/')) return 'Package Details'
return 'PatchMon'
}
const handleLogout = async () => {
await logout()
setUserMenuOpen(false)
}
// Save sidebar collapsed state to localStorage
useEffect(() => {
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed))
}, [sidebarCollapsed])
// Close user menu when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (userMenuRef.current && !userMenuRef.current.contains(event.target)) {
setUserMenuOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [])
return (
<div className="min-h-screen bg-secondary-50">
{/* Mobile sidebar */}
<div className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? 'block' : 'hidden'}`}>
<div className="fixed inset-0 bg-secondary-600 bg-opacity-75" onClick={() => setSidebarOpen(false)} />
<div className="relative flex w-full max-w-xs flex-col bg-white pb-4 pt-5 shadow-xl">
<div className="absolute right-0 top-0 -mr-12 pt-2">
<button
type="button"
className="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
onClick={() => setSidebarOpen(false)}
>
<X className="h-6 w-6 text-white" />
</button>
</div>
<div className="flex flex-shrink-0 items-center px-4">
<div className="flex items-center">
<Shield className="h-8 w-8 text-primary-600" />
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">PatchMon</h1>
</div>
</div>
<nav className="mt-8 flex-1 space-y-6 px-2">
{navigation.map((item, index) => {
if (item.name) {
// Single item (Dashboard)
return (
<Link
key={item.name}
to={item.href}
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
isActive(item.href)
? 'bg-primary-100 text-primary-900'
: 'text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900'
}`}
onClick={() => setSidebarOpen(false)}
>
<item.icon className="mr-3 h-5 w-5" />
{item.name}
</Link>
)
} else if (item.section) {
// Section with items
return (
<div key={item.section}>
<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wider mb-2 px-2">
{item.section}
</h3>
<div className="space-y-1">
{item.items.map((subItem) => (
<Link
key={subItem.name}
to={subItem.href}
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
isActive(subItem.href)
? 'bg-primary-100 text-primary-900'
: 'text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900'
} ${subItem.comingSoon ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={subItem.comingSoon ? (e) => e.preventDefault() : () => setSidebarOpen(false)}
>
<subItem.icon className="mr-3 h-5 w-5" />
<span className="flex items-center gap-2">
{subItem.name}
{subItem.comingSoon && (
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
Soon
</span>
)}
</span>
</Link>
))}
</div>
</div>
)
}
return null
})}
</nav>
</div>
</div>
{/* Desktop sidebar */}
<div className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 ${
sidebarCollapsed ? 'lg:w-16' : 'lg:w-64'
} bg-white dark:bg-secondary-800`}>
<div className={`flex grow flex-col gap-y-5 overflow-y-auto border-r border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 ${
sidebarCollapsed ? 'px-2 shadow-lg' : 'px-6'
}`}>
<div className={`flex h-16 shrink-0 items-center border-b border-secondary-200 ${
sidebarCollapsed ? 'justify-center' : 'justify-between'
}`}>
{sidebarCollapsed ? (
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
title="Expand sidebar"
>
<ChevronRight className="h-5 w-5 text-white" />
</button>
) : (
<>
<div className="flex items-center">
<Shield className="h-8 w-8 text-primary-600" />
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">PatchMon</h1>
</div>
<button
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
title="Collapse sidebar"
>
<ChevronLeft className="h-5 w-5 text-white" />
</button>
</>
)}
</div>
<nav className="flex flex-1 flex-col">
<ul className="flex flex-1 flex-col gap-y-6">
{navigation.map((item, index) => {
if (item.name) {
// Single item (Dashboard)
return (
<li key={item.name}>
<Link
to={item.href}
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-semibold transition-all duration-200 ${
isActive(item.href)
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700'
} ${sidebarCollapsed ? 'justify-center p-2' : 'p-2'}`}
title={sidebarCollapsed ? item.name : ''}
>
<item.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
{!sidebarCollapsed && (
<span className="truncate">{item.name}</span>
)}
</Link>
</li>
)
} else if (item.section) {
// Section with items
return (
<li key={item.section}>
{!sidebarCollapsed && (
<h3 className="text-xs font-semibold text-secondary-500 dark:text-secondary-300 uppercase tracking-wider mb-2 px-2">
{item.section}
</h3>
)}
<ul className={`space-y-1 ${sidebarCollapsed ? '' : '-mx-2'}`}>
{item.items.map((subItem) => (
<li key={subItem.name}>
<Link
to={subItem.href}
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 ${
isActive(subItem.href)
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700'
} ${sidebarCollapsed ? 'justify-center p-2' : 'p-2'} ${
subItem.comingSoon ? 'opacity-50 cursor-not-allowed' : ''
}`}
title={sidebarCollapsed ? subItem.name : ''}
onClick={subItem.comingSoon ? (e) => e.preventDefault() : undefined}
>
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
{!sidebarCollapsed && (
<span className="truncate flex items-center gap-2">
{subItem.name}
{subItem.comingSoon && (
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
Soon
</span>
)}
</span>
)}
</Link>
</li>
))}
</ul>
</li>
)
}
return null
})}
</ul>
</nav>
{/* Profile Section - Bottom of Sidebar */}
<div className="border-t border-secondary-200">
{!sidebarCollapsed ? (
<div className="space-y-1">
{/* My Profile Link */}
<Link
to="/profile"
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 p-2 ${
isActive('/profile')
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-500'
}`}
>
<UserCircle className="h-5 w-5 shrink-0" />
<span className="truncate">My Profile</span>
</Link>
{/* User Info with Sign Out */}
<div className="flex items-center justify-between p-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-x-2">
<p className="text-sm font-medium text-secondary-900 dark:text-white truncate">
{user?.username}
</p>
{user?.role === 'admin' && (
<span className="inline-flex items-center rounded-full bg-primary-100 px-1.5 py-0.5 text-xs font-medium text-primary-800">
Admin
</span>
)}
</div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 truncate">
{user?.email}
</p>
</div>
<button
onClick={handleLogout}
className="ml-2 p-1.5 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded transition-colors"
title="Sign out"
>
<LogOut className="h-4 w-4" />
</button>
</div>
</div>
) : (
<div className="space-y-1">
<Link
to="/profile"
className="flex items-center justify-center p-2 text-secondary-700 hover:bg-secondary-50 rounded-md transition-colors"
title="My Profile"
>
<UserCircle className="h-5 w-5 text-white" />
</Link>
<button
onClick={handleLogout}
className="flex items-center justify-center w-full p-2 text-secondary-700 hover:bg-secondary-50 rounded-md transition-colors"
title="Sign out"
>
<LogOut className="h-5 w-5 text-white" />
</button>
</div>
)}
</div>
</div>
</div>
{/* Main content */}
<div className={`transition-all duration-300 ${
sidebarCollapsed ? 'lg:pl-16' : 'lg:pl-64'
}`}>
{/* Top bar */}
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
<button
type="button"
className="-m-2.5 p-2.5 text-secondary-700 lg:hidden"
onClick={() => setSidebarOpen(true)}
>
<Menu className="h-6 w-6" />
</button>
{/* Separator */}
<div className="h-6 w-px bg-secondary-200 lg:hidden" />
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
<div className="relative flex flex-1 items-center">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
{getPageTitle()}
</h2>
</div>
<div className="flex items-center gap-x-4 lg:gap-x-6">
{/* Last updated info */}
{stats && (
<div className="flex items-center gap-x-2 text-sm text-secondary-500 dark:text-secondary-400">
<Clock className="h-4 w-4" />
<span>Last updated: {formatRelativeTime(stats.lastUpdated)}</span>
<button
onClick={() => refetch()}
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-800 rounded"
title="Refresh data"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
)}
{/* Customize Dashboard Button - Only show on Dashboard page */}
{location.pathname === '/' && (
<button
onClick={() => {
// This will be handled by the Dashboard component
const event = new CustomEvent('openDashboardSettings');
window.dispatchEvent(event);
}}
className="btn-outline flex items-center gap-2"
>
<Settings className="h-4 w-4" />
Customize Dashboard
</button>
)}
</div>
</div>
</div>
<main className="py-6 bg-secondary-50 dark:bg-secondary-800 min-h-screen">
<div className="px-4 sm:px-6 lg:px-8">
{children}
</div>
</main>
</div>
</div>
)
}
export default Layout

View File

@@ -0,0 +1,47 @@
import React from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = null }) => {
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth()
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)
}
if (!isAuthenticated()) {
return <Navigate to="/login" replace />
}
// Check admin requirement
if (requireAdmin && !isAdmin()) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
<p className="text-secondary-600">You don't have permission to access this page.</p>
</div>
</div>
)
}
// Check specific permission requirement
if (requirePermission && !hasPermission(requirePermission)) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
<p className="text-secondary-600">You don't have permission to access this page.</p>
</div>
</div>
)
}
return children
}
export default ProtectedRoute

View File

@@ -0,0 +1,246 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
const AuthContext = createContext()
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [token, setToken] = useState(null)
const [permissions, setPermissions] = useState(null)
const [isLoading, setIsLoading] = useState(true)
// Initialize auth state from localStorage
useEffect(() => {
const storedToken = localStorage.getItem('token')
const storedUser = localStorage.getItem('user')
const storedPermissions = localStorage.getItem('permissions')
if (storedToken && storedUser) {
try {
setToken(storedToken)
setUser(JSON.parse(storedUser))
if (storedPermissions) {
setPermissions(JSON.parse(storedPermissions))
} else {
// Fetch permissions if not stored
fetchPermissions(storedToken)
}
} catch (error) {
console.error('Error parsing stored user data:', error)
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('permissions')
}
}
setIsLoading(false)
}, [])
// Periodically refresh permissions when user is logged in
useEffect(() => {
if (token && user) {
// Refresh permissions every 30 seconds
const interval = setInterval(() => {
refreshPermissions()
}, 30000)
return () => clearInterval(interval)
}
}, [token, user])
const fetchPermissions = async (authToken) => {
try {
const response = await fetch('/api/v1/permissions/user-permissions', {
headers: {
'Authorization': `Bearer ${authToken}`,
},
})
if (response.ok) {
const data = await response.json()
setPermissions(data)
localStorage.setItem('permissions', JSON.stringify(data))
return data
} else {
console.error('Failed to fetch permissions')
return null
}
} catch (error) {
console.error('Error fetching permissions:', error)
return null
}
}
const refreshPermissions = async () => {
if (token) {
const updatedPermissions = await fetchPermissions(token)
return updatedPermissions
}
return null
}
const login = async (username, password) => {
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
})
const data = await response.json()
if (response.ok) {
setToken(data.token)
setUser(data.user)
localStorage.setItem('token', data.token)
localStorage.setItem('user', JSON.stringify(data.user))
// Fetch user permissions after successful login
const userPermissions = await fetchPermissions(data.token)
if (userPermissions) {
setPermissions(userPermissions)
}
return { success: true }
} else {
return { success: false, error: data.error || 'Login failed' }
}
} catch (error) {
return { success: false, error: 'Network error occurred' }
}
}
const logout = async () => {
try {
if (token) {
await fetch('/api/v1/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
}
} catch (error) {
console.error('Logout error:', error)
} finally {
setToken(null)
setUser(null)
setPermissions(null)
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('permissions')
}
}
const updateProfile = async (profileData) => {
try {
const response = await fetch('/api/v1/auth/profile', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(profileData),
})
const data = await response.json()
if (response.ok) {
setUser(data.user)
localStorage.setItem('user', JSON.stringify(data.user))
return { success: true, user: data.user }
} else {
return { success: false, error: data.error || 'Update failed' }
}
} catch (error) {
return { success: false, error: 'Network error occurred' }
}
}
const changePassword = async (currentPassword, newPassword) => {
try {
const response = await fetch('/api/v1/auth/change-password', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ currentPassword, newPassword }),
})
const data = await response.json()
if (response.ok) {
return { success: true }
} else {
return { success: false, error: data.error || 'Password change failed' }
}
} catch (error) {
return { success: false, error: 'Network error occurred' }
}
}
const isAuthenticated = () => {
return !!(token && user)
}
const isAdmin = () => {
return user?.role === 'admin'
}
// Permission checking functions
const hasPermission = (permission) => {
return permissions?.[permission] === true
}
const canViewDashboard = () => hasPermission('canViewDashboard')
const canViewHosts = () => hasPermission('canViewHosts')
const canManageHosts = () => hasPermission('canManageHosts')
const canViewPackages = () => hasPermission('canViewPackages')
const canManagePackages = () => hasPermission('canManagePackages')
const canViewUsers = () => hasPermission('canViewUsers')
const canManageUsers = () => hasPermission('canManageUsers')
const canViewReports = () => hasPermission('canViewReports')
const canExportData = () => hasPermission('canExportData')
const canManageSettings = () => hasPermission('canManageSettings')
const value = {
user,
token,
permissions,
isLoading,
login,
logout,
updateProfile,
changePassword,
refreshPermissions,
isAuthenticated,
isAdmin,
hasPermission,
canViewDashboard,
canViewHosts,
canManageHosts,
canViewPackages,
canManagePackages,
canViewUsers,
canManageUsers,
canViewReports,
canExportData,
canManageSettings
}
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}

View File

@@ -0,0 +1,54 @@
import React, { createContext, useContext, useEffect, useState } from 'react'
const ThemeContext = createContext()
export const useTheme = () => {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState(() => {
// Check localStorage first, then system preference
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
return savedTheme
}
// Check system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark'
}
return 'light'
})
useEffect(() => {
// Apply theme to document
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
// Save to localStorage
localStorage.setItem('theme', theme)
}, [theme])
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light')
}
const value = {
theme,
toggleTheme,
isDark: theme === 'dark'
}
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
)
}

127
frontend/src/index.css Normal file
View File

@@ -0,0 +1,127 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: Inter, ui-sans-serif, system-ui;
}
body {
@apply bg-secondary-50 dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 antialiased;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-150;
}
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
}
.btn-secondary {
@apply btn bg-secondary-600 text-white hover:bg-secondary-700 focus:ring-secondary-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-outline {
@apply btn border-secondary-300 dark:border-secondary-600 text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-secondary-500;
}
.card {
@apply bg-white dark:bg-secondary-800 rounded-lg shadow-card dark:shadow-card-dark border border-secondary-200 dark:border-secondary-600;
}
.card-hover {
@apply card hover:shadow-card-hover transition-shadow duration-150;
}
.input {
@apply block w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100;
}
.label {
@apply block text-sm font-medium text-secondary-700 dark:text-secondary-200;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-primary {
@apply badge bg-primary-100 text-primary-800;
}
.badge-secondary {
@apply badge bg-secondary-100 text-secondary-800;
}
.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;
}
}
@layer utilities {
.text-shadow {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
}
.dark .scrollbar-thin {
scrollbar-color: #64748b #475569;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: #f1f5f9;
}
.dark .scrollbar-thin::-webkit-scrollbar-track {
background: #475569;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 3px;
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: #64748b;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: #94a3b8;
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: #94a3b8;
}
}

27
frontend/src/main.jsx Normal file
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 App from './App.jsx'
import './index.css'
// Create a client for React Query
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
})
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,460 @@
import React, { useState, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import {
Server,
Package,
AlertTriangle,
Shield,
TrendingUp,
RefreshCw,
Clock
} from 'lucide-react'
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title } from 'chart.js'
import { Pie, Bar } from 'react-chartjs-2'
import { dashboardAPI, dashboardPreferencesAPI, formatRelativeTime } from '../utils/api'
import DashboardSettingsModal from '../components/DashboardSettingsModal'
import { useTheme } from '../contexts/ThemeContext'
// Register Chart.js components
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title)
const Dashboard = () => {
const [showSettingsModal, setShowSettingsModal] = useState(false)
const [cardPreferences, setCardPreferences] = useState([])
const navigate = useNavigate()
const { isDark } = useTheme()
// Navigation handlers
const handleTotalHostsClick = () => {
navigate('/hosts')
}
const handleHostsNeedingUpdatesClick = () => {
navigate('/hosts?filter=needsUpdates')
}
const handleOutdatedPackagesClick = () => {
navigate('/packages?filter=outdated')
}
const handleSecurityUpdatesClick = () => {
navigate('/packages?filter=security')
}
const { data: stats, isLoading, error, refetch } = useQuery({
queryKey: ['dashboardStats'],
queryFn: () => dashboardAPI.getStats().then(res => res.data),
refetchInterval: 60000, // Refresh every minute
staleTime: 30000, // Consider data stale after 30 seconds
})
// Fetch user's dashboard preferences
const { data: preferences, refetch: refetchPreferences } = useQuery({
queryKey: ['dashboardPreferences'],
queryFn: () => dashboardPreferencesAPI.get().then(res => res.data),
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
})
// Fetch default card configuration
const { data: defaultCards } = useQuery({
queryKey: ['dashboardDefaultCards'],
queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data),
})
// Merge preferences with default cards
useEffect(() => {
if (preferences && defaultCards) {
const mergedCards = defaultCards.map(defaultCard => {
const userPreference = preferences.find(p => p.cardId === defaultCard.cardId);
return {
...defaultCard,
enabled: userPreference ? userPreference.enabled : defaultCard.enabled,
order: userPreference ? userPreference.order : defaultCard.order
};
}).sort((a, b) => a.order - b.order);
setCardPreferences(mergedCards);
} else if (defaultCards) {
// If no preferences exist, use defaults
setCardPreferences(defaultCards.sort((a, b) => a.order - b.order));
}
}, [preferences, defaultCards])
// Listen for custom event from Layout component
useEffect(() => {
const handleOpenSettings = () => {
setShowSettingsModal(true);
};
window.addEventListener('openDashboardSettings', handleOpenSettings);
return () => {
window.removeEventListener('openDashboardSettings', handleOpenSettings);
};
}, [])
// Helper function to check if a card should be displayed
const isCardEnabled = (cardId) => {
const card = cardPreferences.find(c => c.cardId === cardId);
return card ? card.enabled : true; // Default to enabled if not found
}
// Helper function to get card type for layout grouping
const getCardType = (cardId) => {
if (['totalHosts', 'hostsNeedingUpdates', 'totalOutdatedPackages', 'securityUpdates'].includes(cardId)) {
return 'stats';
} else if (['osDistribution', 'updateStatus', 'packagePriority'].includes(cardId)) {
return 'charts';
} else if (['erroredHosts', 'quickStats'].includes(cardId)) {
return 'fullwidth';
}
return 'fullwidth'; // Default to full width
}
// Helper function to get CSS class for card group
const getGroupClassName = (cardType) => {
switch (cardType) {
case 'stats':
return 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4';
case 'charts':
return 'grid grid-cols-1 lg:grid-cols-3 gap-6';
case 'fullwidth':
return 'space-y-6';
default:
return 'space-y-6';
}
}
// Helper function to render a card by ID
const renderCard = (cardId) => {
switch (cardId) {
case 'totalHosts':
return (
<div
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
onClick={handleTotalHostsClick}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<Server className="h-5 w-5 text-primary-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">Total Hosts</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.totalHosts}
</p>
</div>
</div>
</div>
);
case 'hostsNeedingUpdates':
return (
<div
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
onClick={handleHostsNeedingUpdatesClick}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<AlertTriangle className="h-5 w-5 text-warning-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">Needs Updating</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.hostsNeedingUpdates}
</p>
</div>
</div>
</div>
);
case 'totalOutdatedPackages':
return (
<div
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
onClick={handleOutdatedPackagesClick}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<Package className="h-5 w-5 text-secondary-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">Outdated Packages</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.totalOutdatedPackages}
</p>
</div>
</div>
</div>
);
case 'securityUpdates':
return (
<div
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
onClick={handleSecurityUpdatesClick}
>
<div className="flex items-center">
<div className="flex-shrink-0">
<Shield className="h-5 w-5 text-danger-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">Security Updates</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.securityUpdates}
</p>
</div>
</div>
</div>
);
case 'erroredHosts':
return (
<div className={`border rounded-lg p-4 ${
stats.cards.erroredHosts > 0
? 'bg-danger-50 border-danger-200'
: 'bg-success-50 border-success-200'
}`}>
<div className="flex">
<AlertTriangle className={`h-5 w-5 ${
stats.cards.erroredHosts > 0 ? 'text-danger-400' : 'text-success-400'
}`} />
<div className="ml-3">
{stats.cards.erroredHosts > 0 ? (
<>
<h3 className="text-sm font-medium text-danger-800">
{stats.cards.erroredHosts} host{stats.cards.erroredHosts > 1 ? 's' : ''} haven't reported in 24+ hours
</h3>
<p className="text-sm text-danger-700 mt-1">
These hosts may be offline or experiencing connectivity issues.
</p>
</>
) : (
<>
<h3 className="text-sm font-medium text-success-800">
All hosts are reporting normally
</h3>
<p className="text-sm text-success-700 mt-1">
No hosts have failed to report in the last 24 hours.
</p>
</>
)}
</div>
</div>
</div>
);
case 'osDistribution':
return (
<div className="card p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">OS Distribution</h3>
<div className="h-64">
<Pie data={osChartData} options={chartOptions} />
</div>
</div>
);
case 'updateStatus':
return (
<div className="card p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Update Status</h3>
<div className="h-64">
<Pie data={updateStatusChartData} options={chartOptions} />
</div>
</div>
);
case 'packagePriority':
return (
<div className="card p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Package Priority</h3>
<div className="h-64">
<Pie data={packagePriorityChartData} options={chartOptions} />
</div>
</div>
);
case 'quickStats':
return (
<div className="card p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Quick Stats</h3>
<TrendingUp className="h-5 w-5 text-success-500" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-primary-600">
{((stats.cards.hostsNeedingUpdates / stats.cards.totalHosts) * 100).toFixed(1)}%
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Hosts need updates</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-danger-600">
{stats.cards.securityUpdates}
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Security updates pending</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-success-600">
{stats.cards.totalHosts - stats.cards.erroredHosts}
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Hosts online</div>
</div>
</div>
</div>
);
default:
return null;
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
</div>
)
}
if (error) {
return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">Error loading dashboard</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || 'Failed to load dashboard statistics'}
</p>
<button
onClick={() => refetch()}
className="mt-2 btn-danger text-xs"
>
Try again
</button>
</div>
</div>
</div>
)
}
const chartOptions = {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: isDark ? '#ffffff' : '#374151',
font: {
size: 12
}
}
},
},
}
const osChartData = {
labels: stats.charts.osDistribution.map(item => item.name),
datasets: [
{
data: stats.charts.osDistribution.map(item => item.count),
backgroundColor: [
'#3B82F6', // Blue
'#10B981', // Green
'#F59E0B', // Yellow
'#EF4444', // Red
'#8B5CF6', // Purple
'#06B6D4', // Cyan
],
borderWidth: 2,
borderColor: '#ffffff',
},
],
}
const updateStatusChartData = {
labels: stats.charts.updateStatusDistribution.map(item => item.name),
datasets: [
{
data: stats.charts.updateStatusDistribution.map(item => item.count),
backgroundColor: [
'#10B981', // Green - Up to date
'#F59E0B', // Yellow - Needs updates
'#EF4444', // Red - Errored
],
borderWidth: 2,
borderColor: '#ffffff',
},
],
}
const packagePriorityChartData = {
labels: stats.charts.packageUpdateDistribution.map(item => item.name),
datasets: [
{
data: stats.charts.packageUpdateDistribution.map(item => item.count),
backgroundColor: [
'#EF4444', // Red - Security
'#3B82F6', // Blue - Regular
],
borderWidth: 2,
borderColor: '#ffffff',
},
],
}
return (
<div className="space-y-6">
{/* Dynamically Rendered Cards - Unified Order */}
{(() => {
const enabledCards = cardPreferences
.filter(card => isCardEnabled(card.cardId))
.sort((a, b) => a.order - b.order);
// Group consecutive cards of the same type for proper layout
const cardGroups = [];
let currentGroup = null;
enabledCards.forEach(card => {
const cardType = getCardType(card.cardId);
if (!currentGroup || currentGroup.type !== cardType) {
// Start a new group
currentGroup = {
type: cardType,
cards: [card]
};
cardGroups.push(currentGroup);
} else {
// Add to existing group
currentGroup.cards.push(card);
}
});
return (
<>
{cardGroups.map((group, groupIndex) => (
<div key={groupIndex} className={getGroupClassName(group.type)}>
{group.cards.map(card => (
<div key={card.cardId}>
{renderCard(card.cardId)}
</div>
))}
</div>
))}
</>
);
})()}
{/* Dashboard Settings Modal */}
<DashboardSettingsModal
isOpen={showSettingsModal}
onClose={() => setShowSettingsModal(false)}
/>
</div>
)
}
export default Dashboard

View File

@@ -0,0 +1,732 @@
import React, { useState } from 'react'
import { useParams, Link, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Server,
ArrowLeft,
Package,
Shield,
Clock,
CheckCircle,
AlertTriangle,
RefreshCw,
Calendar,
Monitor,
HardDrive,
Key,
Trash2,
X,
Copy,
Eye,
Code,
EyeOff,
ToggleLeft,
ToggleRight
} from 'lucide-react'
import { dashboardAPI, adminHostsAPI, settingsAPI, formatRelativeTime, formatDate } from '../utils/api'
const HostDetail = () => {
const { hostId } = useParams()
const navigate = useNavigate()
const queryClient = useQueryClient()
const [showCredentialsModal, setShowCredentialsModal] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const { data: host, isLoading, error, refetch } = useQuery({
queryKey: ['host', hostId],
queryFn: () => dashboardAPI.getHostDetail(hostId).then(res => res.data),
refetchInterval: 60000,
staleTime: 30000,
})
const deleteHostMutation = useMutation({
mutationFn: (hostId) => adminHostsAPI.delete(hostId),
onSuccess: () => {
queryClient.invalidateQueries(['hosts'])
navigate('/hosts')
},
})
// Toggle auto-update mutation
const toggleAutoUpdateMutation = useMutation({
mutationFn: (autoUpdate) => adminHostsAPI.toggleAutoUpdate(hostId, autoUpdate).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries(['host', hostId])
queryClient.invalidateQueries(['hosts'])
}
})
const handleDeleteHost = async () => {
if (window.confirm(`Are you sure you want to delete host "${host.hostname}"? This action cannot be undone.`)) {
try {
await deleteHostMutation.mutateAsync(hostId)
} catch (error) {
console.error('Failed to delete host:', error)
alert('Failed to delete host')
}
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
</div>
)
}
if (error) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/hosts" className="text-secondary-500 hover:text-secondary-700">
<ArrowLeft className="h-5 w-5" />
</Link>
</div>
</div>
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">Error loading host</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || 'Failed to load host details'}
</p>
<button
onClick={() => refetch()}
className="mt-2 btn-danger text-xs"
>
Try again
</button>
</div>
</div>
</div>
</div>
)
}
if (!host) {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/hosts" className="text-secondary-500 hover:text-secondary-700">
<ArrowLeft className="h-5 w-5" />
</Link>
</div>
</div>
<div className="card p-8 text-center">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">Host Not Found</h3>
<p className="text-secondary-600 dark:text-secondary-300">
The requested host could not be found.
</p>
</div>
</div>
)
}
const getStatusColor = (isStale, needsUpdate) => {
if (isStale) return 'text-danger-600'
if (needsUpdate) return 'text-warning-600'
return 'text-success-600'
}
const getStatusIcon = (isStale, needsUpdate) => {
if (isStale) return <AlertTriangle className="h-5 w-5" />
if (needsUpdate) return <Clock className="h-5 w-5" />
return <CheckCircle className="h-5 w-5" />
}
const getStatusText = (isStale, needsUpdate) => {
if (isStale) return 'Stale'
if (needsUpdate) return 'Needs Updates'
return 'Up to Date'
}
const isStale = new Date() - new Date(host.lastUpdate) > 24 * 60 * 60 * 1000
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/hosts" className="text-secondary-500 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-200">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">{host.hostname}</h1>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowCredentialsModal(true)}
className="btn-outline flex items-center gap-2"
>
<Key className="h-4 w-4" />
View Credentials
</button>
<button
onClick={() => setShowDeleteModal(true)}
className="btn-danger flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Delete Host
</button>
</div>
</div>
{/* Host Information */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic Info */}
<div className="card p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Host Information</h3>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Hostname</p>
<p className="font-medium text-secondary-900 dark:text-white">{host.hostname}</p>
</div>
</div>
<div className="flex items-center gap-3">
<Shield className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Host Group</p>
{host.hostGroup ? (
<span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: host.hostGroup.color }}
>
{host.hostGroup.name}
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-200">
Ungrouped
</span>
)}
</div>
</div>
<div className="flex items-center gap-3">
<Monitor className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Operating System</p>
<p className="font-medium text-secondary-900 dark:text-white">{host.osType} {host.osVersion}</p>
</div>
</div>
{host.ip && (
<div className="flex items-center gap-3">
<HardDrive className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">IP Address</p>
<p className="font-medium text-secondary-900 dark:text-white">{host.ip}</p>
</div>
</div>
)}
{host.architecture && (
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Architecture</p>
<p className="font-medium text-secondary-900 dark:text-white">{host.architecture}</p>
</div>
</div>
)}
<div className="flex items-center gap-3">
<Calendar className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Last Update</p>
<p className="font-medium text-secondary-900 dark:text-white">{formatRelativeTime(host.lastUpdate)}</p>
</div>
</div>
{host.agentVersion && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Code className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Agent Version</p>
<p className="font-medium text-secondary-900 dark:text-white">{host.agentVersion}</p>
</div>
</div>
{/* Auto-Update Toggle */}
<div className="flex items-center gap-2">
<span className="text-sm text-secondary-500 dark:text-secondary-300">Auto-update</span>
<button
onClick={() => toggleAutoUpdateMutation.mutate(!host.autoUpdate)}
disabled={toggleAutoUpdateMutation.isPending}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
host.autoUpdate
? 'bg-primary-600 dark:bg-primary-500'
: 'bg-secondary-200 dark:bg-secondary-600'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
host.autoUpdate ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
)}
</div>
</div>
{/* Statistics */}
<div className="card p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Statistics</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="text-center">
<div className="flex items-center justify-center w-12 h-12 bg-primary-100 rounded-lg mx-auto mb-2">
<Package className="h-6 w-6 text-primary-600" />
</div>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.totalPackages}</p>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Total Packages</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center w-12 h-12 bg-warning-100 rounded-lg mx-auto mb-2">
<Clock className="h-6 w-6 text-warning-600" />
</div>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.outdatedPackages}</p>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Outdated</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center w-12 h-12 bg-danger-100 rounded-lg mx-auto mb-2">
<Shield className="h-6 w-6 text-danger-600" />
</div>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.securityUpdates}</p>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Security Updates</p>
</div>
</div>
{/* Status */}
<div className="mt-6 pt-4 border-t border-secondary-200 dark:border-secondary-600">
<div className={`flex items-center gap-2 ${getStatusColor(isStale, host.stats.outdatedPackages > 0)}`}>
{getStatusIcon(isStale, host.stats.outdatedPackages > 0)}
<span className="font-medium">{getStatusText(isStale, host.stats.outdatedPackages > 0)}</span>
</div>
</div>
</div>
</div>
{/* Packages */}
<div className="card">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Packages</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Package
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Current Version
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Available Version
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{host.hostPackages?.map((hostPackage) => (
<tr key={hostPackage.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Package className="h-4 w-4 text-secondary-400 mr-3" />
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{hostPackage.package.name}
</div>
{hostPackage.package.description && (
<div className="text-sm text-secondary-500 dark:text-secondary-300">
{hostPackage.package.description}
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
{hostPackage.currentVersion}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
{hostPackage.availableVersion || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{hostPackage.needsUpdate ? (
<div className="flex items-center gap-2">
<span className={`badge ${hostPackage.isSecurityUpdate ? 'badge-danger' : 'badge-warning'}`}>
{hostPackage.isSecurityUpdate ? 'Security Update' : 'Update Available'}
</span>
{hostPackage.isSecurityUpdate && (
<Shield className="h-4 w-4 text-danger-600" />
)}
</div>
) : (
<span className="badge-success">Up to date</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{host.hostPackages?.length === 0 && (
<div className="text-center py-8">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">No packages found</p>
</div>
)}
</div>
{/* Update History */}
<div className="card">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Update History</h3>
</div>
<div className="p-6">
{host.updateHistory?.length > 0 ? (
<div className="space-y-4">
{host.updateHistory.map((update, index) => (
<div key={update.id} className="flex items-center justify-between py-3 border-b border-secondary-100 dark:border-secondary-700 last:border-0">
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${update.status === 'success' ? 'bg-success-500' : 'bg-danger-500'}`} />
<div>
<p className="text-sm font-medium text-secondary-900 dark:text-white">
{update.status === 'success' ? 'Update Successful' : 'Update Failed'}
</p>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
{formatDate(update.timestamp)}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm text-secondary-900 dark:text-white">
{update.packagesCount} packages
</p>
{update.securityCount > 0 && (
<p className="text-xs text-danger-600">
{update.securityCount} security updates
</p>
)}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8">
<Calendar className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">No update history available</p>
</div>
)}
</div>
</div>
{/* Credentials Modal */}
{showCredentialsModal && (
<CredentialsModal
host={host}
isOpen={showCredentialsModal}
onClose={() => setShowCredentialsModal(false)}
/>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && (
<DeleteConfirmationModal
host={host}
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
onConfirm={handleDeleteHost}
isLoading={deleteHostMutation.isPending}
/>
)}
</div>
)
}
// Credentials Modal Component
const CredentialsModal = ({ host, isOpen, onClose }) => {
const [showApiKey, setShowApiKey] = useState(false)
const [activeTab, setActiveTab] = useState('credentials')
const { data: serverUrlData } = useQuery({
queryKey: ['serverUrl'],
queryFn: () => settingsAPI.getServerUrl().then(res => res.data),
})
const serverUrl = serverUrlData?.serverUrl || 'http://localhost:3001'
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text)
}
const getSetupCommands = () => {
return `# Run this on the target host: ${host?.hostname}
echo "🔄 Setting up PatchMon agent..."
# Download and install agent
echo "📥 Downloading agent script..."
curl -o /tmp/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download
sudo mkdir -p /etc/patchmon
sudo mv /tmp/patchmon-agent.sh /usr/local/bin/patchmon-agent.sh
sudo chmod +x /usr/local/bin/patchmon-agent.sh
# Configure credentials
echo "🔑 Configuring API credentials..."
sudo /usr/local/bin/patchmon-agent.sh configure "${host?.apiId}" "${host?.apiKey}"
# Test configuration
echo "🧪 Testing configuration..."
sudo /usr/local/bin/patchmon-agent.sh test
# Send initial update
echo "📊 Sending initial package data..."
sudo /usr/local/bin/patchmon-agent.sh update
# Setup crontab
echo "⏰ Setting up hourly crontab..."
echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -
echo "✅ PatchMon agent setup complete!"
echo " - Agent installed: /usr/local/bin/patchmon-agent.sh"
echo " - Config directory: /etc/patchmon/"
echo " - Updates: Every hour via crontab"
echo " - View logs: tail -f /var/log/patchmon-agent.log"`
}
if (!isOpen || !host) return null
const commands = getSetupCommands()
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Host Setup - {host.hostname}</h3>
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300">
<X className="h-5 w-5" />
</button>
</div>
{/* Tabs */}
<div className="border-b border-secondary-200 dark:border-secondary-600 mb-6">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('credentials')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'credentials'
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500'
}`}
>
API Credentials
</button>
<button
onClick={() => setActiveTab('quick-install')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'quick-install'
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500'
}`}
>
Quick Install
</button>
</nav>
</div>
{/* Tab Content */}
{activeTab === 'credentials' && (
<div className="space-y-6">
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">API Credentials</h4>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">API ID</label>
<div className="flex items-center gap-2">
<input
type="text"
value={host.apiId}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => copyToClipboard(host.apiId)}
className="btn-outline flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">API Key</label>
<div className="flex items-center gap-2">
<input
type={showApiKey ? 'text' : 'password'}
value={host.apiKey}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => setShowApiKey(!showApiKey)}
className="btn-outline flex items-center gap-1"
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
<button
onClick={() => copyToClipboard(host.apiKey)}
className="btn-outline flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
</div>
</div>
<div className="bg-warning-50 dark:bg-warning-900 border border-warning-200 dark:border-warning-700 rounded-lg p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-warning-400 dark:text-warning-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-warning-800 dark:text-warning-200">Security Notice</h3>
<p className="text-sm text-warning-700 dark:text-warning-300 mt-1">
Keep these credentials secure. They provide full access to this host's monitoring data.
</p>
</div>
</div>
</div>
</div>
)}
{activeTab === 'quick-install' && (
<div className="space-y-4">
<div className="bg-primary-50 dark:bg-primary-900 border border-primary-200 dark:border-primary-700 rounded-lg p-4">
<h4 className="text-sm font-medium text-primary-900 dark:text-primary-200 mb-2">One-Line Installation</h4>
<p className="text-sm text-primary-700 dark:text-primary-300 mb-3">
Copy and run this command on the target host to automatically install and configure the PatchMon agent:
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={`curl -s ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host.apiId}" "${host.apiKey}"`}
readOnly
className="flex-1 px-3 py-2 border border-primary-300 dark:border-primary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => copyToClipboard(`curl -s ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host.apiId}" "${host.apiKey}"`)}
className="btn-primary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">Manual Installation</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-3">
If you prefer manual installation, run these commands on the target host:
</p>
<pre className="bg-secondary-900 dark:bg-secondary-800 text-secondary-100 dark:text-secondary-200 p-4 rounded-md text-sm overflow-x-auto">
<code>{commands}</code>
</pre>
<button
onClick={() => copyToClipboard(commands)}
className="mt-3 btn-outline flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy Commands
</button>
</div>
</div>
)}
<div className="flex justify-end pt-6">
<button onClick={onClose} className="btn-primary">
Close
</button>
</div>
</div>
</div>
)
}
// Delete Confirmation Modal Component
const DeleteConfirmationModal = ({ host, isOpen, onClose, onConfirm, isLoading }) => {
if (!isOpen || !host) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 dark:bg-danger-900 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600 dark:text-danger-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Delete Host
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300">
This action cannot be undone
</p>
</div>
</div>
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-300">
Are you sure you want to delete the host{' '}
<span className="font-semibold">"{host.hostname}"</span>?
</p>
<div className="mt-3 p-3 bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md">
<p className="text-sm text-danger-800 dark:text-danger-200">
<strong>Warning:</strong> This will permanently remove the host and all its associated data,
including package information and update history.
</p>
</div>
</div>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
onClick={onConfirm}
className="btn-danger"
disabled={isLoading}
>
{isLoading ? 'Deleting...' : 'Delete Host'}
</button>
</div>
</div>
</div>
)
}
export default HostDetail

View File

@@ -0,0 +1,498 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Plus,
Edit,
Trash2,
Server,
Users,
AlertTriangle,
CheckCircle
} from 'lucide-react'
import { hostGroupsAPI } from '../utils/api'
const HostGroups = () => {
const [showCreateModal, setShowCreateModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [selectedGroup, setSelectedGroup] = useState(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [groupToDelete, setGroupToDelete] = useState(null)
const queryClient = useQueryClient()
// Fetch host groups
const { data: hostGroups, isLoading, error } = useQuery({
queryKey: ['hostGroups'],
queryFn: () => hostGroupsAPI.list().then(res => res.data),
})
// Create host group mutation
const createMutation = useMutation({
mutationFn: (data) => hostGroupsAPI.create(data),
onSuccess: () => {
queryClient.invalidateQueries(['hostGroups'])
setShowCreateModal(false)
},
onError: (error) => {
console.error('Failed to create host group:', error)
}
})
// Update host group mutation
const updateMutation = useMutation({
mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries(['hostGroups'])
setShowEditModal(false)
setSelectedGroup(null)
},
onError: (error) => {
console.error('Failed to update host group:', error)
}
})
// Delete host group mutation
const deleteMutation = useMutation({
mutationFn: (id) => hostGroupsAPI.delete(id),
onSuccess: () => {
queryClient.invalidateQueries(['hostGroups'])
setShowDeleteModal(false)
setGroupToDelete(null)
},
onError: (error) => {
console.error('Failed to delete host group:', error)
}
})
const handleCreate = (data) => {
createMutation.mutate(data)
}
const handleEdit = (group) => {
setSelectedGroup(group)
setShowEditModal(true)
}
const handleUpdate = (data) => {
updateMutation.mutate({ id: selectedGroup.id, data })
}
const handleDeleteClick = (group) => {
setGroupToDelete(group)
setShowDeleteModal(true)
}
const handleDeleteConfirm = () => {
deleteMutation.mutate(groupToDelete.id)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)
}
if (error) {
return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading host groups
</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || 'Failed to load host groups'}
</p>
</div>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-secondary-600 dark:text-secondary-300">
Organize your hosts into logical groups for better management
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Create Group
</button>
</div>
{/* Host Groups Grid */}
{hostGroups && hostGroups.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{hostGroups.map((group) => (
<div key={group.id} className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }}
/>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
{group.name}
</h3>
{group.description && (
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-1">
{group.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(group)}
className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
title="Edit group"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteClick(group)}
className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
title="Delete group"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-4 flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-300">
<div className="flex items-center gap-1">
<Server className="h-4 w-4" />
<span>{group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
No host groups yet
</h3>
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
Create your first host group to organize your hosts
</p>
<button
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2 mx-auto"
>
<Plus className="h-4 w-4" />
Create Group
</button>
</div>
)}
{/* Create Modal */}
{showCreateModal && (
<CreateHostGroupModal
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreate}
isLoading={createMutation.isPending}
/>
)}
{/* Edit Modal */}
{showEditModal && selectedGroup && (
<EditHostGroupModal
group={selectedGroup}
onClose={() => {
setShowEditModal(false)
setSelectedGroup(null)
}}
onSubmit={handleUpdate}
isLoading={updateMutation.isPending}
/>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && groupToDelete && (
<DeleteHostGroupModal
group={groupToDelete}
onClose={() => {
setShowDeleteModal(false)
setGroupToDelete(null)
}}
onConfirm={handleDeleteConfirm}
isLoading={deleteMutation.isPending}
/>
)}
</div>
)
}
// Create Host Group Modal
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
const [formData, setFormData] = useState({
name: '',
description: '',
color: '#3B82F6'
})
const handleSubmit = (e) => {
e.preventDefault()
onSubmit(formData)
}
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
Create Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Name *
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="#3B82F6"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={isLoading}
>
{isLoading ? 'Creating...' : 'Create Group'}
</button>
</div>
</form>
</div>
</div>
)
}
// Edit Host Group Modal
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
const [formData, setFormData] = useState({
name: group.name,
description: group.description || '',
color: group.color || '#3B82F6'
})
const handleSubmit = (e) => {
e.preventDefault()
onSubmit(formData)
}
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
Edit Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Name *
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="#3B82F6"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={isLoading}
>
{isLoading ? 'Updating...' : 'Update Group'}
</button>
</div>
</form>
</div>
</div>
)
}
// Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Delete Host Group
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300">
This action cannot be undone
</p>
</div>
</div>
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-200">
Are you sure you want to delete the host group{' '}
<span className="font-semibold">"{group.name}"</span>?
</p>
{group._count.hosts > 0 && (
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
<p className="text-sm text-warning-800">
<strong>Warning:</strong> This group contains {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}.
You must move or remove these hosts before deleting the group.
</p>
</div>
)}
</div>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
onClick={onConfirm}
className="btn-danger"
disabled={isLoading || group._count.hosts > 0}
>
{isLoading ? 'Deleting...' : 'Delete Group'}
</button>
</div>
</div>
</div>
)
}
export default HostGroups

1598
frontend/src/pages/Hosts.jsx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,158 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Eye, EyeOff, Lock, User, AlertCircle } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
const Login = () => {
const [formData, setFormData] = useState({
username: '',
password: ''
})
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const navigate = useNavigate()
const { login } = useAuth()
const handleSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
try {
const result = await login(formData.username, formData.password)
if (result.success) {
// Redirect to dashboard
navigate('/')
} else {
setError(result.error || 'Login failed')
}
} catch (err) {
setError('Network error occurred')
} finally {
setIsLoading(false)
}
}
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100">
<Lock className="h-6 w-6 text-primary-600" />
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900">
Sign in to PatchMon
</h2>
<p className="mt-2 text-center text-sm text-secondary-600">
Monitor and manage your Linux package updates
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-secondary-700">
Username or Email
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-secondary-400" />
</div>
<input
id="username"
name="username"
type="text"
required
value={formData.username}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Enter your username or email"
/>
</div>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-secondary-700">
Password
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-secondary-400" />
</div>
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
required
value={formData.password}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-10 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Enter your password"
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-secondary-400 hover:text-secondary-500"
>
{showPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
</div>
</div>
</div>
</div>
{error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<div className="flex">
<AlertCircle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<p className="text-sm text-danger-700">{error}</p>
</div>
</div>
</div>
)}
<div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md 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>
<div className="text-center">
<p className="text-sm text-secondary-600">
Need help? Contact your system administrator.
</p>
</div>
</form>
</div>
</div>
)
}
export default Login

View File

@@ -0,0 +1,25 @@
import React from 'react'
import { useParams } from 'react-router-dom'
import { Package } from 'lucide-react'
const PackageDetail = () => {
const { packageId } = useParams()
return (
<div className="space-y-6">
<div className="card p-8 text-center">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 mb-2">Package Details</h3>
<p className="text-secondary-600">
Detailed view for package: {packageId}
</p>
<p className="text-secondary-600 mt-2">
This page will show package information, affected hosts, version distribution, and more.
</p>
</div>
</div>
)
}
export default PackageDetail

View File

@@ -0,0 +1,294 @@
import React, { useState, useEffect } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import {
Package,
Server,
Shield,
RefreshCw,
Search,
AlertTriangle,
Filter,
ExternalLink
} from 'lucide-react'
import { dashboardAPI } from '../utils/api'
const Packages = () => {
const [searchTerm, setSearchTerm] = useState('')
const [categoryFilter, setCategoryFilter] = useState('all')
const [securityFilter, setSecurityFilter] = useState('all')
const [searchParams] = useSearchParams()
// Handle URL filter parameters
useEffect(() => {
const filter = searchParams.get('filter')
if (filter === 'outdated') {
// For outdated packages, we want to show all packages that need updates
// This is the default behavior, so we don't need to change filters
setCategoryFilter('all')
setSecurityFilter('all')
} else if (filter === 'security') {
// For security updates, filter to show only security updates
setSecurityFilter('security')
setCategoryFilter('all')
}
}, [searchParams])
const { data: packages, isLoading, error, refetch } = useQuery({
queryKey: ['packages'],
queryFn: () => dashboardAPI.getPackages().then(res => res.data),
refetchInterval: 60000,
staleTime: 30000,
})
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
</div>
)
}
if (error) {
return (
<div className="space-y-6">
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">Error loading packages</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || 'Failed to load packages'}
</p>
<button
onClick={() => refetch()}
className="mt-2 btn-danger text-xs"
>
Try again
</button>
</div>
</div>
</div>
</div>
)
}
// Filter packages based on search and filters
const filteredPackages = packages?.filter(pkg => {
const matchesSearch = pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(pkg.description && pkg.description.toLowerCase().includes(searchTerm.toLowerCase()))
const matchesCategory = categoryFilter === 'all' || pkg.category === categoryFilter
const matchesSecurity = securityFilter === 'all' ||
(securityFilter === 'security' && pkg.isSecurityUpdate) ||
(securityFilter === 'regular' && !pkg.isSecurityUpdate)
return matchesSearch && matchesCategory && matchesSecurity
}) || []
// Get unique categories
const categories = [...new Set(packages?.map(pkg => pkg.category).filter(Boolean))] || []
// Calculate unique affected hosts
const uniqueAffectedHosts = new Set()
packages?.forEach(pkg => {
pkg.affectedHosts.forEach(host => {
uniqueAffectedHosts.add(host.hostId)
})
})
const uniqueAffectedHostsCount = uniqueAffectedHosts.size
return (
<div className="space-y-6">
{/* Summary Stats */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
<div className="card p-4">
<div className="flex items-center">
<Package className="h-5 w-5 text-primary-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Total Packages</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{packages?.length || 0}</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<Shield className="h-5 w-5 text-danger-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Security Updates</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{packages?.filter(pkg => pkg.isSecurityUpdate).length || 0}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<Server className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Affected Hosts</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{uniqueAffectedHostsCount}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<Filter className="h-5 w-5 text-secondary-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Categories</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{categories.length}</p>
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="card p-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-secondary-400 dark:text-secondary-500" />
<input
type="text"
placeholder="Search packages..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
/>
</div>
</div>
{/* Category Filter */}
<div className="sm:w-48">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Categories</option>
{categories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
{/* Security Filter */}
<div className="sm:w-48">
<select
value={securityFilter}
onChange={(e) => setSecurityFilter(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Updates</option>
<option value="security">Security Only</option>
<option value="regular">Regular Only</option>
</select>
</div>
</div>
</div>
{/* Packages List */}
<div className="card">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Packages Needing Updates ({filteredPackages.length})
</h3>
{filteredPackages.length === 0 ? (
<div className="text-center py-8">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
{packages?.length === 0 ? 'No packages need updates' : 'No packages match your filters'}
</p>
{packages?.length === 0 && (
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
All packages are up to date across all hosts
</p>
)}
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Package
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Latest Version
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Affected Hosts
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Priority
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredPackages.map((pkg) => (
<tr key={pkg.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Package className="h-5 w-5 text-secondary-400 mr-3" />
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{pkg.name}
</div>
{pkg.description && (
<div className="text-sm text-secondary-500 dark:text-secondary-300 max-w-md truncate">
{pkg.description}
</div>
)}
{pkg.category && (
<div className="text-xs text-secondary-400 dark:text-secondary-400">
Category: {pkg.category}
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
{pkg.latestVersion || 'Unknown'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-secondary-900 dark:text-white">
{pkg.affectedHostsCount} host{pkg.affectedHostsCount !== 1 ? 's' : ''}
</div>
<div className="text-xs text-secondary-500 dark:text-secondary-300">
{pkg.affectedHosts.slice(0, 2).map(host => host.hostname).join(', ')}
{pkg.affectedHosts.length > 2 && ` +${pkg.affectedHosts.length - 2} more`}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{pkg.isSecurityUpdate ? (
<span className="badge-danger flex items-center gap-1">
<Shield className="h-3 w-3" />
Security Update
</span>
) : (
<span className="badge-warning">Regular Update</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
)
}
export default Packages

View File

@@ -0,0 +1,389 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Shield,
Settings,
Users,
Server,
Package,
BarChart3,
Download,
Eye,
Edit,
Trash2,
Plus,
Save,
X,
AlertTriangle,
RefreshCw
} from 'lucide-react'
import { permissionsAPI } from '../utils/api'
import { useAuth } from '../contexts/AuthContext'
const Permissions = () => {
const [editingRole, setEditingRole] = useState(null)
const [showAddModal, setShowAddModal] = useState(false)
const queryClient = useQueryClient()
const { refreshPermissions } = useAuth()
// Fetch all role permissions
const { data: roles, isLoading, error } = useQuery({
queryKey: ['rolePermissions'],
queryFn: () => permissionsAPI.getRoles().then(res => res.data)
})
// Update role permissions mutation
const updateRoleMutation = useMutation({
mutationFn: ({ role, permissions }) => permissionsAPI.updateRole(role, permissions),
onSuccess: () => {
queryClient.invalidateQueries(['rolePermissions'])
setEditingRole(null)
// Refresh user permissions to apply changes immediately
refreshPermissions()
}
})
// Delete role mutation
const deleteRoleMutation = useMutation({
mutationFn: (role) => permissionsAPI.deleteRole(role),
onSuccess: () => {
queryClient.invalidateQueries(['rolePermissions'])
}
})
const handleSavePermissions = async (role, permissions) => {
try {
await updateRoleMutation.mutateAsync({ role, permissions })
} catch (error) {
console.error('Failed to update permissions:', error)
}
}
const handleDeleteRole = async (role) => {
if (window.confirm(`Are you sure you want to delete the "${role}" role? This action cannot be undone.`)) {
try {
await deleteRoleMutation.mutateAsync(role)
} catch (error) {
console.error('Failed to delete role:', error)
}
}
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)
}
if (error) {
return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">Error loading permissions</h3>
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-end items-center">
<div className="flex space-x-3">
<button
onClick={() => refreshPermissions()}
className="inline-flex items-center px-4 py-2 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh Permissions
</button>
<button
onClick={() => setShowAddModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<Plus className="h-4 w-4 mr-2" />
Add Role
</button>
</div>
</div>
{/* Roles List */}
<div className="space-y-4">
{roles && Array.isArray(roles) && roles.map((role) => (
<RolePermissionsCard
key={role.id}
role={role}
isEditing={editingRole === role.role}
onEdit={() => setEditingRole(role.role)}
onCancel={() => setEditingRole(null)}
onSave={handleSavePermissions}
onDelete={handleDeleteRole}
/>
))}
</div>
{/* Add Role Modal */}
<AddRoleModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSuccess={() => {
queryClient.invalidateQueries(['rolePermissions'])
setShowAddModal(false)
}}
/>
</div>
)
}
// Role Permissions Card Component
const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDelete }) => {
const [permissions, setPermissions] = useState(role)
const permissionFields = [
{ key: 'canViewDashboard', label: 'View Dashboard', icon: BarChart3, description: 'Access to the main dashboard' },
{ key: 'canViewHosts', label: 'View Hosts', icon: Server, description: 'See host information and status' },
{ key: 'canManageHosts', label: 'Manage Hosts', icon: Edit, description: 'Add, edit, and delete hosts' },
{ key: 'canViewPackages', label: 'View Packages', icon: Package, description: 'See package information' },
{ key: 'canManagePackages', label: 'Manage Packages', icon: Settings, description: 'Edit package details' },
{ key: 'canViewUsers', label: 'View Users', icon: Users, description: 'See user list and details' },
{ key: 'canManageUsers', label: 'Manage Users', icon: Shield, description: 'Add, edit, and delete users' },
{ key: 'canViewReports', label: 'View Reports', icon: BarChart3, description: 'Access to reports and analytics' },
{ key: 'canExportData', label: 'Export Data', icon: Download, description: 'Download data and reports' },
{ key: 'canManageSettings', label: 'Manage Settings', icon: Settings, description: 'System configuration access' }
]
const handlePermissionChange = (key, value) => {
setPermissions(prev => ({
...prev,
[key]: value
}))
}
const handleSave = () => {
onSave(role.role, permissions)
}
const isAdminRole = role.role === 'admin'
return (
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<div className="flex items-center">
<Shield className="h-5 w-5 text-primary-600 mr-3" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white capitalize">{role.role}</h3>
{isAdminRole && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
System Role
</span>
)}
</div>
<div className="flex items-center space-x-2">
{isEditing ? (
<>
<button
onClick={handleSave}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700"
>
<Save className="h-4 w-4 mr-1" />
Save
</button>
<button
onClick={onCancel}
className="inline-flex items-center px-3 py-1 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50"
>
<X className="h-4 w-4 mr-1" />
Cancel
</button>
</>
) : (
<>
<button
onClick={onEdit}
disabled={isAdminRole}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</button>
{!isAdminRole && (
<button
onClick={() => onDelete(role.role)}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</button>
)}
</>
)}
</div>
</div>
</div>
<div className="px-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{permissionFields.map((field) => {
const Icon = field.icon
const isChecked = permissions[field.key]
return (
<div key={field.key} className="flex items-start">
<div className="flex items-center h-5">
<input
type="checkbox"
checked={isChecked}
onChange={(e) => handlePermissionChange(field.key, e.target.checked)}
disabled={!isEditing || (isAdminRole && field.key === 'canManageUsers')}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50"
/>
</div>
<div className="ml-3">
<div className="flex items-center">
<Icon className="h-4 w-4 text-secondary-400 mr-2" />
<label className="text-sm font-medium text-secondary-900 dark:text-white">
{field.label}
</label>
</div>
<p className="text-xs text-secondary-500 mt-1">
{field.description}
</p>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
// Add Role Modal Component
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
const [formData, setFormData] = useState({
role: '',
canViewDashboard: true,
canViewHosts: true,
canManageHosts: false,
canViewPackages: true,
canManagePackages: false,
canViewUsers: false,
canManageUsers: false,
canViewReports: true,
canExportData: false,
canManageSettings: false
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
try {
await permissionsAPI.updateRole(formData.role, formData)
onSuccess()
} catch (err) {
setError(err.response?.data?.error || 'Failed to create role')
} finally {
setIsLoading(false)
}
}
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target
setFormData({
...formData,
[name]: type === 'checkbox' ? checked : value
})
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-medium text-secondary-900 mb-4">Add New Role</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
Role Name
</label>
<input
type="text"
name="role"
required
value={formData.role}
onChange={handleInputChange}
className="block w-full border-secondary-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
placeholder="e.g., host_manager, readonly"
/>
<p className="mt-1 text-xs text-secondary-500">Use lowercase with underscores (e.g., host_manager)</p>
</div>
<div className="space-y-3">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white">Permissions</h4>
{[
{ key: 'canViewDashboard', label: 'View Dashboard' },
{ key: 'canViewHosts', label: 'View Hosts' },
{ key: 'canManageHosts', label: 'Manage Hosts' },
{ key: 'canViewPackages', label: 'View Packages' },
{ key: 'canManagePackages', label: 'Manage Packages' },
{ key: 'canViewUsers', label: 'View Users' },
{ key: 'canManageUsers', label: 'Manage Users' },
{ key: 'canViewReports', label: 'View Reports' },
{ key: 'canExportData', label: 'Export Data' },
{ key: 'canManageSettings', label: 'Manage Settings' }
].map((permission) => (
<div key={permission.key} className="flex items-center">
<input
type="checkbox"
name={permission.key}
checked={formData[permission.key]}
onChange={handleInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label className="ml-2 block text-sm text-secondary-700">
{permission.label}
</label>
</div>
))}
</div>
{error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<p className="text-sm text-danger-700">{error}</p>
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 bg-white border border-secondary-300 rounded-md hover:bg-secondary-50"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? 'Creating...' : 'Create Role'}
</button>
</div>
</form>
</div>
</div>
)
}
export default Permissions

View File

@@ -0,0 +1,414 @@
import React, { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
import {
User,
Mail,
Shield,
Key,
Save,
Eye,
EyeOff,
CheckCircle,
AlertCircle,
Sun,
Moon,
Settings
} from 'lucide-react'
const Profile = () => {
const { user, updateProfile, changePassword } = useAuth()
const { theme, toggleTheme, isDark } = useTheme()
const [activeTab, setActiveTab] = useState('profile')
const [isLoading, setIsLoading] = useState(false)
const [message, setMessage] = useState({ type: '', text: '' })
const [profileData, setProfileData] = useState({
username: user?.username || '',
email: user?.email || ''
})
const [passwordData, setPasswordData] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: ''
})
const [showPasswords, setShowPasswords] = useState({
current: false,
new: false,
confirm: false
})
const handleProfileSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setMessage({ type: '', text: '' })
try {
const result = await updateProfile(profileData)
if (result.success) {
setMessage({ type: 'success', text: 'Profile updated successfully!' })
} else {
setMessage({ type: 'error', text: result.error || 'Failed to update profile' })
}
} catch (error) {
setMessage({ type: 'error', text: 'Network error occurred' })
} finally {
setIsLoading(false)
}
}
const handlePasswordSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setMessage({ type: '', text: '' })
if (passwordData.newPassword !== passwordData.confirmPassword) {
setMessage({ type: 'error', text: 'New passwords do not match' })
setIsLoading(false)
return
}
if (passwordData.newPassword.length < 6) {
setMessage({ type: 'error', text: 'New password must be at least 6 characters' })
setIsLoading(false)
return
}
try {
const result = await changePassword(passwordData.currentPassword, passwordData.newPassword)
if (result.success) {
setMessage({ type: 'success', text: 'Password changed successfully!' })
setPasswordData({
currentPassword: '',
newPassword: '',
confirmPassword: ''
})
} else {
setMessage({ type: 'error', text: result.error || 'Failed to change password' })
}
} catch (error) {
setMessage({ type: 'error', text: 'Network error occurred' })
} finally {
setIsLoading(false)
}
}
const handleInputChange = (e) => {
const { name, value } = e.target
if (activeTab === 'profile') {
setProfileData(prev => ({ ...prev, [name]: value }))
} else {
setPasswordData(prev => ({ ...prev, [name]: value }))
}
}
const togglePasswordVisibility = (field) => {
setShowPasswords(prev => ({ ...prev, [field]: !prev[field] }))
}
const tabs = [
{ id: 'profile', name: 'Profile Information', icon: User },
{ id: 'password', name: 'Change Password', icon: Key },
{ id: 'preferences', name: 'Preferences', icon: Settings }
]
return (
<div className="space-y-6">
{/* Header */}
<div>
<p className="text-sm text-secondary-600 dark:text-secondary-300">
Manage your account information and security settings
</p>
</div>
{/* User Info Card */}
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
<div className="flex items-center space-x-4">
<div className="flex-shrink-0">
<div className="h-16 w-16 rounded-full bg-primary-100 flex items-center justify-center">
<User className="h-8 w-8 text-primary-600" />
</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">{user?.username}</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300">{user?.email}</p>
<div className="mt-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user?.role === 'admin'
? 'bg-primary-100 text-primary-800'
: user?.role === 'host_manager'
? 'bg-green-100 text-green-800'
: user?.role === 'readonly'
? 'bg-yellow-100 text-yellow-800'
: 'bg-secondary-100 text-secondary-800'
}`}>
<Shield className="h-3 w-3 mr-1" />
{user?.role?.charAt(0).toUpperCase() + user?.role?.slice(1).replace('_', ' ')}
</span>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
<div className="border-b border-secondary-200 dark:border-secondary-600">
<nav className="-mb-px flex space-x-8 px-6">
{tabs.map((tab) => {
const Icon = tab.icon
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center ${
activeTab === tab.id
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500'
}`}
>
<Icon className="h-4 w-4 mr-2" />
{tab.name}
</button>
)
})}
</nav>
</div>
<div className="p-6">
{/* Success/Error Message */}
{message.text && (
<div className={`mb-6 rounded-md p-4 ${
message.type === 'success'
? 'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700'
: 'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700'
}`}>
<div className="flex">
{message.type === 'success' ? (
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
) : (
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
)}
<div className="ml-3">
<p className={`text-sm font-medium ${
message.type === 'success' ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'
}`}>
{message.text}
</p>
</div>
</div>
</div>
)}
{/* Profile Information Tab */}
{activeTab === 'profile' && (
<form onSubmit={handleProfileSubmit} className="space-y-6">
<div>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Profile Information</h3>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
<div>
<label htmlFor="username" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200">
Username
</label>
<div className="mt-1 relative">
<input
type="text"
name="username"
id="username"
value={profileData.username}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 pl-10 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
required
/>
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
</div>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200">
Email Address
</label>
<div className="mt-1 relative">
<input
type="email"
name="email"
id="email"
value={profileData.email}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 pl-10 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
required
/>
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isLoading}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md 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"
>
<Save className="h-4 w-4 mr-2" />
{isLoading ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
)}
{/* Change Password Tab */}
{activeTab === 'password' && (
<form onSubmit={handlePasswordSubmit} className="space-y-6">
<div>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Change Password</h3>
<div className="space-y-4">
<div>
<label htmlFor="currentPassword" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200">
Current Password
</label>
<div className="mt-1 relative">
<input
type={showPasswords.current ? 'text' : 'password'}
name="currentPassword"
id="currentPassword"
value={passwordData.currentPassword}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 pl-10 pr-10 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
required
/>
<Key className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
<button
type="button"
onClick={() => togglePasswordVisibility('current')}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-secondary-400 dark:text-secondary-500 hover:text-secondary-600 dark:hover:text-secondary-300"
>
{showPasswords.current ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
<div>
<label htmlFor="newPassword" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200">
New Password
</label>
<div className="mt-1 relative">
<input
type={showPasswords.new ? 'text' : 'password'}
name="newPassword"
id="newPassword"
value={passwordData.newPassword}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 pl-10 pr-10 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
required
minLength="6"
/>
<Key className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
<button
type="button"
onClick={() => togglePasswordVisibility('new')}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-secondary-400 dark:text-secondary-500 hover:text-secondary-600 dark:hover:text-secondary-300"
>
{showPasswords.new ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">Must be at least 6 characters long</p>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200">
Confirm New Password
</label>
<div className="mt-1 relative">
<input
type={showPasswords.confirm ? 'text' : 'password'}
name="confirmPassword"
id="confirmPassword"
value={passwordData.confirmPassword}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 pl-10 pr-10 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
required
minLength="6"
/>
<Key className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
<button
type="button"
onClick={() => togglePasswordVisibility('confirm')}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-secondary-400 dark:text-secondary-500 hover:text-secondary-600 dark:hover:text-secondary-300"
>
{showPasswords.confirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
</div>
</div>
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isLoading}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md 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"
>
<Key className="h-4 w-4 mr-2" />
{isLoading ? 'Changing...' : 'Change Password'}
</button>
</div>
</form>
)}
{/* Preferences Tab */}
{activeTab === 'preferences' && (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Preferences</h3>
{/* Theme Settings */}
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-3">Appearance</h4>
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="flex-shrink-0">
{isDark ? (
<Moon className="h-5 w-5 text-secondary-600 dark:text-secondary-400" />
) : (
<Sun className="h-5 w-5 text-secondary-600 dark:text-secondary-400" />
)}
</div>
<div>
<p className="text-sm font-medium text-secondary-900 dark:text-white">
{isDark ? 'Dark Mode' : 'Light Mode'}
</p>
<p className="text-xs text-secondary-500 dark:text-secondary-400">
{isDark ? 'Switch to light mode' : 'Switch to dark mode'}
</p>
</div>
</div>
<button
onClick={toggleTheme}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
isDark ? 'bg-primary-600' : 'bg-secondary-300'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
isDark ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
</div>
</div>
)
}
export default Profile

View File

@@ -0,0 +1,272 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
Server,
Shield,
ShieldCheck,
AlertTriangle,
Users,
Globe,
Lock,
Unlock,
Database,
Eye
} from 'lucide-react';
import { repositoryAPI } from '../utils/api';
const Repositories = () => {
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState('all'); // all, secure, insecure
const [filterStatus, setFilterStatus] = useState('all'); // all, active, inactive
// Fetch repositories
const { data: repositories = [], isLoading, error } = useQuery({
queryKey: ['repositories'],
queryFn: () => repositoryAPI.list().then(res => res.data)
});
// Fetch repository statistics
const { data: stats } = useQuery({
queryKey: ['repository-stats'],
queryFn: () => repositoryAPI.getStats().then(res => res.data)
});
// Filter repositories based on search and filters
const filteredRepositories = repositories.filter(repo => {
const matchesSearch = repo.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
repo.url.toLowerCase().includes(searchTerm.toLowerCase()) ||
repo.distribution.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = filterType === 'all' ||
(filterType === 'secure' && repo.isSecure) ||
(filterType === 'insecure' && !repo.isSecure);
const matchesStatus = filterStatus === 'all' ||
(filterStatus === 'active' && repo.isActive) ||
(filterStatus === 'inactive' && !repo.isActive);
return matchesSearch && matchesType && matchesStatus;
});
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center">
<AlertTriangle className="h-5 w-5 text-red-400 mr-2" />
<span className="text-red-700 dark:text-red-300">
Failed to load repositories: {error.message}
</span>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
Repositories
</h1>
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
Manage and monitor package repositories across your infrastructure
</p>
</div>
</div>
{/* Statistics Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Database className="h-8 w-8 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Total Repositories</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.totalRepositories}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Server className="h-8 w-8 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Active Repositories</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.activeRepositories}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Shield className="h-8 w-8 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Secure (HTTPS)</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.secureRepositories}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="relative">
<ShieldCheck className="h-8 w-8 text-green-600" />
<span className="absolute -top-1 -right-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs font-medium px-1.5 py-0.5 rounded-full">
{stats.securityPercentage}%
</span>
</div>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Security Score</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.securityPercentage}%</p>
</div>
</div>
</div>
</div>
)}
{/* Search and Filters */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow p-6">
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<input
type="text"
placeholder="Search repositories..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
/>
</div>
{/* Security Filter */}
<div>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
>
<option value="all">All Security Types</option>
<option value="secure">HTTPS Only</option>
<option value="insecure">HTTP Only</option>
</select>
</div>
{/* Status Filter */}
<div>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
>
<option value="all">All Statuses</option>
<option value="active">Active Only</option>
<option value="inactive">Inactive Only</option>
</select>
</div>
</div>
</div>
{/* Repositories List */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Repositories ({filteredRepositories.length})
</h2>
</div>
{filteredRepositories.length === 0 ? (
<div className="px-6 py-12 text-center">
<Database className="mx-auto h-12 w-12 text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">No repositories found</h3>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
{searchTerm || filterType !== 'all' || filterStatus !== 'all'
? 'Try adjusting your search or filters.'
: 'No repositories have been reported by your hosts yet.'}
</p>
</div>
) : (
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
{filteredRepositories.map((repo) => (
<div key={repo.id} className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
{repo.isSecure ? (
<Lock className="h-4 w-4 text-green-600" />
) : (
<Unlock className="h-4 w-4 text-orange-600" />
)}
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
{repo.name}
</h3>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
repo.isActive
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}>
{repo.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</div>
<div className="mt-2 space-y-1">
<p className="text-sm text-secondary-600 dark:text-secondary-300">
<Globe className="inline h-4 w-4 mr-1" />
{repo.url}
</p>
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400">
<span>Distribution: <span className="font-medium">{repo.distribution}</span></span>
<span>Type: <span className="font-medium">{repo.repoType}</span></span>
<span>Components: <span className="font-medium">{repo.components}</span></span>
</div>
</div>
</div>
<div className="flex items-center gap-4">
{/* Host Count */}
<div className="text-center">
<div className="flex items-center gap-1 text-sm text-secondary-500 dark:text-secondary-400">
<Users className="h-4 w-4" />
<span>{repo.hostCount} hosts</span>
</div>
</div>
{/* View Details */}
<Link
to={`/repositories/${repo.id}`}
className="btn-outline text-sm flex items-center gap-1"
>
<Eye className="h-4 w-4" />
View
</Link>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default Repositories;

View File

@@ -0,0 +1,369 @@
import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft,
Server,
Shield,
ShieldOff,
AlertTriangle,
Users,
Globe,
Lock,
Unlock,
Database,
Calendar,
Activity
} from 'lucide-react';
import { repositoryAPI } from '../utils/api';
const RepositoryDetail = () => {
const { repositoryId } = useParams();
const queryClient = useQueryClient();
const [editMode, setEditMode] = useState(false);
const [formData, setFormData] = useState({});
// Fetch repository details
const { data: repository, isLoading, error } = useQuery({
queryKey: ['repository', repositoryId],
queryFn: () => repositoryAPI.getById(repositoryId).then(res => res.data),
enabled: !!repositoryId
});
// Update repository mutation
const updateRepositoryMutation = useMutation({
mutationFn: (data) => repositoryAPI.update(repositoryId, data),
onSuccess: () => {
queryClient.invalidateQueries(['repository', repositoryId]);
queryClient.invalidateQueries(['repositories']);
setEditMode(false);
}
});
const handleEdit = () => {
setFormData({
name: repository.name,
description: repository.description || '',
isActive: repository.isActive,
priority: repository.priority || ''
});
setEditMode(true);
};
const handleSave = () => {
updateRepositoryMutation.mutate(formData);
};
const handleCancel = () => {
setEditMode(false);
setFormData({});
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link
to="/repositories"
className="btn-outline flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to Repositories
</Link>
</div>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center">
<AlertTriangle className="h-5 w-5 text-red-400 mr-2" />
<span className="text-red-700 dark:text-red-300">
Failed to load repository: {error.message}
</span>
</div>
</div>
</div>
);
}
if (!repository) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link
to="/repositories"
className="btn-outline flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to Repositories
</Link>
</div>
<div className="text-center py-12">
<Database className="mx-auto h-12 w-12 text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">Repository not found</h3>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
The repository you're looking for doesn't exist.
</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
to="/repositories"
className="btn-outline flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Link>
<div>
<div className="flex items-center gap-3">
{repository.isSecure ? (
<Lock className="h-6 w-6 text-green-600" />
) : (
<Unlock className="h-6 w-6 text-orange-600" />
)}
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
{repository.name}
</h1>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
repository.isActive
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}>
{repository.isActive ? 'Active' : 'Inactive'}
</span>
</div>
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
Repository configuration and host assignments
</p>
</div>
</div>
<div className="flex items-center gap-2">
{editMode ? (
<>
<button
onClick={handleCancel}
className="btn-outline"
disabled={updateRepositoryMutation.isPending}
>
Cancel
</button>
<button
onClick={handleSave}
className="btn-primary"
disabled={updateRepositoryMutation.isPending}
>
{updateRepositoryMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
</>
) : (
<button
onClick={handleEdit}
className="btn-primary"
>
Edit Repository
</button>
)}
</div>
</div>
{/* Repository Information */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Repository Information
</h2>
</div>
<div className="px-6 py-4 space-y-4">
{editMode ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Repository Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Priority
</label>
<input
type="number"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
placeholder="Optional priority"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows="3"
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
placeholder="Optional description"
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="isActive"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label htmlFor="isActive" className="ml-2 block text-sm text-secondary-900 dark:text-white">
Repository is active
</label>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">URL</label>
<div className="flex items-center mt-1">
<Globe className="h-4 w-4 text-secondary-400 mr-2" />
<span className="text-secondary-900 dark:text-white">{repository.url}</span>
</div>
</div>
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Distribution</label>
<p className="text-secondary-900 dark:text-white mt-1">{repository.distribution}</p>
</div>
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Components</label>
<p className="text-secondary-900 dark:text-white mt-1">{repository.components}</p>
</div>
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Repository Type</label>
<p className="text-secondary-900 dark:text-white mt-1">{repository.repoType}</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Security</label>
<div className="flex items-center mt-1">
{repository.isSecure ? (
<>
<Shield className="h-4 w-4 text-green-600 mr-2" />
<span className="text-green-600">Secure (HTTPS)</span>
</>
) : (
<>
<ShieldOff className="h-4 w-4 text-orange-600 mr-2" />
<span className="text-orange-600">Insecure (HTTP)</span>
</>
)}
</div>
</div>
{repository.priority && (
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Priority</label>
<p className="text-secondary-900 dark:text-white mt-1">{repository.priority}</p>
</div>
)}
{repository.description && (
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Description</label>
<p className="text-secondary-900 dark:text-white mt-1">{repository.description}</p>
</div>
)}
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Created</label>
<div className="flex items-center mt-1">
<Calendar className="h-4 w-4 text-secondary-400 mr-2" />
<span className="text-secondary-900 dark:text-white">
{new Date(repository.createdAt).toLocaleDateString()}
</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
{/* Hosts Using This Repository */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white flex items-center gap-2">
<Users className="h-5 w-5" />
Hosts Using This Repository ({repository.hostRepositories?.length || 0})
</h2>
</div>
{!repository.hostRepositories || repository.hostRepositories.length === 0 ? (
<div className="px-6 py-12 text-center">
<Server className="mx-auto h-12 w-12 text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">No hosts using this repository</h3>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
This repository hasn't been reported by any hosts yet.
</p>
</div>
) : (
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
{repository.hostRepositories.map((hostRepo) => (
<div key={hostRepo.id} className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${
hostRepo.host.status === 'active'
? 'bg-green-500'
: hostRepo.host.status === 'pending'
? 'bg-yellow-500'
: 'bg-red-500'
}`} />
<div>
<Link
to={`/hosts/${hostRepo.host.id}`}
className="text-primary-600 hover:text-primary-700 font-medium"
>
{hostRepo.host.hostname}
</Link>
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
<span>IP: {hostRepo.host.ip}</span>
<span>OS: {hostRepo.host.osType} {hostRepo.host.osVersion}</span>
<span>Last Update: {new Date(hostRepo.host.lastUpdate).toLocaleDateString()}</span>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-xs text-secondary-500 dark:text-secondary-400">Last Checked</div>
<div className="text-sm text-secondary-900 dark:text-white">
{new Date(hostRepo.lastChecked).toLocaleDateString()}
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default RepositoryDetail;

View File

@@ -0,0 +1,824 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Save, Server, Globe, Shield, AlertCircle, CheckCircle, Code, Plus, Trash2, Star, Download, X, Settings as SettingsIcon } from 'lucide-react';
import { settingsAPI, agentVersionAPI } from '../utils/api';
const Settings = () => {
const [formData, setFormData] = useState({
serverProtocol: 'http',
serverHost: 'localhost',
serverPort: 3001,
frontendUrl: 'http://localhost:3000',
updateInterval: 60,
autoUpdate: false
});
const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false);
// Tab management
const [activeTab, setActiveTab] = useState('server');
// Tab configuration
const tabs = [
{ id: 'server', name: 'Server Configuration', icon: Server },
{ id: 'frontend', name: 'Frontend Configuration', icon: Globe },
{ id: 'agent', name: 'Agent Management', icon: SettingsIcon }
];
// Agent version management state
const [showAgentVersionModal, setShowAgentVersionModal] = useState(false);
const [editingAgentVersion, setEditingAgentVersion] = useState(null);
const [agentVersionForm, setAgentVersionForm] = useState({
version: '',
releaseNotes: '',
scriptContent: '',
isDefault: false
});
const queryClient = useQueryClient();
// Fetch current settings
const { data: settings, isLoading, error } = useQuery({
queryKey: ['settings'],
queryFn: () => settingsAPI.get().then(res => res.data)
});
// Update form data when settings are loaded
useEffect(() => {
if (settings) {
console.log('Settings loaded:', settings);
console.log('updateInterval from settings:', settings.updateInterval);
const newFormData = {
serverProtocol: settings.serverProtocol || 'http',
serverHost: settings.serverHost || 'localhost',
serverPort: settings.serverPort || 3001,
frontendUrl: settings.frontendUrl || 'http://localhost:3000',
updateInterval: settings.updateInterval || 60,
autoUpdate: settings.autoUpdate || false
};
console.log('Setting form data to:', newFormData);
setFormData(newFormData);
setIsDirty(false);
}
}, [settings]);
// Update settings mutation
const updateSettingsMutation = useMutation({
mutationFn: (data) => {
console.log('Mutation called with data:', data);
return settingsAPI.update(data).then(res => {
console.log('API response:', res);
return res.data;
});
},
onSuccess: (data) => {
console.log('Mutation success:', data);
console.log('Invalidating queries and updating form data');
queryClient.invalidateQueries(['settings']);
// Update form data with the returned data
setFormData({
serverProtocol: data.serverProtocol || 'http',
serverHost: data.serverHost || 'localhost',
serverPort: data.serverPort || 3001,
frontendUrl: data.frontendUrl || 'http://localhost:3000',
updateInterval: data.updateInterval || 60,
autoUpdate: data.autoUpdate || false
});
setIsDirty(false);
setErrors({});
},
onError: (error) => {
console.log('Mutation error:', error);
if (error.response?.data?.errors) {
setErrors(error.response.data.errors.reduce((acc, err) => {
acc[err.path] = err.msg;
return acc;
}, {}));
} else {
setErrors({ general: error.response?.data?.error || 'Failed to update settings' });
}
}
});
// Agent version queries and mutations
const { data: agentVersions, isLoading: agentVersionsLoading, error: agentVersionsError } = useQuery({
queryKey: ['agentVersions'],
queryFn: () => {
console.log('Fetching agent versions...');
return agentVersionAPI.list().then(res => {
console.log('Agent versions API response:', res);
return res.data;
});
}
});
// Debug agent versions
useEffect(() => {
console.log('Agent versions data:', agentVersions);
console.log('Agent versions loading:', agentVersionsLoading);
console.log('Agent versions error:', agentVersionsError);
}, [agentVersions, agentVersionsLoading, agentVersionsError]);
const createAgentVersionMutation = useMutation({
mutationFn: (data) => agentVersionAPI.create(data).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries(['agentVersions']);
setShowAgentVersionModal(false);
setAgentVersionForm({ version: '', releaseNotes: '', scriptContent: '', isDefault: false });
}
});
const setCurrentAgentVersionMutation = useMutation({
mutationFn: (id) => agentVersionAPI.setCurrent(id).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries(['agentVersions']);
}
});
const setDefaultAgentVersionMutation = useMutation({
mutationFn: (id) => agentVersionAPI.setDefault(id).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries(['agentVersions']);
}
});
const deleteAgentVersionMutation = useMutation({
mutationFn: (id) => agentVersionAPI.delete(id).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries(['agentVersions']);
}
});
const handleInputChange = (field, value) => {
console.log(`handleInputChange: ${field} = ${value}`);
setFormData(prev => {
const newData = { ...prev, [field]: value };
console.log('New form data:', newData);
return newData;
});
setIsDirty(true);
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: null }));
}
};
const handleSubmit = (e) => {
e.preventDefault();
updateSettingsMutation.mutate(formData);
};
const validateForm = () => {
const newErrors = {};
if (!formData.serverHost.trim()) {
newErrors.serverHost = 'Server host is required';
}
if (!formData.serverPort || formData.serverPort < 1 || formData.serverPort > 65535) {
newErrors.serverPort = 'Port must be between 1 and 65535';
}
try {
new URL(formData.frontendUrl);
} catch {
newErrors.frontendUrl = 'Frontend URL must be a valid URL';
}
if (!formData.updateInterval || formData.updateInterval < 5 || formData.updateInterval > 1440) {
newErrors.updateInterval = 'Update interval must be between 5 and 1440 minutes';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = () => {
console.log('Saving settings:', formData);
if (validateForm()) {
console.log('Validation passed, calling mutation');
updateSettingsMutation.mutate(formData);
} else {
console.log('Validation failed:', errors);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error loading settings</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{error.response?.data?.error || 'Failed to load settings'}
</p>
</div>
</div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto p-6">
<div className="mb-8">
<p className="text-secondary-600 dark:text-secondary-300">
Configure your PatchMon server settings. These settings will be used in installation scripts and agent communications.
</p>
</div>
{errors.general && (
<div className="mb-6 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<p className="text-sm text-red-700 dark:text-red-300">{errors.general}</p>
</div>
</div>
</div>
)}
{/* Tab Navigation */}
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
<div className="border-b border-secondary-200 dark:border-secondary-600">
<nav className="-mb-px flex space-x-8 px-6">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === tab.id
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500'
}`}
>
<Icon className="h-4 w-4" />
{tab.name}
</button>
);
})}
</nav>
</div>
{/* Tab Content */}
<div className="p-6">
{/* Server Configuration Tab */}
{activeTab === 'server' && (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="flex items-center mb-6">
<Server className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">Server Configuration</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Protocol
</label>
<select
value={formData.serverProtocol}
onChange={(e) => handleInputChange('serverProtocol', e.target.value)}
className="w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Host *
</label>
<input
type="text"
value={formData.serverHost}
onChange={(e) => handleInputChange('serverHost', e.target.value)}
className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.serverHost ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
}`}
placeholder="example.com"
/>
{errors.serverHost && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.serverHost}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Port *
</label>
<input
type="number"
value={formData.serverPort}
onChange={(e) => handleInputChange('serverPort', parseInt(e.target.value))}
className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.serverPort ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
}`}
min="1"
max="65535"
/>
{errors.serverPort && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.serverPort}</p>
)}
</div>
</div>
<div className="mt-4 p-4 bg-secondary-50 dark:bg-secondary-700 rounded-md">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">Server URL</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 font-mono">
{formData.serverProtocol}://{formData.serverHost}:{formData.serverPort}
</p>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
This URL will be used in installation scripts and agent communications.
</p>
</div>
{/* Update Interval */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Agent Update Interval (minutes)
</label>
<input
type="number"
min="5"
max="1440"
value={formData.updateInterval}
onChange={(e) => {
console.log('Update interval input changed:', e.target.value);
handleInputChange('updateInterval', parseInt(e.target.value) || 60);
}}
className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.updateInterval ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
}`}
placeholder="60"
/>
{errors.updateInterval && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.updateInterval}</p>
)}
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
How often agents should check for updates (5-1440 minutes). This affects new installations.
</p>
</div>
{/* Auto-Update Setting */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.autoUpdate}
onChange={(e) => handleInputChange('autoUpdate', e.target.checked)}
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
/>
Enable Automatic Agent Updates
</div>
</label>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
When enabled, agents will automatically update themselves when a newer version is available during their regular update cycle.
</p>
</div>
{/* Security Notice */}
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div className="flex">
<Shield className="h-5 w-5 text-blue-400 dark:text-blue-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">Security Notice</h3>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
Changing these settings will affect all installation scripts and agent communications.
Make sure the server URL is accessible from your client networks.
</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={!isDirty || updateSettingsMutation.isPending}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
!isDirty || updateSettingsMutation.isPending
? 'bg-secondary-400 cursor-not-allowed'
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
}`}
>
{updateSettingsMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Settings
</>
)}
</button>
</div>
{updateSettingsMutation.isSuccess && (
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
<div className="ml-3">
<p className="text-sm text-green-700 dark:text-green-300">Settings saved successfully!</p>
</div>
</div>
</div>
)}
</form>
)}
{/* Frontend Configuration Tab */}
{activeTab === 'frontend' && (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="flex items-center mb-6">
<Globe className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">Frontend Configuration</h2>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Frontend URL *
</label>
<input
type="url"
value={formData.frontendUrl}
onChange={(e) => handleInputChange('frontendUrl', e.target.value)}
className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.frontendUrl ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
}`}
placeholder="https://patchmon.example.com"
/>
{errors.frontendUrl && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.frontendUrl}</p>
)}
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
The URL where users will access the PatchMon web interface.
</p>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={!isDirty || updateSettingsMutation.isPending}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
!isDirty || updateSettingsMutation.isPending
? 'bg-secondary-400 cursor-not-allowed'
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
}`}
>
{updateSettingsMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Settings
</>
)}
</button>
</div>
{updateSettingsMutation.isSuccess && (
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
<div className="ml-3">
<p className="text-sm text-green-700 dark:text-green-300">Settings saved successfully!</p>
</div>
</div>
</div>
)}
</form>
)}
{/* Agent Management Tab */}
{activeTab === 'agent' && (
<div className="space-y-6">
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center mb-2">
<SettingsIcon className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">Agent Version Management</h2>
</div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">
Manage different versions of the PatchMon agent script
</p>
</div>
<button
onClick={() => setShowAgentVersionModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Add Version
</button>
</div>
{/* Version Summary */}
{agentVersions && agentVersions.length > 0 && (
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4 mb-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Current Version:</span>
<span className="text-sm text-secondary-900 dark:text-white font-mono">
{agentVersions.find(v => v.isCurrent)?.version || 'None'}
</span>
</div>
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Default Version:</span>
<span className="text-sm text-secondary-900 dark:text-white font-mono">
{agentVersions.find(v => v.isDefault)?.version || 'None'}
</span>
</div>
</div>
</div>
)}
{agentVersionsLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
) : agentVersionsError ? (
<div className="text-center py-8">
<p className="text-red-600 dark:text-red-400">Error loading agent versions: {agentVersionsError.message}</p>
</div>
) : !agentVersions || agentVersions.length === 0 ? (
<div className="text-center py-8">
<p className="text-secondary-500 dark:text-secondary-400">No agent versions found</p>
</div>
) : (
<div className="space-y-4">
{agentVersions.map((version) => (
<div key={version.id} className="border border-secondary-200 dark:border-secondary-600 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Code className="h-5 w-5 text-secondary-400 dark:text-secondary-500" />
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Version {version.version}
</h3>
{version.isDefault && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
<Star className="h-3 w-3 mr-1" />
Default
</span>
)}
{version.isCurrent && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Current
</span>
)}
</div>
{version.releaseNotes && (
<div className="text-sm text-secondary-500 dark:text-secondary-300 mt-1">
<p className="line-clamp-3 whitespace-pre-line">
{version.releaseNotes}
</p>
</div>
)}
<p className="text-xs text-secondary-400 dark:text-secondary-400 mt-1">
Created: {new Date(version.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => {
const downloadUrl = `/api/v1/hosts/agent/download?version=${version.version}`;
window.open(downloadUrl, '_blank');
}}
className="btn-outline text-xs flex items-center gap-1"
>
<Download className="h-3 w-3" />
Download
</button>
<button
onClick={() => setCurrentAgentVersionMutation.mutate(version.id)}
disabled={version.isCurrent || setCurrentAgentVersionMutation.isPending}
className="btn-outline text-xs flex items-center gap-1"
>
<CheckCircle className="h-3 w-3" />
Set Current
</button>
<button
onClick={() => setDefaultAgentVersionMutation.mutate(version.id)}
disabled={version.isDefault || setDefaultAgentVersionMutation.isPending}
className="btn-outline text-xs flex items-center gap-1"
>
<Star className="h-3 w-3" />
Set Default
</button>
<button
onClick={() => deleteAgentVersionMutation.mutate(version.id)}
disabled={version.isDefault || version.isCurrent || deleteAgentVersionMutation.isPending}
className="btn-danger text-xs flex items-center gap-1"
>
<Trash2 className="h-3 w-3" />
Delete
</button>
</div>
</div>
</div>
))}
{agentVersions?.length === 0 && (
<div className="text-center py-8">
<Code className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">No agent versions found</p>
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
Add your first agent version to get started
</p>
</div>
)}
</div>
)}
</div>
)}
</div>
</div>
{/* Agent Version Modal */}
{showAgentVersionModal && (
<AgentVersionModal
isOpen={showAgentVersionModal}
onClose={() => {
setShowAgentVersionModal(false);
setAgentVersionForm({ version: '', releaseNotes: '', scriptContent: '', isDefault: false });
}}
onSubmit={createAgentVersionMutation.mutate}
isLoading={createAgentVersionMutation.isPending}
/>
)}
</div>
);
};
// Agent Version Modal Component
const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => {
const [formData, setFormData] = useState({
version: '',
releaseNotes: '',
scriptContent: '',
isDefault: false
});
const [errors, setErrors] = useState({});
const handleSubmit = (e) => {
e.preventDefault();
// Basic validation
const newErrors = {};
if (!formData.version.trim()) newErrors.version = 'Version is required';
if (!formData.scriptContent.trim()) newErrors.scriptContent = 'Script content is required';
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSubmit(formData);
};
const handleFileUpload = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
setFormData(prev => ({ ...prev, scriptContent: event.target.result }));
};
reader.readAsText(file);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Add Agent Version</h3>
<button
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="px-6 py-4">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Version *
</label>
<input
type="text"
value={formData.version}
onChange={(e) => setFormData(prev => ({ ...prev, version: e.target.value }))}
className={`block w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.version ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
}`}
placeholder="e.g., 1.0.1"
/>
{errors.version && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.version}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Release Notes
</label>
<textarea
value={formData.releaseNotes}
onChange={(e) => setFormData(prev => ({ ...prev, releaseNotes: e.target.value }))}
rows={3}
className="block w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
placeholder="Describe what's new in this version..."
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Script Content *
</label>
<div className="space-y-2">
<input
type="file"
accept=".sh"
onChange={handleFileUpload}
className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
/>
<textarea
value={formData.scriptContent}
onChange={(e) => setFormData(prev => ({ ...prev, scriptContent: e.target.value }))}
rows={10}
className={`block w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm ${
errors.scriptContent ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
}`}
placeholder="Paste the agent script content here..."
/>
{errors.scriptContent && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.scriptContent}</p>
)}
</div>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="isDefault"
checked={formData.isDefault}
onChange={(e) => setFormData(prev => ({ ...prev, isDefault: e.target.checked }))}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-600 rounded"
/>
<label htmlFor="isDefault" className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200">
Set as default version for new installations
</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
type="button"
onClick={onClose}
className="btn-outline"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="btn-primary"
>
{isLoading ? 'Creating...' : 'Create Version'}
</button>
</div>
</form>
</div>
</div>
);
};
export default Settings;

View File

@@ -0,0 +1,490 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2, Edit, User, Mail, Shield, Calendar, CheckCircle, XCircle } from 'lucide-react'
import { adminUsersAPI, permissionsAPI } from '../utils/api'
import { useAuth } from '../contexts/AuthContext'
const Users = () => {
const [showAddModal, setShowAddModal] = useState(false)
const [editingUser, setEditingUser] = useState(null)
const queryClient = useQueryClient()
const { user: currentUser } = useAuth()
// Fetch users
const { data: users, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => adminUsersAPI.list().then(res => res.data)
})
// Fetch available roles
const { data: roles } = useQuery({
queryKey: ['rolePermissions'],
queryFn: () => permissionsAPI.getRoles().then(res => res.data)
})
// Delete user mutation
const deleteUserMutation = useMutation({
mutationFn: adminUsersAPI.delete,
onSuccess: () => {
queryClient.invalidateQueries(['users'])
}
})
// Update user mutation
const updateUserMutation = useMutation({
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries(['users'])
setEditingUser(null)
}
})
const handleDeleteUser = async (userId, username) => {
if (window.confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
try {
await deleteUserMutation.mutateAsync(userId)
} catch (error) {
console.error('Failed to delete user:', error)
}
}
}
const handleUserCreated = () => {
queryClient.invalidateQueries(['users'])
setShowAddModal(false)
}
const handleEditUser = (user) => {
setEditingUser(user)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)
}
if (error) {
return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<XCircle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">Error loading users</h3>
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-end items-center">
<button
onClick={() => setShowAddModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<Plus className="h-4 w-4 mr-2" />
Add User
</button>
</div>
{/* Users Table */}
<div className="bg-white dark:bg-secondary-800 shadow overflow-hidden sm:rounded-md">
<ul className="divide-y divide-secondary-200 dark:divide-secondary-600">
{users && Array.isArray(users) && users.length > 0 ? (
users.map((user) => (
<li key={user.id}>
<div className="px-4 py-4 flex items-center justify-between">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="h-10 w-10 rounded-full bg-primary-100 flex items-center justify-center">
<User className="h-5 w-5 text-primary-600" />
</div>
</div>
<div className="ml-4">
<div className="flex items-center">
<p className="text-sm font-medium text-secondary-900 dark:text-white">{user.username}</p>
{user.id === currentUser?.id && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
You
</span>
)}
<span className={`ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.role === 'admin'
? 'bg-primary-100 text-primary-800'
: user.role === 'host_manager'
? 'bg-green-100 text-green-800'
: user.role === 'readonly'
? 'bg-yellow-100 text-yellow-800'
: 'bg-secondary-100 text-secondary-800'
}`}>
<Shield className="h-3 w-3 mr-1" />
{user.role.charAt(0).toUpperCase() + user.role.slice(1).replace('_', ' ')}
</span>
{user.isActive ? (
<CheckCircle className="ml-2 h-4 w-4 text-green-500" />
) : (
<XCircle className="ml-2 h-4 w-4 text-red-500" />
)}
</div>
<div className="flex items-center mt-1 text-sm text-secondary-500 dark:text-secondary-300">
<Mail className="h-4 w-4 mr-1" />
{user.email}
</div>
<div className="flex items-center mt-1 text-sm text-secondary-500 dark:text-secondary-300">
<Calendar className="h-4 w-4 mr-1" />
Created: {new Date(user.createdAt).toLocaleDateString()}
{user.lastLogin && (
<>
<span className="mx-2"></span>
Last login: {new Date(user.lastLogin).toLocaleDateString()}
</>
)}
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => handleEditUser(user)}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
title="Edit user"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteUser(user.id, user.username)}
className="text-danger-400 hover:text-danger-600 dark:text-danger-500 dark:hover:text-danger-400 disabled:text-gray-300 disabled:cursor-not-allowed"
title={
user.id === currentUser?.id
? "Cannot delete your own account"
: user.role === 'admin' && users.filter(u => u.role === 'admin').length === 1
? "Cannot delete the last admin user"
: "Delete user"
}
disabled={
user.id === currentUser?.id ||
(user.role === 'admin' && users.filter(u => u.role === 'admin').length === 1)
}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</li>
))
) : (
<li>
<div className="px-4 py-8 text-center">
<User className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">No users found</p>
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
Click "Add User" to create the first user
</p>
</div>
</li>
)}
</ul>
</div>
{/* Add User Modal */}
<AddUserModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onUserCreated={handleUserCreated}
roles={roles}
/>
{/* Edit User Modal */}
{editingUser && (
<EditUserModal
user={editingUser}
isOpen={!!editingUser}
onClose={() => setEditingUser(null)}
onUserUpdated={() => updateUserMutation.mutate()}
roles={roles}
/>
)}
</div>
)
}
// Add User Modal Component
const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
const [formData, setFormData] = useState({
username: '',
email: '',
password: '',
role: 'user'
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
try {
const response = await adminUsersAPI.create(formData)
onUserCreated()
} catch (err) {
setError(err.response?.data?.error || 'Failed to create user')
} finally {
setIsLoading(false)
}
}
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
if (!isOpen) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Add New User</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Username
</label>
<input
type="text"
name="username"
required
value={formData.username}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Email
</label>
<input
type="email"
name="email"
required
value={formData.email}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Password
</label>
<input
type="password"
name="password"
required
minLength={6}
value={formData.password}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">Minimum 6 characters</p>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Role
</label>
<select
name="role"
value={formData.role}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
>
{roles && Array.isArray(roles) ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() + role.role.slice(1).replace('_', ' ')}
</option>
))
) : (
<>
<option value="user">User</option>
<option value="admin">Admin</option>
</>
)}
</select>
</div>
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p>
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? 'Creating...' : 'Create User'}
</button>
</div>
</form>
</div>
</div>
)
}
// Edit User Modal Component
const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
const [formData, setFormData] = useState({
username: user?.username || '',
email: user?.email || '',
role: user?.role || 'user',
isActive: user?.isActive ?? true
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const handleSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
try {
await adminUsersAPI.update(user.id, formData)
onUserUpdated()
} catch (err) {
setError(err.response?.data?.error || 'Failed to update user')
} finally {
setIsLoading(false)
}
}
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target
setFormData({
...formData,
[name]: type === 'checkbox' ? checked : value
})
}
if (!isOpen || !user) return null
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Edit User</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Username
</label>
<input
type="text"
name="username"
required
value={formData.username}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Email
</label>
<input
type="email"
name="email"
required
value={formData.email}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Role
</label>
<select
name="role"
value={formData.role}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
>
{roles && Array.isArray(roles) ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() + role.role.slice(1).replace('_', ' ')}
</option>
))
) : (
<>
<option value="user">User</option>
<option value="admin">Admin</option>
</>
)}
</select>
</div>
<div className="flex items-center">
<input
type="checkbox"
name="isActive"
checked={formData.isActive}
onChange={handleInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200">
Active user
</label>
</div>
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p>
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? 'Updating...' : 'Update User'}
</button>
</div>
</form>
</div>
</div>
)
}
export default Users

194
frontend/src/utils/api.js Normal file
View File

@@ -0,0 +1,194 @@
import axios from 'axios'
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1'
// Create axios instance with default config
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
})
// Request interceptor
api.interceptors.request.use(
(config) => {
// Add auth token if available
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response interceptor
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Handle unauthorized
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
// Dashboard API
export const dashboardAPI = {
getStats: () => api.get('/dashboard/stats'),
getHosts: () => api.get('/dashboard/hosts'),
getPackages: () => api.get('/dashboard/packages'),
getHostDetail: (hostId) => api.get(`/dashboard/hosts/${hostId}`),
}
// Admin Hosts API (for management interface)
export const adminHostsAPI = {
create: (data) => api.post('/hosts/create', data),
list: () => api.get('/hosts/admin/list'),
delete: (hostId) => api.delete(`/hosts/${hostId}`),
regenerateCredentials: (hostId) => api.post(`/hosts/${hostId}/regenerate-credentials`),
updateGroup: (hostId, hostGroupId) => api.put(`/hosts/${hostId}/group`, { hostGroupId }),
bulkUpdateGroup: (hostIds, hostGroupId) => api.put('/hosts/bulk/group', { hostIds, hostGroupId }),
toggleAutoUpdate: (hostId, autoUpdate) => api.patch(`/hosts/${hostId}/auto-update`, { autoUpdate })
}
// Host Groups API
export const hostGroupsAPI = {
list: () => api.get('/host-groups'),
get: (id) => api.get(`/host-groups/${id}`),
create: (data) => api.post('/host-groups', data),
update: (id, data) => api.put(`/host-groups/${id}`, data),
delete: (id) => api.delete(`/host-groups/${id}`),
getHosts: (id) => api.get(`/host-groups/${id}/hosts`),
}
// Admin Users API (for user management)
export const adminUsersAPI = {
list: () => api.get('/auth/admin/users'),
create: (userData) => api.post('/auth/admin/users', userData),
update: (userId, userData) => api.put(`/auth/admin/users/${userId}`, userData),
delete: (userId) => api.delete(`/auth/admin/users/${userId}`)
}
// Permissions API (for role management)
export const permissionsAPI = {
getRoles: () => api.get('/permissions/roles'),
getRole: (role) => api.get(`/permissions/roles/${role}`),
updateRole: (role, permissions) => api.put(`/permissions/roles/${role}`, permissions),
deleteRole: (role) => api.delete(`/permissions/roles/${role}`),
getUserPermissions: () => api.get('/permissions/user-permissions')
}
// Settings API
export const settingsAPI = {
get: () => api.get('/settings'),
update: (settings) => api.put('/settings', settings),
getServerUrl: () => api.get('/settings/server-url')
}
// Agent Version API
export const agentVersionAPI = {
list: () => api.get('/hosts/agent/versions'),
create: (data) => api.post('/hosts/agent/versions', data),
update: (id, data) => api.put(`/hosts/agent/versions/${id}`, data),
delete: (id) => api.delete(`/hosts/agent/versions/${id}`),
setCurrent: (id) => api.patch(`/hosts/agent/versions/${id}/current`),
setDefault: (id) => api.patch(`/hosts/agent/versions/${id}/default`),
download: (version) => api.get(`/hosts/agent/download${version ? `?version=${version}` : ''}`, { responseType: 'blob' })
}
// Repository API
export const repositoryAPI = {
list: () => api.get('/repositories'),
getById: (repositoryId) => api.get(`/repositories/${repositoryId}`),
getByHost: (hostId) => api.get(`/repositories/host/${hostId}`),
update: (repositoryId, data) => api.put(`/repositories/${repositoryId}`, data),
toggleHostRepository: (hostId, repositoryId, isEnabled) =>
api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, { isEnabled }),
getStats: () => api.get('/repositories/stats/summary'),
cleanupOrphaned: () => api.delete('/repositories/cleanup/orphaned')
}
// Dashboard Preferences API
export const dashboardPreferencesAPI = {
get: () => api.get('/dashboard-preferences'),
update: (preferences) => api.put('/dashboard-preferences', { preferences }),
getDefaults: () => api.get('/dashboard-preferences/defaults')
}
// Hosts API (for agent communication - kept for compatibility)
export const hostsAPI = {
// Legacy register endpoint (now deprecated)
register: (data) => api.post('/hosts/register', data),
// Updated to use API credentials
update: (apiId, apiKey, data) => api.post('/hosts/update', data, {
headers: {
'X-API-ID': apiId,
'X-API-KEY': apiKey
}
}),
getInfo: (apiId, apiKey) => api.get('/hosts/info', {
headers: {
'X-API-ID': apiId,
'X-API-KEY': apiKey
}
}),
ping: (apiId, apiKey) => api.post('/hosts/ping', {}, {
headers: {
'X-API-ID': apiId,
'X-API-KEY': apiKey
}
}),
toggleAutoUpdate: (id, autoUpdate) => api.patch(`/hosts/${id}/auto-update`, { autoUpdate })
}
// Packages API
export const packagesAPI = {
getAll: (params = {}) => api.get('/packages', { params }),
getById: (packageId) => api.get(`/packages/${packageId}`),
getCategories: () => api.get('/packages/categories/list'),
getHosts: (packageId, params = {}) => api.get(`/packages/${packageId}/hosts`, { params }),
update: (packageId, data) => api.put(`/packages/${packageId}`, data),
search: (query, params = {}) => api.get(`/packages/search/${query}`, { params }),
}
// Utility functions
export const formatError = (error) => {
if (error.response?.data?.message) {
return error.response.data.message
}
if (error.response?.data?.error) {
return error.response.data.error
}
if (error.message) {
return error.message
}
return 'An unexpected error occurred'
}
export const formatDate = (date) => {
return new Date(date).toLocaleString()
}
export const formatRelativeTime = (date) => {
const now = new Date()
const diff = now - new Date(date)
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`
return `${seconds} second${seconds > 1 ? 's' : ''} ago`
}
export default api