mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-22 15:31:22 +00:00
feat: Add Server Version management with GitHub integration
- Add Server Version tab in settings - Add githubRepoUrl field to Settings model - Add database migration for github_repo_url - Update settings API to handle GitHub repo URL - Add version checking UI with current/latest version display - Default GitHub repo: git@github.com:9technologygroup/patchmon.net.git
This commit is contained in:
@@ -18,7 +18,8 @@ import {
|
||||
Clock,
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
Wrench
|
||||
Wrench,
|
||||
Plus
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
@@ -59,7 +60,7 @@ const Layout = ({ children }) => {
|
||||
]
|
||||
},
|
||||
{
|
||||
section: 'Users',
|
||||
section: 'PatchMon Users',
|
||||
items: [
|
||||
...(canViewUsers() ? [{ name: 'Users', href: '/users', icon: Users }] : []),
|
||||
...(canManageSettings() ? [{ name: 'Permissions', href: '/permissions', icon: Shield }] : []),
|
||||
@@ -68,7 +69,7 @@ const Layout = ({ children }) => {
|
||||
{
|
||||
section: 'Settings',
|
||||
items: [
|
||||
...(canManageSettings() ? [{ name: 'Settings', href: '/settings', icon: Settings }] : []),
|
||||
...(canManageSettings() ? [{ name: 'Server Config', href: '/settings', icon: Settings }] : []),
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -100,6 +101,26 @@ const Layout = ({ children }) => {
|
||||
setUserMenuOpen(false)
|
||||
}
|
||||
|
||||
const handleAddHost = () => {
|
||||
// Navigate to hosts page with add modal parameter
|
||||
window.location.href = '/hosts?action=add'
|
||||
}
|
||||
|
||||
// Short format for navigation area
|
||||
const formatRelativeTimeShort = (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}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
if (minutes > 0) return `${minutes}m ago`
|
||||
return `${seconds}s ago`
|
||||
}
|
||||
|
||||
// Save sidebar collapsed state to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed))
|
||||
@@ -168,26 +189,57 @@ const Layout = ({ children }) => {
|
||||
</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
|
||||
<div key={subItem.name}>
|
||||
{subItem.name === 'Hosts' && canManageHosts() ? (
|
||||
// Special handling for Hosts item with integrated + button (mobile)
|
||||
<Link
|
||||
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'
|
||||
}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<subItem.icon className="mr-3 h-5 w-5" />
|
||||
<span className="flex items-center gap-2 flex-1">
|
||||
{subItem.name}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setSidebarOpen(false)
|
||||
handleAddHost()
|
||||
}}
|
||||
className="ml-auto flex items-center justify-center w-5 h-5 rounded-full border-2 border-current opacity-60 hover:opacity-100 transition-all duration-200 self-center"
|
||||
title="Add Host"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
</Link>
|
||||
) : (
|
||||
// Standard navigation item (mobile)
|
||||
<Link
|
||||
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>
|
||||
</div>
|
||||
@@ -268,30 +320,65 @@ const Layout = ({ children }) => {
|
||||
<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
|
||||
{subItem.name === 'Hosts' && canManageHosts() ? (
|
||||
// Special handling for Hosts item with integrated + button
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
to={subItem.href}
|
||||
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 flex-1 ${
|
||||
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'}`}
|
||||
title={sidebarCollapsed ? subItem.name : ''}
|
||||
>
|
||||
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
|
||||
{!sidebarCollapsed && (
|
||||
<span className="truncate flex items-center gap-2 flex-1">
|
||||
{subItem.name}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
{!sidebarCollapsed && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleAddHost()
|
||||
}}
|
||||
className="ml-auto flex items-center justify-center w-5 h-5 rounded-full border-2 border-current opacity-60 hover:opacity-100 transition-all duration-200 self-center"
|
||||
title="Add Host"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
// Standard navigation item
|
||||
<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>
|
||||
@@ -304,39 +391,41 @@ const Layout = ({ children }) => {
|
||||
</nav>
|
||||
|
||||
{/* Profile Section - Bottom of Sidebar */}
|
||||
<div className="border-t border-secondary-200">
|
||||
<div className="border-t border-secondary-200 dark:border-secondary-600">
|
||||
{!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>
|
||||
{/* User Info with Sign Out - Username is clickable */}
|
||||
<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
|
||||
<Link
|
||||
to="/profile"
|
||||
className={`flex-1 min-w-0 rounded-md p-2 transition-all duration-200 ${
|
||||
isActive('/profile')
|
||||
? 'bg-primary-50 dark:bg-primary-600'
|
||||
: 'hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<UserCircle className={`h-5 w-5 shrink-0 ${
|
||||
isActive('/profile')
|
||||
? 'text-primary-700 dark:text-white'
|
||||
: 'text-secondary-500 dark:text-secondary-400'
|
||||
}`} />
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className={`text-sm leading-6 font-semibold truncate ${
|
||||
isActive('/profile')
|
||||
? 'text-primary-700 dark:text-white'
|
||||
: 'text-secondary-700 dark:text-secondary-200'
|
||||
}`}>
|
||||
{user?.username}
|
||||
</span>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 truncate">
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="ml-2 p-1.5 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded transition-colors"
|
||||
@@ -345,23 +434,55 @@ const Layout = ({ children }) => {
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Updated info */}
|
||||
{stats && (
|
||||
<div className="px-3 py-1 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center gap-x-2 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Updated: {formatRelativeTimeShort(stats.lastUpdated)}</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</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"
|
||||
className={`flex items-center justify-center p-2 rounded-md transition-colors ${
|
||||
isActive('/profile')
|
||||
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
|
||||
: 'text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
}`}
|
||||
title={`My Profile (${user?.username})`}
|
||||
>
|
||||
<UserCircle className="h-5 w-5 text-white" />
|
||||
<UserCircle className="h-5 w-5" />
|
||||
</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"
|
||||
className="flex items-center justify-center w-full p-2 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-md transition-colors"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="h-5 w-5 text-white" />
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
{/* Updated info for collapsed sidebar */}
|
||||
{stats && (
|
||||
<div className="flex justify-center py-1 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
|
||||
title={`Refresh data - Updated: ${formatRelativeTimeShort(stats.lastUpdated)}`}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -369,7 +490,7 @@ const Layout = ({ children }) => {
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className={`transition-all duration-300 ${
|
||||
<div className={`flex flex-col min-h-screen transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'lg:pl-16' : 'lg:pl-64'
|
||||
}`}>
|
||||
{/* Top bar */}
|
||||
@@ -392,22 +513,6 @@ const Layout = ({ children }) => {
|
||||
</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
|
||||
@@ -426,7 +531,7 @@ const Layout = ({ children }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="py-6 bg-secondary-50 dark:bg-secondary-800 min-h-screen">
|
||||
<main className="flex-1 py-6 bg-secondary-50 dark:bg-secondary-800">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} 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 { dashboardAPI, dashboardPreferencesAPI, settingsAPI, formatRelativeTime } from '../utils/api'
|
||||
import DashboardSettingsModal from '../components/DashboardSettingsModal'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
|
||||
@@ -42,6 +42,48 @@ const Dashboard = () => {
|
||||
navigate('/packages?filter=security')
|
||||
}
|
||||
|
||||
const handleErroredHostsClick = () => {
|
||||
navigate('/hosts?filter=inactive')
|
||||
}
|
||||
|
||||
const handleOSDistributionClick = () => {
|
||||
navigate('/hosts')
|
||||
}
|
||||
|
||||
const handleUpdateStatusClick = () => {
|
||||
navigate('/hosts')
|
||||
}
|
||||
|
||||
const handlePackagePriorityClick = () => {
|
||||
navigate('/packages?filter=security')
|
||||
}
|
||||
|
||||
// Helper function to format the update interval threshold
|
||||
const formatUpdateIntervalThreshold = () => {
|
||||
if (!settings?.updateInterval) return '24 hours'
|
||||
|
||||
const intervalMinutes = settings.updateInterval
|
||||
const thresholdMinutes = intervalMinutes * 2 // 2x the update interval
|
||||
|
||||
if (thresholdMinutes < 60) {
|
||||
return `${thresholdMinutes} minutes`
|
||||
} else if (thresholdMinutes < 1440) {
|
||||
const hours = Math.floor(thresholdMinutes / 60)
|
||||
const minutes = thresholdMinutes % 60
|
||||
if (minutes === 0) {
|
||||
return `${hours} hour${hours > 1 ? 's' : ''}`
|
||||
}
|
||||
return `${hours}h ${minutes}m`
|
||||
} else {
|
||||
const days = Math.floor(thresholdMinutes / 1440)
|
||||
const hours = Math.floor((thresholdMinutes % 1440) / 60)
|
||||
if (hours === 0) {
|
||||
return `${days} day${days > 1 ? 's' : ''}`
|
||||
}
|
||||
return `${days}d ${hours}h`
|
||||
}
|
||||
}
|
||||
|
||||
const { data: stats, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['dashboardStats'],
|
||||
queryFn: () => dashboardAPI.getStats().then(res => res.data),
|
||||
@@ -49,6 +91,12 @@ const Dashboard = () => {
|
||||
staleTime: 30000, // Consider data stale after 30 seconds
|
||||
})
|
||||
|
||||
// Fetch settings to get the agent update interval
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => settingsAPI.get().then(res => res.data),
|
||||
})
|
||||
|
||||
// Fetch user's dashboard preferences
|
||||
const { data: preferences, refetch: refetchPreferences } = useQuery({
|
||||
queryKey: ['dashboardPreferences'],
|
||||
@@ -210,11 +258,14 @@ const Dashboard = () => {
|
||||
|
||||
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={`border rounded-lg p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 ${
|
||||
stats.cards.erroredHosts > 0
|
||||
? 'bg-danger-50 border-danger-200'
|
||||
: 'bg-success-50 border-success-200'
|
||||
}`}
|
||||
onClick={handleErroredHostsClick}
|
||||
>
|
||||
<div className="flex">
|
||||
<AlertTriangle className={`h-5 w-5 ${
|
||||
stats.cards.erroredHosts > 0 ? 'text-danger-400' : 'text-success-400'
|
||||
@@ -223,7 +274,7 @@ const Dashboard = () => {
|
||||
{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
|
||||
{stats.cards.erroredHosts} host{stats.cards.erroredHosts > 1 ? 's' : ''} haven't reported in {formatUpdateIntervalThreshold()}+
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
These hosts may be offline or experiencing connectivity issues.
|
||||
@@ -235,7 +286,7 @@ const Dashboard = () => {
|
||||
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.
|
||||
No hosts have failed to report in the last {formatUpdateIntervalThreshold()}.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@@ -246,7 +297,10 @@ const Dashboard = () => {
|
||||
|
||||
case 'osDistribution':
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div
|
||||
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleOSDistributionClick}
|
||||
>
|
||||
<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} />
|
||||
@@ -256,7 +310,10 @@ const Dashboard = () => {
|
||||
|
||||
case 'updateStatus':
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div
|
||||
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleUpdateStatusClick}
|
||||
>
|
||||
<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} />
|
||||
@@ -266,7 +323,10 @@ const Dashboard = () => {
|
||||
|
||||
case 'packagePriority':
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div
|
||||
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handlePackagePriorityClick}
|
||||
>
|
||||
<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} />
|
||||
|
||||
@@ -21,7 +21,9 @@ import {
|
||||
Code,
|
||||
EyeOff,
|
||||
ToggleLeft,
|
||||
ToggleRight
|
||||
ToggleRight,
|
||||
Edit,
|
||||
Check
|
||||
} from 'lucide-react'
|
||||
import { dashboardAPI, adminHostsAPI, settingsAPI, formatRelativeTime, formatDate } from '../utils/api'
|
||||
|
||||
@@ -31,6 +33,8 @@ const HostDetail = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [showCredentialsModal, setShowCredentialsModal] = useState(false)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [isEditingHostname, setIsEditingHostname] = useState(false)
|
||||
const [editedHostname, setEditedHostname] = useState('')
|
||||
|
||||
const { data: host, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['host', hostId],
|
||||
@@ -39,6 +43,13 @@ const HostDetail = () => {
|
||||
staleTime: 30000,
|
||||
})
|
||||
|
||||
// Auto-show credentials modal for new/pending hosts
|
||||
React.useEffect(() => {
|
||||
if (host && host.status === 'pending') {
|
||||
setShowCredentialsModal(true)
|
||||
}
|
||||
}, [host])
|
||||
|
||||
const deleteHostMutation = useMutation({
|
||||
mutationFn: (hostId) => adminHostsAPI.delete(hostId),
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Server,
|
||||
AlertTriangle,
|
||||
@@ -88,67 +88,105 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
|
||||
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-md">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-secondary-900">Add New Host</h3>
|
||||
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Add New Host</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>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700">Hostname *</label>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">Hostname *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.hostname}
|
||||
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
|
||||
className="mt-1 block w-full border-secondary-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
className="block w-full px-3 py-2.5 text-base border-2 border-secondary-300 dark:border-secondary-600 rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white transition-all duration-200"
|
||||
placeholder="server.example.com"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-secondary-500">
|
||||
<p className="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
System information (OS, IP, architecture) will be automatically detected when the agent connects.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700">Host Group</label>
|
||||
<select
|
||||
value={formData.hostGroupId}
|
||||
onChange={(e) => setFormData({ ...formData, hostGroupId: e.target.value })}
|
||||
className="mt-1 block w-full border-secondary-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="">No group (ungrouped)</option>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-3">Host Group</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{/* No Group Option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, hostGroupId: '' })}
|
||||
className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
|
||||
formData.hostGroupId === ''
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
|
||||
: 'border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium">No Group</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">Ungrouped</div>
|
||||
{formData.hostGroupId === '' && (
|
||||
<div className="absolute top-2 right-2 w-3 h-3 rounded-full bg-primary-500 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white"></div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Host Group Options */}
|
||||
{hostGroups?.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, hostGroupId: group.id })}
|
||||
className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
|
||||
formData.hostGroupId === group.id
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
|
||||
: 'border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 mb-1 w-full justify-center">
|
||||
{group.color && (
|
||||
<div
|
||||
className="w-3 h-3 rounded-full border border-secondary-300 dark:border-secondary-500 flex-shrink-0"
|
||||
style={{ backgroundColor: group.color }}
|
||||
></div>
|
||||
)}
|
||||
<div className="text-xs font-medium truncate max-w-full">{group.name}</div>
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">Group</div>
|
||||
{formData.hostGroupId === group.id && (
|
||||
<div className="absolute top-2 right-2 w-3 h-3 rounded-full bg-primary-500 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white"></div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-sm text-secondary-500">
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Optional: Assign this host to a group for better organization.
|
||||
</p>
|
||||
</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 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">
|
||||
<div className="flex justify-end space-x-3 pt-2">
|
||||
<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"
|
||||
className="px-6 py-3 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-all duration-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
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"
|
||||
className="px-6 py-3 text-sm font-medium text-white bg-primary-600 border-2 border-transparent rounded-lg hover:bg-primary-700 disabled:opacity-50 transition-all duration-200"
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Host'}
|
||||
</button>
|
||||
@@ -564,6 +602,7 @@ const Hosts = () => {
|
||||
const [selectedHosts, setSelectedHosts] = useState([])
|
||||
const [showBulkAssignModal, setShowBulkAssignModal] = useState(false)
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Table state
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
@@ -575,15 +614,35 @@ const Hosts = () => {
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [groupBy, setGroupBy] = useState('none')
|
||||
const [showColumnSettings, setShowColumnSettings] = useState(false)
|
||||
const [hideStale, setHideStale] = useState(false)
|
||||
|
||||
// Handle URL filter parameters
|
||||
useEffect(() => {
|
||||
const filter = searchParams.get('filter')
|
||||
if (filter === 'needsUpdates') {
|
||||
setShowFilters(true)
|
||||
setStatusFilter('all')
|
||||
// We'll filter hosts with updates > 0 in the filtering logic
|
||||
} else if (filter === 'inactive') {
|
||||
setShowFilters(true)
|
||||
setStatusFilter('inactive')
|
||||
// We'll filter hosts with inactive status in the filtering logic
|
||||
} else if (filter === 'upToDate') {
|
||||
setShowFilters(true)
|
||||
setStatusFilter('active')
|
||||
// We'll filter hosts that are up to date in the filtering logic
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// Handle add host action from navigation
|
||||
const action = searchParams.get('action')
|
||||
if (action === 'add') {
|
||||
setShowAddModal(true)
|
||||
// Remove the action parameter from URL without triggering a page reload
|
||||
const newSearchParams = new URLSearchParams(searchParams)
|
||||
newSearchParams.delete('action')
|
||||
navigate(`/hosts${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ''}`, { replace: true })
|
||||
}
|
||||
}, [searchParams, navigate])
|
||||
|
||||
// Column configuration
|
||||
const [columnConfig, setColumnConfig] = useState(() => {
|
||||
@@ -726,16 +785,22 @@ const Hosts = () => {
|
||||
(groupFilter !== 'ungrouped' && host.hostGroup?.id === groupFilter)
|
||||
|
||||
// Status filter
|
||||
const matchesStatus = statusFilter === 'all' || host.status === statusFilter
|
||||
const matchesStatus = statusFilter === 'all' || (host.effectiveStatus || host.status) === statusFilter
|
||||
|
||||
// OS filter
|
||||
const matchesOs = osFilter === 'all' || host.osType?.toLowerCase() === osFilter.toLowerCase()
|
||||
|
||||
// URL filter for hosts needing updates
|
||||
// URL filter for hosts needing updates, inactive hosts, or up-to-date hosts
|
||||
const filter = searchParams.get('filter')
|
||||
const matchesUrlFilter = filter !== 'needsUpdates' || (host.updatesCount && host.updatesCount > 0)
|
||||
const matchesUrlFilter =
|
||||
(filter !== 'needsUpdates' || (host.updatesCount && host.updatesCount > 0)) &&
|
||||
(filter !== 'inactive' || (host.effectiveStatus || host.status) === 'inactive') &&
|
||||
(filter !== 'upToDate' || (!host.isStale && host.updatesCount === 0))
|
||||
|
||||
return matchesSearch && matchesGroup && matchesStatus && matchesOs && matchesUrlFilter
|
||||
// Hide stale filter
|
||||
const matchesHideStale = !hideStale || !host.isStale
|
||||
|
||||
return matchesSearch && matchesGroup && matchesStatus && matchesOs && matchesUrlFilter && matchesHideStale
|
||||
})
|
||||
|
||||
// Sorting
|
||||
@@ -768,8 +833,8 @@ const Hosts = () => {
|
||||
bValue = b.agentVersion?.toLowerCase() || 'zzz_no_version'
|
||||
break
|
||||
case 'status':
|
||||
aValue = a.status
|
||||
bValue = b.status
|
||||
aValue = a.effectiveStatus || a.status
|
||||
bValue = b.effectiveStatus || b.status
|
||||
break
|
||||
case 'updates':
|
||||
aValue = a.updatesCount || 0
|
||||
@@ -806,7 +871,7 @@ const Hosts = () => {
|
||||
groupKey = host.hostGroup?.name || 'Ungrouped'
|
||||
break
|
||||
case 'status':
|
||||
groupKey = host.status.charAt(0).toUpperCase() + host.status.slice(1)
|
||||
groupKey = (host.effectiveStatus || host.status).charAt(0).toUpperCase() + (host.effectiveStatus || host.status).slice(1)
|
||||
break
|
||||
case 'os':
|
||||
groupKey = host.osType || 'Unknown'
|
||||
@@ -957,7 +1022,7 @@ const Hosts = () => {
|
||||
case 'status':
|
||||
return (
|
||||
<div className="text-sm text-secondary-900 dark:text-white">
|
||||
{host.status.charAt(0).toUpperCase() + host.status.slice(1)}
|
||||
{(host.effectiveStatus || host.status).charAt(0).toUpperCase() + (host.effectiveStatus || host.status).slice(1)}
|
||||
</div>
|
||||
)
|
||||
case 'updates':
|
||||
@@ -989,7 +1054,50 @@ const Hosts = () => {
|
||||
|
||||
const handleHostCreated = (newHost) => {
|
||||
queryClient.invalidateQueries(['hosts'])
|
||||
// Host created successfully - user can view details for setup instructions
|
||||
// Navigate to host detail page to show credentials and setup instructions
|
||||
navigate(`/hosts/${newHost.hostId}`)
|
||||
}
|
||||
|
||||
// Stats card click handlers
|
||||
const handleTotalHostsClick = () => {
|
||||
// Clear all filters to show all hosts
|
||||
setSearchTerm('')
|
||||
setGroupFilter('all')
|
||||
setStatusFilter('all')
|
||||
setOsFilter('all')
|
||||
setGroupBy('none')
|
||||
setHideStale(false)
|
||||
setShowFilters(false)
|
||||
}
|
||||
|
||||
const handleUpToDateClick = () => {
|
||||
// Filter to show only up-to-date hosts
|
||||
setStatusFilter('active')
|
||||
setShowFilters(true)
|
||||
// Use the upToDate URL filter
|
||||
const newSearchParams = new URLSearchParams(window.location.search)
|
||||
newSearchParams.set('filter', 'upToDate')
|
||||
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true })
|
||||
}
|
||||
|
||||
const handleNeedsUpdatesClick = () => {
|
||||
// Filter to show hosts needing updates (regardless of status)
|
||||
setStatusFilter('all')
|
||||
setShowFilters(true)
|
||||
// We'll use the existing needsUpdates URL filter logic
|
||||
const newSearchParams = new URLSearchParams(window.location.search)
|
||||
newSearchParams.set('filter', 'needsUpdates')
|
||||
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true })
|
||||
}
|
||||
|
||||
const handleStaleClick = () => {
|
||||
// Filter to show stale/inactive hosts
|
||||
setStatusFilter('inactive')
|
||||
setShowFilters(true)
|
||||
// We'll use the existing inactive URL filter logic
|
||||
const newSearchParams = new URLSearchParams(window.location.search)
|
||||
newSearchParams.set('filter', 'inactive')
|
||||
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@@ -1045,7 +1153,10 @@ const Hosts = () => {
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4">
|
||||
<div className="card p-4">
|
||||
<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">
|
||||
<Server className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<div>
|
||||
@@ -1054,7 +1165,10 @@ const Hosts = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleUpToDateClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 text-success-600 mr-2" />
|
||||
<div>
|
||||
@@ -1065,18 +1179,24 @@ const Hosts = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleNeedsUpdatesClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-5 w-5 text-warning-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Needs Updates</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{hosts?.filter(h => !h.isStale && h.updatesCount > 0).length || 0}
|
||||
{hosts?.filter(h => h.updatesCount > 0).length || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleStaleClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-600 mr-2" />
|
||||
<div>
|
||||
@@ -1150,15 +1270,22 @@ const Hosts = () => {
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.value)}
|
||||
className="appearance-none bg-white dark:bg-secondary-800 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 pr-8 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-secondary-900 dark:text-white hover:border-secondary-400 dark:hover:border-secondary-500 transition-colors"
|
||||
className="appearance-none bg-white dark:bg-secondary-800 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg px-2 py-2 pr-6 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-secondary-900 dark:text-white hover:border-secondary-400 dark:hover:border-secondary-500 transition-colors min-w-[120px]"
|
||||
>
|
||||
<option value="none">No Grouping</option>
|
||||
<option value="group">Group by Host Group</option>
|
||||
<option value="status">Group by Status</option>
|
||||
<option value="os">Group by OS</option>
|
||||
<option value="group">By Group</option>
|
||||
<option value="status">By Status</option>
|
||||
<option value="os">By OS</option>
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500 pointer-events-none" />
|
||||
<ChevronDown className="absolute right-1 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500 pointer-events-none" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setHideStale(!hideStale)}
|
||||
className={`btn-outline flex items-center gap-2 ${hideStale ? 'bg-primary-50 border-primary-300' : ''}`}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Hide Stale
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
@@ -1222,6 +1349,7 @@ const Hosts = () => {
|
||||
setStatusFilter('all')
|
||||
setOsFilter('all')
|
||||
setGroupBy('none')
|
||||
setHideStale(false)
|
||||
}}
|
||||
className="btn-outline w-full"
|
||||
>
|
||||
@@ -1356,15 +1484,28 @@ const Hosts = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{groupHosts.map((host) => (
|
||||
<tr key={host.id} className={`hover:bg-secondary-50 dark:hover:bg-secondary-700 ${selectedHosts.includes(host.id) ? 'bg-primary-50 dark:bg-primary-600' : ''}`}>
|
||||
{visibleColumns.map((column) => (
|
||||
<td key={column.id} className="px-4 py-2 whitespace-nowrap text-center">
|
||||
{renderCellContent(column, host)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{groupHosts.map((host) => {
|
||||
const isInactive = (host.effectiveStatus || host.status) === 'inactive'
|
||||
const isSelected = selectedHosts.includes(host.id)
|
||||
|
||||
let rowClasses = 'hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
|
||||
if (isSelected) {
|
||||
rowClasses += ' bg-primary-50 dark:bg-primary-600'
|
||||
} else if (isInactive) {
|
||||
rowClasses += ' bg-red-50 dark:bg-red-900/20'
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={host.id} className={rowClasses}>
|
||||
{visibleColumns.map((column) => (
|
||||
<td key={column.id} className="px-4 py-2 whitespace-nowrap text-center">
|
||||
{renderCellContent(column, host)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,8 @@ const Settings = () => {
|
||||
serverPort: 3001,
|
||||
frontendUrl: 'http://localhost:3000',
|
||||
updateInterval: 60,
|
||||
autoUpdate: false
|
||||
autoUpdate: false,
|
||||
githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git'
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
@@ -22,7 +23,8 @@ const Settings = () => {
|
||||
const tabs = [
|
||||
{ id: 'server', name: 'Server Configuration', icon: Server },
|
||||
{ id: 'frontend', name: 'Frontend Configuration', icon: Globe },
|
||||
{ id: 'agent', name: 'Agent Management', icon: SettingsIcon }
|
||||
{ id: 'agent', name: 'Agent Management', icon: SettingsIcon },
|
||||
{ id: 'version', name: 'Server Version', icon: Code }
|
||||
];
|
||||
|
||||
// Agent version management state
|
||||
@@ -54,7 +56,8 @@ const Settings = () => {
|
||||
serverPort: settings.serverPort || 3001,
|
||||
frontendUrl: settings.frontendUrl || 'http://localhost:3000',
|
||||
updateInterval: settings.updateInterval || 60,
|
||||
autoUpdate: settings.autoUpdate || false
|
||||
autoUpdate: settings.autoUpdate || false,
|
||||
githubRepoUrl: settings.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
|
||||
};
|
||||
console.log('Setting form data to:', newFormData);
|
||||
setFormData(newFormData);
|
||||
@@ -653,6 +656,100 @@ const Settings = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server Version Tab */}
|
||||
{activeTab === 'version' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<Code className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">Server Version Management</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Version Check Configuration</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
|
||||
Configure automatic version checking against your GitHub repository to notify users of available updates.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||
GitHub Repository URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'}
|
||||
onChange={(e) => handleInputChange('githubRepoUrl', e.target.value)}
|
||||
className="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 font-mono text-sm"
|
||||
placeholder="git@github.com:username/repository.git"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
SSH or HTTPS URL to your GitHub repository
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-2 mb-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>
|
||||
</div>
|
||||
<span className="text-lg font-mono text-secondary-900 dark:text-white">1.2.3</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Download className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Latest Version</span>
|
||||
</div>
|
||||
<span className="text-lg font-mono text-secondary-900 dark:text-white">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement version check
|
||||
console.log('Checking for updates...');
|
||||
}}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Check for Updates
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement update notification
|
||||
console.log('Enable update notifications');
|
||||
}}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Enable Notifications
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-5 w-5 text-amber-400 dark:text-amber-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">Setup Instructions</h3>
|
||||
<div className="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
||||
<p className="mb-2">To enable version checking, you need to:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 ml-4">
|
||||
<li>Create a version tag (e.g., v1.2.3) in your GitHub repository</li>
|
||||
<li>Ensure the repository is publicly accessible or configure access tokens</li>
|
||||
<li>Click "Check for Updates" to verify the connection</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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 { Plus, Trash2, Edit, User, Mail, Shield, Calendar, CheckCircle, XCircle, Key } 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 [resetPasswordUser, setResetPasswordUser] = useState(null)
|
||||
const queryClient = useQueryClient()
|
||||
const { user: currentUser } = useAuth()
|
||||
|
||||
@@ -39,6 +40,15 @@ const Users = () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Reset password mutation
|
||||
const resetPasswordMutation = useMutation({
|
||||
mutationFn: ({ userId, newPassword }) => adminUsersAPI.resetPassword(userId, newPassword),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['users'])
|
||||
setResetPasswordUser(null)
|
||||
}
|
||||
})
|
||||
|
||||
const handleDeleteUser = async (userId, username) => {
|
||||
if (window.confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
|
||||
try {
|
||||
@@ -58,6 +68,10 @@ const Users = () => {
|
||||
setEditingUser(user)
|
||||
}
|
||||
|
||||
const handleResetPassword = (user) => {
|
||||
setResetPasswordUser(user)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -156,6 +170,18 @@ const Users = () => {
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleResetPassword(user)}
|
||||
className="text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300 disabled:text-gray-300 disabled:cursor-not-allowed"
|
||||
title={
|
||||
!user.isActive
|
||||
? "Cannot reset password for inactive user"
|
||||
: "Reset password"
|
||||
}
|
||||
disabled={!user.isActive}
|
||||
>
|
||||
<Key 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"
|
||||
@@ -209,6 +235,17 @@ const Users = () => {
|
||||
roles={roles}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
{resetPasswordUser && (
|
||||
<ResetPasswordModal
|
||||
user={resetPasswordUser}
|
||||
isOpen={!!resetPasswordUser}
|
||||
onClose={() => setResetPasswordUser(null)}
|
||||
onPasswordReset={resetPasswordMutation.mutate}
|
||||
isLoading={resetPasswordMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -487,4 +524,126 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Reset Password Modal Component
|
||||
const ResetPasswordModal = ({ user, isOpen, onClose, onPasswordReset, isLoading }) => {
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
// Validate passwords
|
||||
if (newPassword.length < 6) {
|
||||
setError('Password must be at least 6 characters long')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await onPasswordReset({ userId: user.id, newPassword })
|
||||
// Reset form on success
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to reset password')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setError('')
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 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">
|
||||
Reset Password for {user.username}
|
||||
</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">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={6}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
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"
|
||||
placeholder="Enter new password (min 6 characters)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
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"
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<Key className="h-5 w-5 text-yellow-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
Password Reset Warning
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
|
||||
<p>This will immediately change the user's password. The user will need to use the new password to login.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</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={handleClose}
|
||||
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 flex items-center"
|
||||
>
|
||||
{isLoading && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>}
|
||||
{isLoading ? 'Resetting...' : 'Reset Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Users
|
||||
|
||||
@@ -73,7 +73,8 @@ 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}`)
|
||||
delete: (userId) => api.delete(`/auth/admin/users/${userId}`),
|
||||
resetPassword: (userId, newPassword) => api.post(`/auth/admin/users/${userId}/reset-password`, { newPassword })
|
||||
}
|
||||
|
||||
// Permissions API (for role management)
|
||||
|
||||
Reference in New Issue
Block a user