Updated frontend to snake_case and fixed bugs with some pages that were not showing. Fixed authentication side.

This commit is contained in:
Muhammad Ibrahim
2025-09-21 20:27:47 +01:00
parent 875ab31317
commit 2de80f0c06
22 changed files with 581 additions and 385 deletions

View File

@@ -5,39 +5,39 @@ const prisma = new PrismaClient();
async function checkAgentVersion() {
try {
// Check current agent version in database
const agentVersion = await prisma.agentVersion.findFirst({
const agentVersion = await prisma.agent_versions.findFirst({
where: { version: '1.2.6' }
});
if (agentVersion) {
console.log('✅ Agent version 1.2.6 found in database');
console.log('Version:', agentVersion.version);
console.log('Is Default:', agentVersion.isDefault);
console.log('Script Content Length:', agentVersion.scriptContent?.length || 0);
console.log('Created At:', agentVersion.createdAt);
console.log('Updated At:', agentVersion.updatedAt);
console.log('Is Default:', agentVersion.is_default);
console.log('Script Content Length:', agentVersion.script_content?.length || 0);
console.log('Created At:', agentVersion.created_at);
console.log('Updated At:', agentVersion.updated_at);
// Check if script content contains the current version
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('AGENT_VERSION="1.2.6"')) {
if (agentVersion.script_content && agentVersion.script_content.includes('AGENT_VERSION="1.2.6"')) {
console.log('✅ Script content contains correct version 1.2.6');
} else {
console.log('❌ Script content does not contain version 1.2.6');
}
// Check if script content contains system info functions
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('get_hardware_info()')) {
if (agentVersion.script_content && agentVersion.script_content.includes('get_hardware_info()')) {
console.log('✅ Script content contains hardware info function');
} else {
console.log('❌ Script content missing hardware info function');
}
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('get_network_info()')) {
if (agentVersion.script_content && agentVersion.script_content.includes('get_network_info()')) {
console.log('✅ Script content contains network info function');
} else {
console.log('❌ Script content missing network info function');
}
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('get_system_info()')) {
if (agentVersion.script_content && agentVersion.script_content.includes('get_system_info()')) {
console.log('✅ Script content contains system info function');
} else {
console.log('❌ Script content missing system info function');
@@ -49,12 +49,12 @@ async function checkAgentVersion() {
// List all agent versions
console.log('\n=== All Agent Versions ===');
const allVersions = await prisma.agentVersion.findMany({
orderBy: { createdAt: 'desc' }
const allVersions = await prisma.agent_versions.findMany({
orderBy: { created_at: 'desc' }
});
allVersions.forEach(version => {
console.log(`Version: ${version.version}, Default: ${version.isDefault}, Length: ${version.scriptContent?.length || 0}`);
console.log(`Version: ${version.version}, Default: ${version.is_default}, Length: ${version.script_content?.length || 0}`);
});
} catch (error) {

View File

@@ -25,7 +25,9 @@ const authenticateToken = async (req, res, next) => {
email: true,
role: true,
is_active: true,
last_login: true
last_login: true,
created_at: true,
updated_at: true
}
});
@@ -79,7 +81,10 @@ const optionalAuth = async (req, res, next) => {
username: true,
email: true,
role: true,
is_active: true
is_active: true,
last_login: true,
created_at: true,
updated_at: true
}
});

View File

@@ -20,7 +20,7 @@ const requirePermission = (permission) => {
if (!rolePermissions[permission]) {
return res.status(403).json({
error: 'Insufficient permissions',
message: `You don't have permission to ${permission.replace('can', '').toLowerCase()}`
message: `You don't have permission to ${permission.replace('can_', '').replace('_', ' ')}`
});
}
@@ -32,17 +32,17 @@ const requirePermission = (permission) => {
};
};
// Specific permission middlewares
const requireViewDashboard = requirePermission('canViewDashboard');
const requireViewHosts = requirePermission('canViewHosts');
const requireManageHosts = requirePermission('canManageHosts');
const requireViewPackages = requirePermission('canViewPackages');
const requireManagePackages = requirePermission('canManagePackages');
const requireViewUsers = requirePermission('canViewUsers');
const requireManageUsers = requirePermission('canManageUsers');
const requireViewReports = requirePermission('canViewReports');
const requireExportData = requirePermission('canExportData');
const requireManageSettings = requirePermission('canManageSettings');
// Specific permission middlewares - using snake_case field names
const requireViewDashboard = requirePermission('can_view_dashboard');
const requireViewHosts = requirePermission('can_view_hosts');
const requireManageHosts = requirePermission('can_manage_hosts');
const requireViewPackages = requirePermission('can_view_packages');
const requireManagePackages = requirePermission('can_manage_packages');
const requireViewUsers = requirePermission('can_view_users');
const requireManageUsers = requirePermission('can_manage_users');
const requireViewReports = requirePermission('can_view_reports');
const requireExportData = requirePermission('can_export_data');
const requireManageSettings = requirePermission('can_manage_settings');
module.exports = {
requirePermission,

View File

@@ -426,6 +426,10 @@ router.post('/login', [
email: true,
password_hash: true,
role: true,
is_active: true,
last_login: true,
created_at: true,
updated_at: true,
tfa_enabled: true
}
});
@@ -468,7 +472,11 @@ router.post('/login', [
id: user.id,
username: user.username,
email: user.email,
role: user.role
role: user.role,
is_active: user.is_active,
last_login: user.last_login,
created_at: user.created_at,
updated_at: user.updated_at
}
});
} catch (error) {

View File

@@ -134,7 +134,7 @@ const validateApiCredentials = async (req, res, next) => {
// Admin endpoint to create a new host manually (replaces auto-registration)
router.post('/create', authenticateToken, requireManageHosts, [
body('friendlyName').isLength({ min: 1 }).withMessage('Friendly name is required'),
body('friendly_name').isLength({ min: 1 }).withMessage('Friendly name is required'),
body('hostGroupId').optional()
], async (req, res) => {
try {
@@ -143,14 +143,14 @@ router.post('/create', authenticateToken, requireManageHosts, [
return res.status(400).json({ errors: errors.array() });
}
const { friendlyName, hostGroupId } = req.body;
const { friendly_name, hostGroupId } = req.body;
// Generate unique API credentials for this host
const { apiId, apiKey } = generateApiCredentials();
// Check if host already exists
const existingHost = await prisma.hosts.findUnique({
where: { friendly_name: friendlyName }
where: { friendly_name: friendly_name }
});
if (existingHost) {
@@ -172,7 +172,7 @@ router.post('/create', authenticateToken, requireManageHosts, [
const host = await prisma.hosts.create({
data: {
id: uuidv4(),
friendly_name: friendlyName,
friendly_name: friendly_name,
os_type: 'unknown', // Will be updated when agent connects
os_version: 'unknown', // Will be updated when agent connects
ip: null, // Will be updated when agent connects
@@ -786,7 +786,7 @@ router.delete('/:hostId', authenticateToken, requireManageHosts, async (req, res
// Toggle host auto-update setting
router.patch('/:hostId/auto-update', authenticateToken, requireManageHosts, [
body('autoUpdate').isBoolean().withMessage('Auto-update must be a boolean')
body('auto_update').isBoolean().withMessage('Auto-update must be a boolean')
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -795,12 +795,12 @@ router.patch('/:hostId/auto-update', authenticateToken, requireManageHosts, [
}
const { hostId } = req.params;
const { autoUpdate } = req.body;
const { auto_update } = req.body;
const host = await prisma.hosts.update({
where: { id: hostId },
data: {
auto_update: autoUpdate,
auto_update: auto_update,
updated_at: new Date()
}
});
@@ -1011,7 +1011,7 @@ router.delete('/agent/versions/:versionId', authenticateToken, requireManageSett
// Update host friendly name (admin only)
router.patch('/:hostId/friendly-name', authenticateToken, requireManageHosts, [
body('friendlyName').isLength({ min: 1, max: 100 }).withMessage('Friendly name must be between 1 and 100 characters')
body('friendly_name').isLength({ min: 1, max: 100 }).withMessage('Friendly name must be between 1 and 100 characters')
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -1020,7 +1020,7 @@ router.patch('/:hostId/friendly-name', authenticateToken, requireManageHosts, [
}
const { hostId } = req.params;
const { friendlyName } = req.body;
const { friendly_name } = req.body;
// Check if host exists
const host = await prisma.hosts.findUnique({
@@ -1034,7 +1034,7 @@ router.patch('/:hostId/friendly-name', authenticateToken, requireManageHosts, [
// Check if friendly name is already taken by another host
const existingHost = await prisma.hosts.findFirst({
where: {
friendly_name: friendlyName,
friendly_name: friendly_name,
id: { not: hostId }
}
});
@@ -1046,7 +1046,7 @@ router.patch('/:hostId/friendly-name', authenticateToken, requireManageHosts, [
// Update the friendly name
const updatedHost = await prisma.hosts.update({
where: { id: hostId },
data: { friendly_name: friendlyName },
data: { friendly_name: friendly_name },
select: {
id: true,
friendly_name: true,

View File

@@ -153,16 +153,16 @@ router.get('/user-permissions', authenticateToken, async (req, res) => {
// If no specific permissions found, return default admin permissions
return res.json({
role: userRole,
canViewDashboard: true,
canViewHosts: true,
canManageHosts: true,
canViewPackages: true,
canManagePackages: true,
canViewUsers: true,
canManageUsers: true,
canViewReports: true,
canExportData: true,
canManageSettings: true,
can_view_dashboard: true,
can_view_hosts: true,
can_manage_hosts: true,
can_view_packages: true,
can_manage_packages: true,
can_view_users: true,
can_manage_users: true,
can_view_reports: true,
can_export_data: true,
can_manage_settings: true,
});
}

View File

@@ -16,7 +16,7 @@ router.get('/setup', authenticateToken, async (req, res) => {
// Check if user already has TFA enabled
const user = await prisma.users.findUnique({
where: { id: userId },
select: { tfaEnabled: true, tfaSecret: true }
select: { tfa_enabled: true, tfa_secret: true }
});
if (user.tfa_enabled) {
@@ -86,7 +86,7 @@ router.post('/verify-setup', authenticateToken, [
// Verify the token
const verified = speakeasy.totp.verify({
secret: user.tfaSecret,
secret: user.tfa_secret,
encoding: 'base32',
token: token,
window: 2 // Allow 2 time windows (60 seconds) for clock drift
@@ -201,7 +201,7 @@ router.post('/regenerate-backup-codes', authenticateToken, async (req, res) => {
// Check if TFA is enabled
const user = await prisma.users.findUnique({
where: { id: userId },
select: { tfaEnabled: true }
select: { tfa_enabled: true }
});
if (!user.tfa_enabled) {
@@ -219,7 +219,7 @@ router.post('/regenerate-backup-codes', authenticateToken, async (req, res) => {
await prisma.users.update({
where: { id: userId },
data: {
tfaBackupCodes: JSON.stringify(backupCodes)
tfa_backup_codes: JSON.stringify(backupCodes)
}
});
@@ -265,7 +265,7 @@ router.post('/verify', [
}
// Check if it's a backup code
const backupCodes = user.tfaBackupCodes ? JSON.parse(user.tfaBackupCodes) : [];
const backupCodes = user.tfa_backup_codes ? JSON.parse(user.tfa_backup_codes) : [];
const isBackupCode = backupCodes.includes(token);
let verified = false;
@@ -276,7 +276,7 @@ router.post('/verify', [
await prisma.users.update({
where: { id: user.id },
data: {
tfaBackupCodes: JSON.stringify(updatedBackupCodes)
tfa_backup_codes: JSON.stringify(updatedBackupCodes)
}
});
verified = true;

View File

@@ -27,70 +27,70 @@ function App() {
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<ProtectedRoute requirePermission="canViewDashboard">
<ProtectedRoute requirePermission="can_view_dashboard">
<Layout>
<Dashboard />
</Layout>
</ProtectedRoute>
} />
<Route path="/hosts" element={
<ProtectedRoute requirePermission="canViewHosts">
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<Hosts />
</Layout>
</ProtectedRoute>
} />
<Route path="/hosts/:hostId" element={
<ProtectedRoute requirePermission="canViewHosts">
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<HostDetail />
</Layout>
</ProtectedRoute>
} />
<Route path="/packages" element={
<ProtectedRoute requirePermission="canViewPackages">
<ProtectedRoute requirePermission="can_view_packages">
<Layout>
<Packages />
</Layout>
</ProtectedRoute>
} />
<Route path="/repositories" element={
<ProtectedRoute requirePermission="canViewHosts">
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<Repositories />
</Layout>
</ProtectedRoute>
} />
<Route path="/repositories/:repositoryId" element={
<ProtectedRoute requirePermission="canViewHosts">
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<RepositoryDetail />
</Layout>
</ProtectedRoute>
} />
<Route path="/users" element={
<ProtectedRoute requirePermission="canViewUsers">
<ProtectedRoute requirePermission="can_view_users">
<Layout>
<Users />
</Layout>
</ProtectedRoute>
} />
<Route path="/permissions" element={
<ProtectedRoute requirePermission="canManageSettings">
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<Permissions />
</Layout>
</ProtectedRoute>
} />
<Route path="/settings" element={
<ProtectedRoute requirePermission="canManageSettings">
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<Settings />
</Layout>
</ProtectedRoute>
} />
<Route path="/options" element={
<ProtectedRoute requirePermission="canManageHosts">
<ProtectedRoute requirePermission="can_manage_hosts">
<Layout>
<Options />
</Layout>
@@ -104,7 +104,7 @@ function App() {
</ProtectedRoute>
} />
<Route path="/packages/:packageId" element={
<ProtectedRoute requirePermission="canViewPackages">
<ProtectedRoute requirePermission="can_view_packages">
<Layout>
<PackageDetail />
</Layout>

View File

@@ -46,11 +46,11 @@ const Layout = ({ children }) => {
const userMenuRef = useRef(null)
// Fetch dashboard stats for the "Last updated" info
const { data: stats, refetch } = useQuery({
const { data: stats, refetch, isFetching } = useQuery({
queryKey: ['dashboardStats'],
queryFn: () => dashboardAPI.getStats().then(res => res.data),
refetchInterval: 60000, // Refresh every minute
staleTime: 30000, // Consider data stale after 30 seconds
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
})
// Fetch version info
@@ -477,10 +477,11 @@ const Layout = ({ children }) => {
<span className="truncate">Updated: {formatRelativeTimeShort(stats.lastUpdated)}</span>
<button
onClick={() => refetch()}
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded flex-shrink-0"
disabled={isFetching}
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded flex-shrink-0 disabled:opacity-50"
title="Refresh data"
>
<RefreshCw className="h-3 w-3" />
<RefreshCw className={`h-3 w-3 ${isFetching ? 'animate-spin' : ''}`} />
</button>
{versionInfo && (
<span className="text-xs text-secondary-400 dark:text-secondary-500 flex-shrink-0">
@@ -516,10 +517,11 @@ const Layout = ({ children }) => {
<div className="flex flex-col items-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"
disabled={isFetching}
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded disabled:opacity-50"
title={`Refresh data - Updated: ${formatRelativeTimeShort(stats.lastUpdated)}`}
>
<RefreshCw className="h-3 w-3" />
<RefreshCw className={`h-3 w-3 ${isFetching ? 'animate-spin' : ''}`} />
</button>
{versionInfo && (
<span className="text-xs text-secondary-400 dark:text-secondary-500 mt-1">

View File

@@ -15,6 +15,7 @@ export const AuthProvider = ({ children }) => {
const [token, setToken] = useState(null)
const [permissions, setPermissions] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [permissionsLoading, setPermissionsLoading] = useState(false)
// Initialize auth state from localStorage
useEffect(() => {
@@ -42,20 +43,17 @@ export const AuthProvider = ({ children }) => {
setIsLoading(false)
}, [])
// Periodically refresh permissions when user is logged in
// Refresh permissions when user logs in (no automatic refresh)
useEffect(() => {
if (token && user) {
// Refresh permissions every 30 seconds
const interval = setInterval(() => {
refreshPermissions()
}, 30000)
return () => clearInterval(interval)
// Only refresh permissions once when user logs in
refreshPermissions()
}
}, [token, user])
const fetchPermissions = async (authToken) => {
try {
setPermissionsLoading(true)
const response = await fetch('/api/v1/permissions/user-permissions', {
headers: {
'Authorization': `Bearer ${authToken}`,
@@ -74,6 +72,8 @@ export const AuthProvider = ({ children }) => {
} catch (error) {
console.error('Error fetching permissions:', error)
return null
} finally {
setPermissionsLoading(false)
}
}
@@ -199,25 +199,29 @@ export const AuthProvider = ({ children }) => {
// Permission checking functions
const hasPermission = (permission) => {
// If permissions are still loading, return false to show loading state
if (permissionsLoading) {
return false
}
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 canViewDashboard = () => hasPermission('can_view_dashboard')
const canViewHosts = () => hasPermission('can_view_hosts')
const canManageHosts = () => hasPermission('can_manage_hosts')
const canViewPackages = () => hasPermission('can_view_packages')
const canManagePackages = () => hasPermission('can_manage_packages')
const canViewUsers = () => hasPermission('can_view_users')
const canManageUsers = () => hasPermission('can_manage_users')
const canViewReports = () => hasPermission('can_view_reports')
const canExportData = () => hasPermission('can_export_data')
const canManageSettings = () => hasPermission('can_manage_settings')
const value = {
user,
token,
permissions,
isLoading,
isLoading: isLoading || permissionsLoading,
login,
logout,
updateProfile,

View File

@@ -19,7 +19,8 @@ export const UpdateNotificationProvider = ({ children }) => {
const { data: updateData, isLoading, error } = useQuery({
queryKey: ['updateCheck'],
queryFn: () => versionAPI.checkUpdates().then(res => res.data),
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
staleTime: 10 * 60 * 1000, // Data stays fresh for 10 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
retry: 1
})

View File

@@ -89,11 +89,11 @@ const Dashboard = () => {
}
}
const { data: stats, isLoading, error, refetch } = useQuery({
const { data: stats, isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['dashboardStats'],
queryFn: () => dashboardAPI.getStats().then(res => res.data),
refetchInterval: 300000, // Refresh every 5 minutes instead of 1 minute
staleTime: 120000, // Consider data stale after 2 minutes
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
})
// Fetch settings to get the agent update interval
@@ -579,6 +579,26 @@ const Dashboard = () => {
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">Dashboard</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Overview of your PatchMon infrastructure
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center gap-2"
title="Refresh dashboard data"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Refreshing...' : 'Refresh'}
</button>
</div>
</div>
{/* Dynamically Rendered Cards - Unified Order */}
{(() => {

View File

@@ -46,15 +46,25 @@ const HostDetail = () => {
const [isEditingFriendlyName, setIsEditingFriendlyName] = useState(false)
const [editedFriendlyName, setEditedFriendlyName] = useState('')
const [showAllUpdates, setShowAllUpdates] = useState(false)
const [activeTab, setActiveTab] = useState('host')
const [activeTab, setActiveTab] = useState(() => {
// Restore tab state from localStorage
const savedTab = localStorage.getItem(`host-detail-tab-${hostId}`)
return savedTab || 'host'
})
const { data: host, isLoading, error, refetch } = useQuery({
const { data: host, isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['host', hostId],
queryFn: () => dashboardAPI.getHostDetail(hostId).then(res => res.data),
refetchInterval: 60000,
staleTime: 30000,
staleTime: 5 * 60 * 1000, // 5 minutes - data stays fresh longer
refetchOnWindowFocus: false, // Don't refetch when window regains focus
})
// Save tab state to localStorage when it changes
const handleTabChange = (tabName) => {
setActiveTab(tabName)
localStorage.setItem(`host-detail-tab-${hostId}`, tabName)
}
// Auto-show credentials modal for new/pending hosts
React.useEffect(() => {
if (host && host.status === 'pending') {
@@ -72,7 +82,7 @@ const HostDetail = () => {
// Toggle auto-update mutation
const toggleAutoUpdateMutation = useMutation({
mutationFn: (autoUpdate) => adminHostsAPI.toggleAutoUpdate(hostId, autoUpdate).then(res => res.data),
mutationFn: (auto_update) => adminHostsAPI.toggleAutoUpdate(hostId, auto_update).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries(['host', hostId])
queryClient.invalidateQueries(['hosts'])
@@ -88,7 +98,7 @@ const HostDetail = () => {
})
const handleDeleteHost = async () => {
if (window.confirm(`Are you sure you want to delete host "${host.friendlyName}"? This action cannot be undone.`)) {
if (window.confirm(`Are you sure you want to delete host "${host.friendly_name}"? This action cannot be undone.`)) {
try {
await deleteHostMutation.mutateAsync(hostId)
} catch (error) {
@@ -178,7 +188,7 @@ const HostDetail = () => {
return 'Up to Date'
}
const isStale = new Date() - new Date(host.lastUpdate) > 24 * 60 * 60 * 1000
const isStale = new Date() - new Date(host.last_update) > 24 * 60 * 60 * 1000
return (
<div className="h-screen flex flex-col">
@@ -188,18 +198,18 @@ const HostDetail = () => {
<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-xl font-semibold text-secondary-900 dark:text-white">{host.friendlyName}</h1>
{host.systemUptime && (
<h1 className="text-xl font-semibold text-secondary-900 dark:text-white">{host.friendly_name}</h1>
{host.system_uptime && (
<div className="flex items-center gap-1 text-sm text-secondary-600 dark:text-secondary-400">
<Clock className="h-4 w-4" />
<span className="text-xs font-medium">Uptime:</span>
<span>{host.systemUptime}</span>
<span>{host.system_uptime}</span>
</div>
)}
<div className="flex items-center gap-1 text-sm text-secondary-600 dark:text-secondary-400">
<Clock className="h-4 w-4" />
<span className="text-xs font-medium">Last updated:</span>
<span>{formatRelativeTime(host.lastUpdate)}</span>
<span>{formatRelativeTime(host.last_update)}</span>
</div>
<div className={`flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(isStale, host.stats.outdatedPackages > 0)}`}>
{getStatusIcon(isStale, host.stats.outdatedPackages > 0)}
@@ -207,6 +217,15 @@ const HostDetail = () => {
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center gap-2 text-sm"
title="Refresh host data"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Refreshing...' : 'Refresh'}
</button>
<button
onClick={() => setShowCredentialsModal(true)}
className="btn-outline flex items-center gap-2 text-sm"
@@ -232,7 +251,7 @@ const HostDetail = () => {
<div className="card">
<div className="flex border-b border-secondary-200 dark:border-secondary-600">
<button
onClick={() => setActiveTab('host')}
onClick={() => handleTabChange('host')}
className={`px-4 py-2 text-sm font-medium ${
activeTab === 'host'
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
@@ -242,17 +261,7 @@ const HostDetail = () => {
Host Info
</button>
<button
onClick={() => setActiveTab('hardware')}
className={`px-4 py-2 text-sm font-medium ${
activeTab === 'hardware'
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
: 'text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300'
}`}
>
Hardware
</button>
<button
onClick={() => setActiveTab('network')}
onClick={() => handleTabChange('network')}
className={`px-4 py-2 text-sm font-medium ${
activeTab === 'network'
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
@@ -262,7 +271,7 @@ const HostDetail = () => {
Network
</button>
<button
onClick={() => setActiveTab('system')}
onClick={() => handleTabChange('system')}
className={`px-4 py-2 text-sm font-medium ${
activeTab === 'system'
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
@@ -272,17 +281,17 @@ const HostDetail = () => {
System
</button>
<button
onClick={() => setActiveTab('monitoring')}
onClick={() => handleTabChange('monitoring')}
className={`px-4 py-2 text-sm font-medium ${
activeTab === 'monitoring'
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
: 'text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300'
}`}
>
Resource Monitor
Resource
</button>
<button
onClick={() => setActiveTab('history')}
onClick={() => handleTabChange('history')}
className={`px-4 py-2 text-sm font-medium ${
activeTab === 'history'
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
@@ -301,7 +310,7 @@ const HostDetail = () => {
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1">Friendly Name</p>
<InlineEdit
value={host.friendlyName}
value={host.friendly_name}
onSave={(newName) => updateFriendlyNameMutation.mutate(newName)}
placeholder="Enter friendly name..."
maxLength={100}
@@ -341,8 +350,8 @@ const HostDetail = () => {
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Operating System</p>
<div className="flex items-center gap-2">
<OSIcon osType={host.osType} className="h-4 w-4" />
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.osType} {host.osVersion}</p>
<OSIcon osType={host.os_type} className="h-4 w-4" />
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.os_type} {host.os_version}</p>
</div>
</div>
@@ -356,29 +365,29 @@ const HostDetail = () => {
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Last Update</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{formatRelativeTime(host.lastUpdate)}</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{formatRelativeTime(host.last_update)}</p>
</div>
{host.agentVersion && (
{host.agent_version && (
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Agent Version</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.agentVersion}</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.agent_version}</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-secondary-500 dark:text-secondary-300">Auto-update</span>
<button
onClick={() => toggleAutoUpdateMutation.mutate(!host.autoUpdate)}
onClick={() => toggleAutoUpdateMutation.mutate(!host.auto_update)}
disabled={toggleAutoUpdateMutation.isPending}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
host.autoUpdate
host.auto_update
? 'bg-primary-600 dark:bg-primary-500'
: 'bg-secondary-200 dark:bg-secondary-600'
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
host.autoUpdate ? 'translate-x-5' : 'translate-x-1'
host.auto_update ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</button>
@@ -389,88 +398,34 @@ const HostDetail = () => {
</div>
)}
{/* Hardware Information */}
{activeTab === 'hardware' && (host.cpuModel || host.ramInstalled || host.diskDetails) && (
<div className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{host.cpuModel && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">CPU Model</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.cpuModel}</p>
</div>
)}
{host.cpuCores && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">CPU Cores</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.cpuCores}</p>
</div>
)}
{host.ramInstalled && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">RAM Installed</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.ramInstalled} GB</p>
</div>
)}
{host.swapSize !== undefined && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Swap Size</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.swapSize} GB</p>
</div>
)}
</div>
{host.diskDetails && Array.isArray(host.diskDetails) && host.diskDetails.length > 0 && (
<div className="pt-4 border-t border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">Disk Details</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{host.diskDetails.map((disk, index) => (
<div key={index} className="bg-secondary-50 dark:bg-secondary-700 p-3 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<HardDrive className="h-4 w-4 text-secondary-500" />
<span className="font-medium text-secondary-900 dark:text-white text-sm">{disk.name}</span>
</div>
<p className="text-xs text-secondary-600 dark:text-secondary-300">Size: {disk.size}</p>
{disk.mountpoint && (
<p className="text-xs text-secondary-600 dark:text-secondary-300">Mount: {disk.mountpoint}</p>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Network Information */}
{activeTab === 'network' && (host.gatewayIp || host.dnsServers || host.networkInterfaces) && (
{activeTab === 'network' && (host.gateway_ip || host.dns_servers || host.network_interfaces) && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{host.gatewayIp && (
{host.gateway_ip && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Gateway IP</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">{host.gatewayIp}</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">{host.gateway_ip}</p>
</div>
)}
{host.dnsServers && Array.isArray(host.dnsServers) && host.dnsServers.length > 0 && (
{host.dns_servers && Array.isArray(host.dns_servers) && host.dns_servers.length > 0 && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">DNS Servers</p>
<div className="space-y-1">
{host.dnsServers.map((dns, index) => (
{host.dns_servers.map((dns, index) => (
<p key={index} className="font-medium text-secondary-900 dark:text-white font-mono text-sm">{dns}</p>
))}
</div>
</div>
)}
{host.networkInterfaces && Array.isArray(host.networkInterfaces) && host.networkInterfaces.length > 0 && (
{host.network_interfaces && Array.isArray(host.network_interfaces) && host.network_interfaces.length > 0 && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Network Interfaces</p>
<div className="space-y-1">
{host.networkInterfaces.map((iface, index) => (
{host.network_interfaces.map((iface, index) => (
<p key={index} className="font-medium text-secondary-900 dark:text-white text-sm">{iface.name}</p>
))}
</div>
@@ -481,7 +436,7 @@ const HostDetail = () => {
)}
{/* System Information */}
{activeTab === 'system' && (host.kernelVersion || host.selinuxStatus || host.architecture) && (
{activeTab === 'system' && (host.kernel_version || host.selinux_status || host.architecture) && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{host.architecture && (
@@ -491,24 +446,24 @@ const HostDetail = () => {
</div>
)}
{host.kernelVersion && (
{host.kernel_version && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Kernel Version</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">{host.kernelVersion}</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">{host.kernel_version}</p>
</div>
)}
{host.selinuxStatus && (
{host.selinux_status && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">SELinux Status</p>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
host.selinuxStatus === 'enabled'
host.selinux_status === 'enabled'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: host.selinuxStatus === 'permissive'
: host.selinux_status === 'permissive'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
}`}>
{host.selinuxStatus}
{host.selinux_status}
</span>
</div>
)}
@@ -518,22 +473,15 @@ const HostDetail = () => {
</div>
)}
{/* Empty state for tabs with no data */}
{activeTab === 'hardware' && !(host.cpuModel || host.ramInstalled || host.diskDetails) && (
<div className="text-center py-8">
<Cpu className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">No hardware information available</p>
</div>
)}
{activeTab === 'network' && !(host.gatewayIp || host.dnsServers || host.networkInterfaces) && (
{activeTab === 'network' && !(host.gateway_ip || host.dns_servers || host.network_interfaces) && (
<div className="text-center py-8">
<Wifi className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">No network information available</p>
</div>
)}
{activeTab === 'system' && !(host.kernelVersion || host.selinuxStatus || host.architecture) && (
{activeTab === 'system' && !(host.kernel_version || host.selinux_status || host.architecture) && (
<div className="text-center py-8">
<Terminal className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">No system information available</p>
@@ -541,35 +489,143 @@ const HostDetail = () => {
)}
{/* System Monitoring */}
{activeTab === 'monitoring' && host.loadAverage && Array.isArray(host.loadAverage) && host.loadAverage.length > 0 && (
<div className="space-y-4">
{activeTab === 'monitoring' && (
<div className="space-y-6">
{/* System Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Load Average</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.loadAverage.map((load, index) => (
<span key={index}>
{load.toFixed(2)}
{index < host.loadAverage.length - 1 && ', '}
</span>
{/* System Uptime */}
{host.system_uptime && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">System Uptime</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.system_uptime}</p>
</div>
)}
{/* CPU Model */}
{host.cpu_model && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Cpu className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">CPU Model</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.cpu_model}</p>
</div>
)}
{/* CPU Cores */}
{host.cpu_cores && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Cpu className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">CPU Cores</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.cpu_cores}</p>
</div>
)}
{/* RAM Installed */}
{host.ram_installed && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<MemoryStick className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">RAM Installed</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.ram_installed} GB</p>
</div>
)}
{/* Swap Size */}
{host.swap_size !== undefined && host.swap_size !== null && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<MemoryStick className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">Swap Size</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.swap_size} GB</p>
</div>
)}
{/* Load Average */}
{host.load_average && Array.isArray(host.load_average) && host.load_average.length > 0 && host.load_average.some(load => load != null) && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Activity className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">Load Average</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.load_average.filter(load => load != null).map((load, index) => (
<span key={index}>
{typeof load === 'number' ? load.toFixed(2) : String(load)}
{index < host.load_average.filter(load => load != null).length - 1 && ', '}
</span>
))}
</p>
</div>
)}
</div>
{/* Disk Information */}
{host.disk_details && Array.isArray(host.disk_details) && host.disk_details.length > 0 && (
<div className="pt-4 border-t border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3 flex items-center gap-2">
<HardDrive className="h-4 w-4 text-primary-600 dark:text-primary-400" />
Disk Usage
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{host.disk_details.map((disk, index) => (
<div key={index} className="bg-secondary-50 dark:bg-secondary-700 p-3 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<HardDrive className="h-4 w-4 text-secondary-500" />
<span className="font-medium text-secondary-900 dark:text-white text-sm">{disk.name || `Disk ${index + 1}`}</span>
</div>
{disk.size && (
<p className="text-xs text-secondary-600 dark:text-secondary-300 mb-1">Size: {disk.size}</p>
)}
{disk.mountpoint && (
<p className="text-xs text-secondary-600 dark:text-secondary-300 mb-1">Mount: {disk.mountpoint}</p>
)}
{disk.usage && typeof disk.usage === 'number' && (
<div className="mt-2">
<div className="flex justify-between text-xs text-secondary-600 dark:text-secondary-300 mb-1">
<span>Usage</span>
<span>{disk.usage}%</span>
</div>
<div className="w-full bg-secondary-200 dark:bg-secondary-600 rounded-full h-2">
<div
className="bg-primary-600 dark:bg-primary-400 h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.min(Math.max(disk.usage, 0), 100)}%` }}
></div>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* No Data State */}
{!host.system_uptime && !host.cpu_model && !host.cpu_cores && !host.ram_installed && host.swap_size === undefined &&
(!host.load_average || !Array.isArray(host.load_average) || host.load_average.length === 0 || !host.load_average.some(load => load != null)) &&
(!host.disk_details || !Array.isArray(host.disk_details) || host.disk_details.length === 0) && (
<div className="text-center py-8">
<Monitor className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">No monitoring data available</p>
<p className="text-xs text-secondary-400 dark:text-secondary-400 mt-1">
Monitoring data will appear once the agent collects system information
</p>
</div>
</div>
</div>
)}
{activeTab === 'monitoring' && (!host.loadAverage || !Array.isArray(host.loadAverage) || host.loadAverage.length === 0) && (
<div className="text-center py-8">
<Monitor className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">No monitoring data available</p>
)}
</div>
)}
{/* Update History */}
{activeTab === 'history' && (
<div className="overflow-x-auto">
{host.updateHistory?.length > 0 ? (
{host.update_history?.length > 0 ? (
<>
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
@@ -589,7 +645,7 @@ const HostDetail = () => {
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{(showAllUpdates ? host.updateHistory : host.updateHistory.slice(0, 5)).map((update, index) => (
{(showAllUpdates ? host.update_history : host.update_history.slice(0, 5)).map((update, index) => (
<tr key={update.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700">
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center gap-1.5">
@@ -607,14 +663,14 @@ const HostDetail = () => {
{formatDate(update.timestamp)}
</td>
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
{update.packagesCount}
{update.packages_count}
</td>
<td className="px-4 py-2 whitespace-nowrap">
{update.securityCount > 0 ? (
{update.security_count > 0 ? (
<div className="flex items-center gap-1">
<Shield className="h-3 w-3 text-danger-600" />
<span className="text-xs text-danger-600 font-medium">
{update.securityCount}
{update.security_count}
</span>
</div>
) : (
@@ -626,7 +682,7 @@ const HostDetail = () => {
</tbody>
</table>
{host.updateHistory.length > 5 && (
{host.update_history.length > 5 && (
<div className="px-4 py-2 border-t border-secondary-200 dark:border-secondary-600 bg-secondary-50 dark:bg-secondary-700">
<button
onClick={() => setShowAllUpdates(!showAllUpdates)}
@@ -640,7 +696,7 @@ const HostDetail = () => {
) : (
<>
<ChevronDown className="h-3 w-3" />
Show All ({host.updateHistory.length} total)
Show All ({host.update_history.length} total)
</>
)}
</button>
@@ -746,7 +802,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
}
const getSetupCommands = () => {
return `# Run this on the target host: ${host?.friendlyName}
return `# Run this on the target host: ${host?.friendly_name}
echo "🔄 Setting up PatchMon agent..."
@@ -788,7 +844,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<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.friendlyName}</h3>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Host Setup - {host.friendly_name}</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>
@@ -1069,7 +1125,7 @@ const DeleteConfirmationModal = ({ host, isOpen, onClose, onConfirm, isLoading }
<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.friendlyName}"</span>?
<span className="font-semibold">"{host.friendly_name}"</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">

View File

@@ -38,7 +38,7 @@ import InlineGroupEdit from '../components/InlineGroupEdit'
// Add Host Modal Component
const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
const [formData, setFormData] = useState({
friendlyName: '',
friendly_name: '',
hostGroupId: ''
})
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -62,7 +62,7 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
const response = await adminHostsAPI.create(formData)
console.log('Host created successfully:', response.data)
onSuccess(response.data)
setFormData({ friendlyName: '', hostGroupId: '' })
setFormData({ friendly_name: '', hostGroupId: '' })
onClose()
} catch (err) {
console.error('Full error object:', err)
@@ -105,8 +105,8 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
<input
type="text"
required
value={formData.friendlyName}
onChange={(e) => setFormData({ ...formData, friendlyName: e.target.value })}
value={formData.friendly_name}
onChange={(e) => setFormData({ ...formData, friendly_name: e.target.value })}
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"
/>
@@ -252,7 +252,7 @@ echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo
fullSetup: `#!/bin/bash
# Complete PatchMon Agent Setup Script
# Run this on the target host: ${host?.friendlyName}
# Run this on the target host: ${host?.friendly_name}
echo "🔄 Setting up PatchMon agent..."
@@ -295,7 +295,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<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-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">Host Setup - {host.friendlyName}</h3>
<h3 className="text-lg font-medium text-secondary-900">Host Setup - {host.friendly_name}</h3>
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600">
<X className="h-5 w-5" />
</button>
@@ -351,7 +351,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<h4 className="text-sm font-medium text-green-800 mb-2">🚀 One-Line Installation</h4>
<p className="text-sm text-green-700">
Copy and paste this single command on <strong>{host.friendlyName}</strong> to install and configure the PatchMon agent automatically.
Copy and paste this single command on <strong>{host.friendly_name}</strong> to install and configure the PatchMon agent automatically.
</p>
</div>
@@ -378,7 +378,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<ul className="text-sm text-blue-700 space-y-1">
<li> Downloads the PatchMon installation script</li>
<li> Installs the agent to <code>/usr/local/bin/patchmon-agent.sh</code></li>
<li> Configures API credentials for <strong>{host.friendlyName}</strong></li>
<li> Configures API credentials for <strong>{host.friendly_name}</strong></li>
<li> Tests the connection to PatchMon server</li>
<li> Sends initial package data</li>
<li> Sets up hourly automatic updates via crontab</li>
@@ -444,7 +444,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="bg-amber-50 border border-amber-200 rounded-md p-4">
<h4 className="text-sm font-medium text-amber-800 mb-2"> Security Note</h4>
<p className="text-sm text-amber-700">
Keep these credentials secure. They provide access to update package information for <strong>{host.friendlyName}</strong> only.
Keep these credentials secure. They provide access to update package information for <strong>{host.friendly_name}</strong> only.
</p>
</div>
</div>
@@ -455,7 +455,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<h4 className="text-sm font-medium text-blue-800 mb-2">📋 Step-by-Step Setup</h4>
<p className="text-sm text-blue-700">
Follow these commands on <strong>{host.friendlyName}</strong> to install and configure the PatchMon agent.
Follow these commands on <strong>{host.friendly_name}</strong> to install and configure the PatchMon agent.
</p>
</div>
@@ -552,7 +552,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<h4 className="text-sm font-medium text-green-800 mb-2">🚀 Automated Setup</h4>
<p className="text-sm text-green-700">
Copy this complete setup script to <strong>{host.friendlyName}</strong> and run it to automatically install and configure everything.
Copy this complete setup script to <strong>{host.friendly_name}</strong> and run it to automatically install and configure everything.
</p>
</div>
@@ -573,7 +573,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="mt-3 text-sm text-secondary-600">
<p><strong>Usage:</strong></p>
<p>1. Copy the script above</p>
<p>2. Save it to a file on {host.friendlyName} (e.g., <code>setup-patchmon.sh</code>)</p>
<p>2. Save it to a file on {host.friendly_name} (e.g., <code>setup-patchmon.sh</code>)</p>
<p>3. Run: <code>chmod +x setup-patchmon.sh && sudo ./setup-patchmon.sh</code></p>
</div>
</div>
@@ -666,65 +666,38 @@ const Hosts = () => {
{ id: 'ip', label: 'IP Address', visible: false, order: 2 },
{ id: 'group', label: 'Group', visible: true, order: 3 },
{ id: 'os', label: 'OS', visible: true, order: 4 },
{ id: 'osVersion', label: 'OS Version', visible: false, order: 5 },
{ id: 'agentVersion', label: 'Agent Version', visible: true, order: 6 },
{ id: 'autoUpdate', label: 'Auto-update', visible: true, order: 7 },
{ id: 'os_version', label: 'OS Version', visible: false, order: 5 },
{ id: 'agent_version', label: 'Agent Version', visible: true, order: 6 },
{ id: 'auto_update', label: 'Auto-update', visible: true, order: 7 },
{ id: 'status', label: 'Status', visible: true, order: 8 },
{ id: 'updates', label: 'Updates', visible: true, order: 9 },
{ id: 'lastUpdate', label: 'Last Update', visible: true, order: 10 },
{ id: 'last_update', label: 'Last Update', visible: true, order: 10 },
{ id: 'actions', label: 'Actions', visible: true, order: 11 }
]
const saved = localStorage.getItem('hosts-column-config')
if (saved) {
const savedConfig = JSON.parse(saved)
try {
const savedConfig = JSON.parse(saved)
// Check if agentVersion column exists in saved config
const hasAgentVersion = savedConfig.some(col => col.id === 'agentVersion')
const hasAutoUpdate = savedConfig.some(col => col.id === 'autoUpdate')
// Check if we have old camelCase column IDs that need to be migrated
const hasOldColumns = savedConfig.some(col =>
col.id === 'agentVersion' || col.id === 'autoUpdate' || col.id === 'osVersion' || col.id === 'lastUpdate'
)
let needsUpdate = false
let updatedConfig = [...savedConfig]
if (!hasAgentVersion) {
// Add agentVersion column to saved config
const agentVersionColumn = { id: 'agentVersion', label: 'Agent Version', visible: true, order: 6 }
// Insert agentVersion column at the correct position
updatedConfig = updatedConfig.map(col => {
if (col.order >= 6) {
return { ...col, order: col.order + 1 }
}
return col
})
updatedConfig.push(agentVersionColumn)
needsUpdate = true
if (hasOldColumns) {
// Clear the old configuration and use the default snake_case configuration
localStorage.removeItem('hosts-column-config')
return defaultConfig
} else {
// Use the existing configuration
return savedConfig
}
} catch (error) {
// If there's an error parsing the config, clear it and use default
localStorage.removeItem('hosts-column-config')
return defaultConfig
}
if (!hasAutoUpdate) {
// Add autoUpdate column to saved config
const autoUpdateColumn = { id: 'autoUpdate', label: 'Auto-update', visible: true, order: 7 }
// Insert autoUpdate column at the correct position
updatedConfig = updatedConfig.map(col => {
if (col.order >= 7) {
return { ...col, order: col.order + 1 }
}
return col
})
updatedConfig.push(autoUpdateColumn)
needsUpdate = true
}
if (needsUpdate) {
updatedConfig.sort((a, b) => a.order - b.order)
localStorage.setItem('hosts-column-config', JSON.stringify(updatedConfig))
return updatedConfig
}
return savedConfig
}
return defaultConfig
@@ -732,11 +705,11 @@ const Hosts = () => {
const queryClient = useQueryClient()
const { data: hosts, isLoading, error, refetch } = useQuery({
const { data: hosts, isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['hosts'],
queryFn: () => dashboardAPI.getHosts().then(res => res.data),
refetchInterval: 300000, // Refresh every 5 minutes instead of 1 minute
staleTime: 120000, // Consider data stale after 2 minutes
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
})
const { data: hostGroups } = useQuery({
@@ -776,7 +749,7 @@ const Hosts = () => {
// Toggle auto-update mutation
const toggleAutoUpdateMutation = useMutation({
mutationFn: ({ hostId, autoUpdate }) => adminHostsAPI.toggleAutoUpdate(hostId, autoUpdate).then(res => res.data),
mutationFn: ({ hostId, auto_update }) => adminHostsAPI.toggleAutoUpdate(hostId, auto_update).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries(['hosts'])
}
@@ -859,9 +832,9 @@ const Hosts = () => {
let filtered = hosts.filter(host => {
// Search filter
const matchesSearch = searchTerm === '' ||
host.friendlyName.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.friendly_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.ip?.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.osType?.toLowerCase().includes(searchTerm.toLowerCase())
host.os_type?.toLowerCase().includes(searchTerm.toLowerCase())
// Group filter
const matchesGroup = groupFilter === 'all' ||
@@ -872,7 +845,7 @@ const Hosts = () => {
const matchesStatus = statusFilter === 'all' || (host.effectiveStatus || host.status) === statusFilter
// OS filter
const matchesOs = osFilter === 'all' || host.osType?.toLowerCase() === osFilter.toLowerCase()
const matchesOs = osFilter === 'all' || host.os_type?.toLowerCase() === osFilter.toLowerCase()
// URL filter for hosts needing updates, inactive hosts, or up-to-date hosts
const filter = searchParams.get('filter')
@@ -893,8 +866,8 @@ const Hosts = () => {
switch (sortField) {
case 'friendlyName':
aValue = a.friendlyName.toLowerCase()
bValue = b.friendlyName.toLowerCase()
aValue = a.friendly_name.toLowerCase()
bValue = b.friendly_name.toLowerCase()
break
case 'hostname':
aValue = a.hostname?.toLowerCase() || 'zzz_no_hostname'
@@ -909,16 +882,16 @@ const Hosts = () => {
bValue = b.hostGroup?.name || 'zzz_ungrouped'
break
case 'os':
aValue = a.osType?.toLowerCase() || 'zzz_unknown'
bValue = b.osType?.toLowerCase() || 'zzz_unknown'
aValue = a.os_type?.toLowerCase() || 'zzz_unknown'
bValue = b.os_type?.toLowerCase() || 'zzz_unknown'
break
case 'osVersion':
aValue = a.osVersion?.toLowerCase() || 'zzz_unknown'
bValue = b.osVersion?.toLowerCase() || 'zzz_unknown'
case 'os_version':
aValue = a.os_version?.toLowerCase() || 'zzz_unknown'
bValue = b.os_version?.toLowerCase() || 'zzz_unknown'
break
case 'agentVersion':
aValue = a.agentVersion?.toLowerCase() || 'zzz_no_version'
bValue = b.agentVersion?.toLowerCase() || 'zzz_no_version'
case 'agent_version':
aValue = a.agent_version?.toLowerCase() || 'zzz_no_version'
bValue = b.agent_version?.toLowerCase() || 'zzz_no_version'
break
case 'status':
aValue = a.effectiveStatus || a.status
@@ -928,9 +901,9 @@ const Hosts = () => {
aValue = a.updatesCount || 0
bValue = b.updatesCount || 0
break
case 'lastUpdate':
aValue = new Date(a.lastUpdate)
bValue = new Date(b.lastUpdate)
case 'last_update':
aValue = new Date(a.last_update)
bValue = new Date(b.last_update)
break
default:
aValue = a[sortField]
@@ -962,7 +935,7 @@ const Hosts = () => {
groupKey = (host.effectiveStatus || host.status).charAt(0).toUpperCase() + (host.effectiveStatus || host.status).slice(1)
break
case 'os':
groupKey = host.osType || 'Unknown'
groupKey = host.os_type || 'Unknown'
break
default:
groupKey = 'All Hosts'
@@ -1022,10 +995,10 @@ const Hosts = () => {
{ id: 'ip', label: 'IP Address', visible: false, order: 3 },
{ id: 'group', label: 'Group', visible: true, order: 4 },
{ id: 'os', label: 'OS', visible: true, order: 5 },
{ id: 'osVersion', label: 'OS Version', visible: false, order: 6 },
{ id: 'os_version', label: 'OS Version', visible: false, order: 6 },
{ id: 'status', label: 'Status', visible: true, order: 7 },
{ id: 'updates', label: 'Updates', visible: true, order: 8 },
{ id: 'lastUpdate', label: 'Last Update', visible: true, order: 9 },
{ id: 'last_update', label: 'Last Update', visible: true, order: 9 },
{ id: 'actions', label: 'Actions', visible: true, order: 10 }
]
updateColumnConfig(defaultConfig)
@@ -1055,7 +1028,7 @@ const Hosts = () => {
case 'host':
return (
<InlineEdit
value={host.friendlyName}
value={host.friendly_name}
onSave={(newName) => updateFriendlyNameMutation.mutate({ hostId: host.id, friendlyName: newName })}
placeholder="Enter friendly name..."
maxLength={100}
@@ -1101,30 +1074,30 @@ const Hosts = () => {
case 'os':
return (
<div className="flex items-center gap-2 text-sm text-secondary-900 dark:text-white">
<OSIcon osType={host.osType} className="h-4 w-4" />
<span>{host.osType}</span>
<OSIcon osType={host.os_type} className="h-4 w-4" />
<span>{host.os_type}</span>
</div>
)
case 'osVersion':
case 'os_version':
return (
<div className="text-sm text-secondary-900 dark:text-white">
{host.osVersion || 'N/A'}
{host.os_version || 'N/A'}
</div>
)
case 'agentVersion':
case 'agent_version':
return (
<div className="text-sm text-secondary-900 dark:text-white">
{host.agentVersion || 'N/A'}
{host.agent_version || 'N/A'}
</div>
)
case 'autoUpdate':
case 'auto_update':
return (
<span className={`text-sm font-medium ${
host.autoUpdate
host.auto_update
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}>
{host.autoUpdate ? 'Yes' : 'No'}
{host.auto_update ? 'Yes' : 'No'}
</span>
)
case 'status':
@@ -1139,10 +1112,10 @@ const Hosts = () => {
{host.updatesCount || 0}
</div>
)
case 'lastUpdate':
case 'last_update':
return (
<div className="text-sm text-secondary-500 dark:text-secondary-300">
{formatRelativeTime(host.lastUpdate)}
{formatRelativeTime(host.last_update)}
</div>
)
case 'actions':
@@ -1260,6 +1233,33 @@ const Hosts = () => {
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">Hosts</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Manage and monitor your connected hosts
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center gap-2"
title="Refresh hosts data"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Refreshing...' : 'Refresh'}
</button>
<button
onClick={() => setShowAddModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Add Host
</button>
</div>
</div>
{/* Stats Summary */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4">
@@ -1550,23 +1550,23 @@ const Hosts = () => {
{column.label}
{getSortIcon('os')}
</button>
) : column.id === 'osVersion' ? (
) : column.id === 'os_version' ? (
<button
onClick={() => handleSort('osVersion')}
onClick={() => handleSort('os_version')}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon('osVersion')}
{getSortIcon('os_version')}
</button>
) : column.id === 'agentVersion' ? (
) : column.id === 'agent_version' ? (
<button
onClick={() => handleSort('agentVersion')}
onClick={() => handleSort('agent_version')}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon('agentVersion')}
{getSortIcon('agent_version')}
</button>
) : column.id === 'autoUpdate' ? (
) : column.id === 'auto_update' ? (
<div className="flex items-center gap-2 font-normal text-xs text-secondary-500 dark:text-secondary-300 normal-case tracking-wider">
{column.label}
</div>
@@ -1586,13 +1586,13 @@ const Hosts = () => {
{column.label}
{getSortIcon('updates')}
</button>
) : column.id === 'lastUpdate' ? (
) : column.id === 'last_update' ? (
<button
onClick={() => handleSort('lastUpdate')}
onClick={() => handleSort('last_update')}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon('lastUpdate')}
{getSortIcon('last_update')}
</button>
) : (
column.label
@@ -1679,7 +1679,7 @@ const BulkAssignModal = ({ selectedHosts, hosts, onClose, onAssign, isLoading })
const selectedHostNames = hosts
.filter(host => selectedHosts.includes(host.id))
.map(host => host.friendlyName)
.map(host => host.friendly_name)
const handleSubmit = (e) => {
e.preventDefault()

View File

@@ -7,7 +7,8 @@ import {
Server,
Users,
AlertTriangle,
CheckCircle
CheckCircle,
Settings
} from 'lucide-react'
import { hostGroupsAPI } from '../utils/api'
@@ -214,7 +215,7 @@ const Options = () => {
const renderComingSoonTab = (tabName) => (
<div className="text-center py-12">
<SettingsIcon className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<Settings 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">
{tabName} Coming Soon
</h3>

View File

@@ -98,19 +98,19 @@ const Packages = () => {
}
}, [searchParams])
const { data: packages, isLoading, error, refetch } = useQuery({
const { data: packages, isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['packages'],
queryFn: () => dashboardAPI.getPackages().then(res => res.data),
refetchInterval: 300000, // Refresh every 5 minutes instead of 1 minute
staleTime: 120000, // Consider data stale after 2 minutes
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
})
// Fetch hosts data to get total packages count
const { data: hosts } = useQuery({
queryKey: ['hosts'],
queryFn: () => dashboardAPI.getHosts().then(res => res.data),
refetchInterval: 300000, // Refresh every 5 minutes instead of 1 minute
staleTime: 120000, // Consider data stale after 2 minutes
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
})
// Filter and sort packages
@@ -330,6 +330,26 @@ const Packages = () => {
return (
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
{/* Page Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">Packages</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Manage package updates and security patches
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center gap-2"
title="Refresh packages data"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Refreshing...' : 'Refresh'}
</button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6 flex-shrink-0">

View File

@@ -148,8 +148,8 @@ const Repositories = () => {
(filterType === 'insecure' && !isSecure);
const matchesStatus = filterStatus === 'all' ||
(filterStatus === 'active' && repo.isActive === true) ||
(filterStatus === 'inactive' && repo.isActive === false);
(filterStatus === 'active' && repo.is_active === true) ||
(filterStatus === 'inactive' && repo.is_active === false);
console.log('Filter results:', {
matchesSearch,
@@ -171,8 +171,8 @@ const Repositories = () => {
aValue = a.isSecure ? 'Secure' : 'Insecure';
bValue = b.isSecure ? 'Secure' : 'Insecure';
} else if (sortField === 'status') {
aValue = a.isActive ? 'Active' : 'Inactive';
bValue = b.isActive ? 'Active' : 'Inactive';
aValue = a.is_active ? 'Active' : 'Inactive';
bValue = b.is_active ? 'Active' : 'Inactive';
}
if (typeof aValue === 'string') {
@@ -426,11 +426,11 @@ const Repositories = () => {
case 'status':
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
repo.isActive
repo.is_active
? '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'}
{repo.is_active ? 'Active' : 'Inactive'}
</span>
)
case 'hostCount':

View File

@@ -45,7 +45,7 @@ const RepositoryDetail = () => {
setFormData({
name: repository.name,
description: repository.description || '',
isActive: repository.isActive,
is_active: repository.is_active,
priority: repository.priority || ''
});
setEditMode(true);
@@ -139,11 +139,11 @@ const RepositoryDetail = () => {
{repository.name}
</h1>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
repository.isActive
repository.is_active
? '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'}
{repository.is_active ? 'Active' : 'Inactive'}
</span>
</div>
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
@@ -228,12 +228,12 @@ const RepositoryDetail = () => {
<div className="flex items-center">
<input
type="checkbox"
id="isActive"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
id="is_active"
checked={formData.is_active}
onChange={(e) => setFormData({ ...formData, is_active: 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">
<label htmlFor="is_active" className="ml-2 block text-sm text-secondary-900 dark:text-white">
Repository is active
</label>
</div>
@@ -295,7 +295,7 @@ const RepositoryDetail = () => {
<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()}
{new Date(repository.created_at).toLocaleDateString()}
</span>
</div>
</div>
@@ -339,7 +339,7 @@ const RepositoryDetail = () => {
to={`/hosts/${hostRepo.host.id}`}
className="text-primary-600 hover:text-primary-700 font-medium"
>
{hostRepo.host.friendlyName}
{hostRepo.host.friendly_name}
</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>

View File

@@ -732,7 +732,7 @@ const Settings = () => {
</div>
)}
<p className="text-xs text-secondary-400 dark:text-secondary-400 mt-1">
Created: {new Date(version.createdAt).toLocaleDateString()}
Created: {new Date(version.created_at).toLocaleDateString()}
</p>
</div>
</div>

View File

@@ -140,7 +140,7 @@ const Users = () => {
<Shield className="h-3 w-3 mr-1" />
{user.role.charAt(0).toUpperCase() + user.role.slice(1).replace('_', ' ')}
</span>
{user.isActive ? (
{user.is_active ? (
<CheckCircle className="ml-2 h-4 w-4 text-green-500" />
) : (
<XCircle className="ml-2 h-4 w-4 text-red-500" />
@@ -152,11 +152,11 @@ const Users = () => {
</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 && (
Created: {new Date(user.created_at).toLocaleDateString()}
{user.last_login && (
<>
<span className="mx-2"></span>
Last login: {new Date(user.lastLogin).toLocaleDateString()}
Last login: {new Date(user.last_login).toLocaleDateString()}
</>
)}
</div>
@@ -174,11 +174,11 @@ const Users = () => {
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
!user.is_active
? "Cannot reset password for inactive user"
: "Reset password"
}
disabled={!user.isActive}
disabled={!user.is_active}
>
<Key className="h-4 w-4" />
</button>
@@ -394,7 +394,7 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
username: user?.username || '',
email: user?.email || '',
role: user?.role || 'user',
isActive: user?.isActive ?? true
is_active: user?.is_active ?? true
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
@@ -486,8 +486,8 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
<div className="flex items-center">
<input
type="checkbox"
name="isActive"
checked={formData.isActive}
name="is_active"
checked={formData.is_active}
onChange={handleInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>

View File

@@ -63,8 +63,8 @@ export const adminHostsAPI = {
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 }),
updateFriendlyName: (hostId, friendlyName) => api.patch(`/hosts/${hostId}/friendly-name`, { friendlyName })
toggleAutoUpdate: (hostId, autoUpdate) => api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
updateFriendlyName: (hostId, friendlyName) => api.patch(`/hosts/${hostId}/friendly-name`, { friendly_name: friendlyName })
}
// Host Groups API
@@ -156,7 +156,7 @@ export const hostsAPI = {
'X-API-KEY': apiKey
}
}),
toggleAutoUpdate: (id, autoUpdate) => api.patch(`/hosts/${id}/auto-update`, { autoUpdate })
toggleAutoUpdate: (id, autoUpdate) => api.patch(`/hosts/${id}/auto-update`, { auto_update: autoUpdate })
}
// Packages API

95
package-lock.json generated
View File

@@ -59,6 +59,7 @@
"cors": "^2.8.5",
"date-fns": "^2.30.0",
"express": "^4.18.2",
"http-proxy-middleware": "^2.0.6",
"lucide-react": "^0.294.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
@@ -1773,6 +1774,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-proxy": {
"version": "1.17.16",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz",
"integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "24.5.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz",
"integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.12.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -2249,7 +2268,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -3495,6 +3513,12 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@@ -3640,7 +3664,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -4152,6 +4175,44 @@
"node": ">= 0.8"
}
},
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-proxy-middleware": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
"license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.8",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.1",
"is-plain-obj": "^3.0.0",
"micromatch": "^4.0.2"
},
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"@types/express": "^4.17.13"
},
"peerDependenciesMeta": {
"@types/express": {
"optional": true
}
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -4408,7 +4469,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -4462,7 +4522,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -4501,7 +4560,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -4534,6 +4592,18 @@
"node": ">=8"
}
},
"node_modules/is-plain-obj": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -5105,7 +5175,6 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -5675,7 +5744,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -6356,6 +6424,12 @@
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -7350,7 +7424,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -7553,6 +7626,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz",
"integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==",
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",