diff --git a/agents/patchmon-agent.sh b/agents/patchmon-agent.sh index f5aab46..436b1e5 100755 --- a/agents/patchmon-agent.sh +++ b/agents/patchmon-agent.sh @@ -1013,8 +1013,9 @@ update_crontab() { # Generate the expected crontab entry local expected_crontab="" if [[ $update_interval -eq 60 ]]; then - # Hourly updates - expected_crontab="0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" + # Hourly updates starting at current minute + local current_minute=$(date +%M) + expected_crontab="$current_minute * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" else # Custom interval updates expected_crontab="*/$update_interval * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" diff --git a/backend/prisma/migrations/20250922201205_add_user_name_fields/migration.sql b/backend/prisma/migrations/20250922201205_add_user_name_fields/migration.sql new file mode 100644 index 0000000..d947df2 --- /dev/null +++ b/backend/prisma/migrations/20250922201205_add_user_name_fields/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "first_name" TEXT, +ADD COLUMN "last_name" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ec1cb2b..87e455a 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -187,6 +187,8 @@ model users { username String @unique email String @unique password_hash String + first_name String? + last_name String? role String @default("admin") is_active Boolean @default(true) last_login DateTime? diff --git a/backend/src/routes/authRoutes.js b/backend/src/routes/authRoutes.js index 2cfced7..7ee1807 100644 --- a/backend/src/routes/authRoutes.js +++ b/backend/src/routes/authRoutes.js @@ -151,8 +151,13 @@ router.post('/admin/users', authenticateToken, requireManageUsers, [ body('username').isLength({ min: 3 }).withMessage('Username must be at least 3 characters'), body('email').isEmail().withMessage('Valid email is required'), body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'), + body('first_name').optional().isLength({ min: 1 }).withMessage('First name must be at least 1 character'), + body('last_name').optional().isLength({ min: 1 }).withMessage('Last name must be at least 1 character'), body('role').optional().custom(async (value) => { if (!value) return true; // Optional field + // Allow built-in roles even if not in role_permissions table yet + const builtInRoles = ['admin', 'user']; + if (builtInRoles.includes(value)) return true; const rolePermissions = await prisma.role_permissions.findUnique({ where: { role: value } }); @@ -168,7 +173,7 @@ router.post('/admin/users', authenticateToken, requireManageUsers, [ return res.status(400).json({ errors: errors.array() }); } - const { username, email, password, role = 'user' } = req.body; + const { username, email, password, first_name, last_name, role = 'user' } = req.body; // Check if user already exists const existingUser = await prisma.users.findFirst({ @@ -190,15 +195,21 @@ router.post('/admin/users', authenticateToken, requireManageUsers, [ // Create user const user = await prisma.users.create({ data: { + id: uuidv4(), username, email, password_hash: passwordHash, - role + first_name: first_name || null, + last_name: last_name || null, + role, + updated_at: new Date() }, select: { id: true, username: true, email: true, + first_name: true, + last_name: true, role: true, is_active: true, created_at: true @@ -542,6 +553,8 @@ router.post('/login', [ id: true, username: true, email: true, + first_name: true, + last_name: true, password_hash: true, role: true, is_active: true, @@ -690,6 +703,8 @@ router.post('/verify-tfa', [ id: user.id, username: user.username, email: user.email, + first_name: user.first_name, + last_name: user.last_name, role: user.role } }); @@ -714,7 +729,9 @@ router.get('/profile', authenticateToken, async (req, res) => { // Update user profile router.put('/profile', authenticateToken, [ body('username').optional().isLength({ min: 3 }).withMessage('Username must be at least 3 characters'), - body('email').optional().isEmail().withMessage('Valid email is required') + body('email').optional().isEmail().withMessage('Valid email is required'), + body('first_name').optional().isLength({ min: 1 }).withMessage('First name must be at least 1 character'), + body('last_name').optional().isLength({ min: 1 }).withMessage('Last name must be at least 1 character') ], async (req, res) => { try { const errors = validationResult(req); @@ -722,11 +739,13 @@ router.put('/profile', authenticateToken, [ return res.status(400).json({ errors: errors.array() }); } - const { username, email } = req.body; + const { username, email, first_name, last_name } = req.body; const updateData = {}; if (username) updateData.username = username; if (email) updateData.email = email; + if (first_name !== undefined) updateData.first_name = first_name || null; + if (last_name !== undefined) updateData.last_name = last_name || null; // Check if username/email already exists (excluding current user) if (username || email) { @@ -756,6 +775,8 @@ router.put('/profile', authenticateToken, [ id: true, username: true, email: true, + first_name: true, + last_name: true, role: true, is_active: true, last_login: true, diff --git a/backend/src/routes/dashboardPreferencesRoutes.js b/backend/src/routes/dashboardPreferencesRoutes.js index 2eec6fd..4179629 100644 --- a/backend/src/routes/dashboardPreferencesRoutes.js +++ b/backend/src/routes/dashboardPreferencesRoutes.js @@ -74,13 +74,17 @@ router.get('/defaults', authenticateToken, async (req, res) => { { cardId: 'hostsNeedingUpdates', title: 'Needs Updating', icon: 'AlertTriangle', enabled: true, order: 1 }, { cardId: 'totalOutdatedPackages', title: 'Outdated Packages', icon: 'Package', enabled: true, order: 2 }, { cardId: 'securityUpdates', title: 'Security Updates', icon: 'Shield', enabled: true, order: 3 }, - { cardId: 'erroredHosts', title: 'Errored Hosts', icon: 'AlertTriangle', enabled: true, order: 4 }, - { cardId: 'offlineHosts', title: 'Offline/Stale Hosts', icon: 'WifiOff', enabled: false, order: 5 }, - { cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 6 }, - { cardId: 'osDistributionBar', title: 'OS Distribution (Bar)', icon: 'BarChart3', enabled: false, order: 7 }, - { cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 8 }, - { cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 9 }, - { cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 10 } + { cardId: 'upToDateHosts', title: 'Up to date', icon: 'CheckCircle', enabled: true, order: 4 }, + { cardId: 'totalHostGroups', title: 'Host Groups', icon: 'Folder', enabled: false, order: 5 }, + { cardId: 'totalUsers', title: 'Users', icon: 'Users', enabled: false, order: 6 }, + { cardId: 'totalRepos', title: 'Repositories', icon: 'GitBranch', enabled: false, order: 7 }, + { cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 8 }, + { cardId: 'osDistributionBar', title: 'OS Distribution (Bar)', icon: 'BarChart3', enabled: false, order: 9 }, + { cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 10 }, + { cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 11 }, + { cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 12 }, + { cardId: 'recentUsers', title: 'Recent Users Logged in', icon: 'Users', enabled: true, order: 13 }, + { cardId: 'recentCollection', title: 'Recent Collection', icon: 'Server', enabled: true, order: 14 } ]; res.json(defaultCards); diff --git a/backend/src/routes/dashboardRoutes.js b/backend/src/routes/dashboardRoutes.js index 11d15f6..5bd5a8b 100644 --- a/backend/src/routes/dashboardRoutes.js +++ b/backend/src/routes/dashboardRoutes.js @@ -5,7 +5,8 @@ const { authenticateToken } = require('../middleware/auth'); const { requireViewDashboard, requireViewHosts, - requireViewPackages + requireViewPackages, + requireViewUsers } = require('../middleware/permissions'); const router = express.Router(); @@ -33,6 +34,9 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) = erroredHosts, securityUpdates, offlineHosts, + totalHostGroups, + totalUsers, + totalRepos, osDistribution, updateTrends ] = await Promise.all([ @@ -83,6 +87,15 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) = } }), + // Total host groups count + prisma.host_groups.count(), + + // Total users count + prisma.users.count(), + + // Total repositories count + prisma.repositories.count(), + // OS distribution for pie chart prisma.hosts.groupBy({ by: ['os_type'], @@ -133,10 +146,14 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) = cards: { totalHosts, hostsNeedingUpdates, + upToDateHosts: Math.max(totalHosts - hostsNeedingUpdates, 0), totalOutdatedPackages, erroredHosts, securityUpdates, - offlineHosts + offlineHosts, + totalHostGroups, + totalUsers, + totalRepos }, charts: { osDistribution: osDistributionFormatted, @@ -338,9 +355,9 @@ router.get('/hosts/:hostId', authenticateToken, requireViewHosts, async (req, re const hostWithStats = { ...host, stats: { - totalPackages: host.host_packages.length, - outdatedPackages: host.host_packages.filter(hp => hp.needs_update).length, - securityUpdates: host.host_packages.filter(hp => hp.needs_update && hp.is_security_update).length + total_packages: host.host_packages.length, + outdated_packages: host.host_packages.filter(hp => hp.needs_update).length, + security_updates: host.host_packages.filter(hp => hp.needs_update && hp.is_security_update).length } }; @@ -351,4 +368,59 @@ router.get('/hosts/:hostId', authenticateToken, requireViewHosts, async (req, re } }); +// Get recent users ordered by last_login desc +router.get('/recent-users', authenticateToken, requireViewUsers, async (req, res) => { + try { + const users = await prisma.users.findMany({ + where: { + last_login: { + not: null + } + }, + select: { + id: true, + username: true, + email: true, + role: true, + last_login: true, + created_at: true + }, + orderBy: [ + { last_login: 'desc' }, + { created_at: 'desc' } + ], + take: 5 + }); + + res.json(users); + } catch (error) { + console.error('Error fetching recent users:', error); + res.status(500).json({ error: 'Failed to fetch recent users' }); + } +}); + +// Get recent hosts that have sent data (ordered by last_update desc) +router.get('/recent-collection', authenticateToken, requireViewHosts, async (req, res) => { + try { + const hosts = await prisma.hosts.findMany({ + select: { + id: true, + friendly_name: true, + hostname: true, + last_update: true, + status: true + }, + orderBy: { + last_update: 'desc' + }, + take: 5 + }); + + res.json(hosts); + } catch (error) { + console.error('Error fetching recent collection:', error); + res.status(500).json({ error: 'Failed to fetch recent collection' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/hostGroupRoutes.js b/backend/src/routes/hostGroupRoutes.js index 1a6c750..272ed37 100644 --- a/backend/src/routes/hostGroupRoutes.js +++ b/backend/src/routes/hostGroupRoutes.js @@ -1,6 +1,7 @@ const express = require('express'); const { body, validationResult } = require('express-validator'); const { PrismaClient } = require('@prisma/client'); +const { randomUUID } = require('crypto'); const { authenticateToken } = require('../middleware/auth'); const { requireManageHosts } = require('../middleware/permissions'); @@ -41,13 +42,13 @@ router.get('/:id', authenticateToken, async (req, res) => { hosts: { select: { id: true, - friendlyName: true, + friendly_name: true, hostname: true, ip: true, - osType: true, - osVersion: true, + os_type: true, + os_version: true, status: true, - lastUpdate: true + last_update: true } } } @@ -89,9 +90,11 @@ router.post('/', authenticateToken, requireManageHosts, [ const hostGroup = await prisma.host_groups.create({ data: { + id: randomUUID(), name, description: description || null, - color: color || '#3B82F6' + color: color || '#3B82F6', + updated_at: new Date() } }); @@ -143,7 +146,8 @@ router.put('/:id', authenticateToken, requireManageHosts, [ data: { name, description: description || null, - color: color || '#3B82F6' + color: color || '#3B82F6', + updated_at: new Date() } }); @@ -199,20 +203,20 @@ router.get('/:id/hosts', authenticateToken, async (req, res) => { const { id } = req.params; const hosts = await prisma.hosts.findMany({ - where: { hostGroupId: id }, + where: { host_group_id: id }, select: { id: true, - friendlyName: true, + friendly_name: true, ip: true, - osType: true, - osVersion: true, + os_type: true, + os_version: true, architecture: true, status: true, - lastUpdate: true, - createdAt: true + last_update: true, + created_at: true }, orderBy: { - friendlyName: 'asc' + friendly_name: 'asc' } }); diff --git a/backend/src/routes/permissionsRoutes.js b/backend/src/routes/permissionsRoutes.js index 048520a..42a5c0e 100644 --- a/backend/src/routes/permissionsRoutes.js +++ b/backend/src/routes/permissionsRoutes.js @@ -1,13 +1,13 @@ const express = require('express'); const { PrismaClient } = require('@prisma/client'); const { authenticateToken, requireAdmin } = require('../middleware/auth'); -const { requireManageSettings } = require('../middleware/permissions'); +const { requireManageSettings, requireManageUsers } = require('../middleware/permissions'); const router = express.Router(); const prisma = new PrismaClient(); -// Get all role permissions -router.get('/roles', authenticateToken, requireManageSettings, async (req, res) => { +// Get all role permissions (allow users who can manage users to view roles) +router.get('/roles', authenticateToken, requireManageUsers, async (req, res) => { try { const permissions = await prisma.role_permissions.findMany({ orderBy: { diff --git a/backend/src/server.js b/backend/src/server.js index 775adfc..a0804f4 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -133,6 +133,18 @@ async function checkAndImportAgentVersion() { version: localVersion, release_notes: `Auto-imported on startup (${new Date().toISOString()})`, script_content: scriptContent, + is_default: true, + is_current: true, + updated_at: new Date() + } + }); + + // Update all other versions to not be default or current + await prisma.agent_versions.updateMany({ + where: { + version: { not: localVersion } + }, + data: { is_default: false, is_current: false, updated_at: new Date() diff --git a/frontend/src/components/DashboardSettingsModal.jsx b/frontend/src/components/DashboardSettingsModal.jsx index e6bf49d..b3a819f 100644 --- a/frontend/src/components/DashboardSettingsModal.jsx +++ b/frontend/src/components/DashboardSettingsModal.jsx @@ -67,6 +67,9 @@ const SortableCardItem = ({ card, onToggle }) => {
{card.title} + {card.typeLabel ? ( + ({card.typeLabel}) + ) : null}
@@ -141,15 +144,39 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => { // Initialize cards when preferences or defaults are loaded useEffect(() => { if (preferences && defaultCards) { + // Normalize server preferences (snake_case -> camelCase) + const normalizedPreferences = preferences.map((p) => ({ + cardId: p.cardId ?? p.card_id, + enabled: p.enabled, + order: p.order, + })); + + const typeLabelFor = (cardId) => { + if (['totalHosts','hostsNeedingUpdates','totalOutdatedPackages','securityUpdates','upToDateHosts','totalHostGroups','totalUsers','totalRepos'].includes(cardId)) return 'Top card'; + if (cardId === 'osDistribution') return 'Pie chart'; + if (cardId === 'osDistributionBar') return 'Bar chart'; + if (cardId === 'updateStatus') return 'Pie chart'; + if (cardId === 'packagePriority') return 'Pie chart'; + if (cardId === 'recentUsers') return 'Table'; + if (cardId === 'recentCollection') return 'Table'; + if (cardId === 'quickStats') return 'Wide card'; + return undefined; + }; + // Merge user preferences with default cards - const mergedCards = defaultCards.map(defaultCard => { - const userPreference = preferences.find(p => p.cardId === defaultCard.cardId); - return { - ...defaultCard, - enabled: userPreference ? userPreference.enabled : defaultCard.enabled, - order: userPreference ? userPreference.order : defaultCard.order - }; - }).sort((a, b) => a.order - b.order); + const mergedCards = defaultCards + .map((defaultCard) => { + const userPreference = normalizedPreferences.find( + (p) => p.cardId === defaultCard.cardId + ); + return { + ...defaultCard, + enabled: userPreference ? userPreference.enabled : defaultCard.enabled, + order: userPreference ? userPreference.order : defaultCard.order, + typeLabel: typeLabelFor(defaultCard.cardId), + }; + }) + .sort((a, b) => a.order - b.order); setCards(mergedCards); } diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index 8253323..4abfd63 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -79,13 +79,13 @@ const Layout = ({ children }) => { { name: 'Reporting', href: '/reporting', icon: BarChart3, comingSoon: true }, ] }, - { + ...(canViewUsers() || canManageUsers() ? [{ section: 'PatchMon Users', items: [ ...(canViewUsers() ? [{ name: 'Users', href: '/users', icon: Users }] : []), ...(canManageSettings() ? [{ name: 'Permissions', href: '/permissions', icon: Shield }] : []), ] - }, + }] : []), { section: 'Settings', items: [ @@ -139,31 +139,6 @@ const Layout = ({ children }) => { window.location.href = '/hosts?action=add' } - const copyEmailToClipboard = async () => { - const email = 'support@patchmon.net' - try { - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(email) - } else { - // Fallback for non-secure contexts - const textArea = document.createElement('textarea') - textArea.value = email - textArea.style.position = 'fixed' - textArea.style.left = '-999999px' - textArea.style.top = '-999999px' - document.body.appendChild(textArea) - textArea.focus() - textArea.select() - document.execCommand('copy') - textArea.remove() - } - // You could add a toast notification here if you have one - } catch (err) { - console.error('Failed to copy email:', err) - // Fallback: show email in prompt - prompt('Copy this email address:', email) - } - } // Fetch GitHub stars count const fetchGitHubStars = async () => { @@ -509,7 +484,7 @@ const Layout = ({ children }) => { ? 'text-primary-700 dark:text-white' : 'text-secondary-700 dark:text-secondary-200' }`}> - {user?.username} + {user?.first_name || user?.username} {user?.role === 'admin' && ( @@ -625,7 +600,6 @@ const Layout = ({ children }) => { target="_blank" rel="noopener noreferrer" className="flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm group relative" - title="⭐ Star us on GitHub! Click to open repository" > {githubStars !== null && ( @@ -634,11 +608,6 @@ const Layout = ({ children }) => { {githubStars} )} - {/* Tooltip */} -
- ⭐ Star us on GitHub! -
-
{ > - + { navigate('/hosts?filter=offline') } + // New navigation handlers for top cards + const handleUsersClick = () => { + navigate('/users') + } + + const handleHostGroupsClick = () => { + navigate('/options') + } + + const handleRepositoriesClick = () => { + navigate('/repositories') + } + const handleOSDistributionClick = () => { navigate('/hosts?showFilters=true', { replace: true }) } @@ -143,6 +159,20 @@ const Dashboard = () => { refetchOnWindowFocus: false, // Don't refetch when window regains focus }) + // Fetch recent users (permission protected server-side) + const { data: recentUsers } = useQuery({ + queryKey: ['dashboardRecentUsers'], + queryFn: () => dashboardAPI.getRecentUsers().then(res => res.data), + staleTime: 60 * 1000, + }) + + // Fetch recent collection (permission protected server-side) + const { data: recentCollection } = useQuery({ + queryKey: ['dashboardRecentCollection'], + queryFn: () => dashboardAPI.getRecentCollection().then(res => res.data), + staleTime: 60 * 1000, + }) + // Fetch settings to get the agent update interval const { data: settings } = useQuery({ queryKey: ['settings'], @@ -162,22 +192,32 @@ const Dashboard = () => { queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data), }) - // Merge preferences with default cards + // Merge preferences with default cards (normalize snake_case from API) useEffect(() => { if (preferences && defaultCards) { - const mergedCards = defaultCards.map(defaultCard => { - const userPreference = preferences.find(p => p.cardId === defaultCard.cardId); - return { - ...defaultCard, - enabled: userPreference ? userPreference.enabled : defaultCard.enabled, - order: userPreference ? userPreference.order : defaultCard.order - }; - }).sort((a, b) => a.order - b.order); - - setCardPreferences(mergedCards); + const normalizedPreferences = preferences.map((p) => ({ + cardId: p.cardId ?? p.card_id, + enabled: p.enabled, + order: p.order, + })) + + const mergedCards = defaultCards + .map((defaultCard) => { + const userPreference = normalizedPreferences.find( + (p) => p.cardId === defaultCard.cardId + ) + return { + ...defaultCard, + enabled: userPreference ? userPreference.enabled : defaultCard.enabled, + order: userPreference ? userPreference.order : defaultCard.order, + } + }) + .sort((a, b) => a.order - b.order) + + setCardPreferences(mergedCards) } else if (defaultCards) { // If no preferences exist, use defaults - setCardPreferences(defaultCards.sort((a, b) => a.order - b.order)); + setCardPreferences(defaultCards.sort((a, b) => a.order - b.order)) } }, [preferences, defaultCards]) @@ -201,9 +241,9 @@ const Dashboard = () => { // Helper function to get card type for layout grouping const getCardType = (cardId) => { - if (['totalHosts', 'hostsNeedingUpdates', 'totalOutdatedPackages', 'securityUpdates'].includes(cardId)) { + if (['totalHosts', 'hostsNeedingUpdates', 'totalOutdatedPackages', 'securityUpdates', 'upToDateHosts', 'totalHostGroups', 'totalUsers', 'totalRepos'].includes(cardId)) { return 'stats'; - } else if (['osDistribution', 'osDistributionBar', 'updateStatus', 'packagePriority'].includes(cardId)) { + } else if (['osDistribution', 'osDistributionBar', 'updateStatus', 'packagePriority', 'recentUsers', 'recentCollection'].includes(cardId)) { return 'charts'; } else if (['erroredHosts', 'quickStats'].includes(cardId)) { return 'fullwidth'; @@ -228,6 +268,24 @@ const Dashboard = () => { // Helper function to render a card by ID const renderCard = (cardId) => { switch (cardId) { + case 'upToDateHosts': + return ( +
+
+
+ +
+
+

Up to date

+

+ {stats.cards.upToDateHosts}/{stats.cards.totalHosts} +

+
+
+
+ ); case 'totalHosts': return (
{
); + + case 'totalHostGroups': + return ( +
+
+
+ +
+
+

Host Groups

+

+ {stats.cards.totalHostGroups} +

+
+
+
+ ); + + case 'totalUsers': + return ( +
+
+
+ +
+
+

Users

+

+ {stats.cards.totalUsers} +

+
+
+
+ ); + + case 'totalRepos': + return ( +
+
+
+ +
+
+

Repositories

+

+ {stats.cards.totalRepos} +

+
+
+
+ ); case 'erroredHosts': return ( @@ -439,30 +548,106 @@ const Dashboard = () => { ); case 'quickStats': + // Calculate dynamic stats + const updatePercentage = stats.cards.totalHosts > 0 ? ((stats.cards.hostsNeedingUpdates / stats.cards.totalHosts) * 100).toFixed(1) : 0; + const onlineHosts = stats.cards.totalHosts - stats.cards.erroredHosts; + const onlinePercentage = stats.cards.totalHosts > 0 ? ((onlineHosts / stats.cards.totalHosts) * 100).toFixed(0) : 0; + const securityPercentage = stats.cards.totalOutdatedPackages > 0 ? ((stats.cards.securityUpdates / stats.cards.totalOutdatedPackages) * 100).toFixed(0) : 0; + const avgPackagesPerHost = stats.cards.totalHosts > 0 ? Math.round(stats.cards.totalOutdatedPackages / stats.cards.totalHosts) : 0; + return (
-

Quick Stats

- +

System Overview

-
+
- {((stats.cards.hostsNeedingUpdates / stats.cards.totalHosts) * 100).toFixed(1)}% + {updatePercentage}% +
+
Need Updates
+
+ {stats.cards.hostsNeedingUpdates}/{stats.cards.totalHosts} hosts
-
Hosts need updates
{stats.cards.securityUpdates}
-
Security updates pending
+
Security Issues
+
+ {securityPercentage}% of updates +
- {stats.cards.totalHosts - stats.cards.erroredHosts} + {onlinePercentage}%
-
Hosts online
+
Online
+
+ {onlineHosts}/{stats.cards.totalHosts} hosts +
+
+
+
+ {avgPackagesPerHost} +
+
Avg per Host
+
+ outdated packages +
+
+
+
+ ); + + case 'recentUsers': + return ( +
+

Recent Users Logged in

+
+
+ {(recentUsers || []).slice(0, 5).map(u => ( +
+
+ {u.username} +
+
+ {u.last_login ? formatRelativeTime(u.last_login) : 'Never'} +
+
+ ))} + {(!recentUsers || recentUsers.length === 0) && ( +
+ No users found +
+ )} +
+
+
+ ); + + case 'recentCollection': + return ( +
+

Recent Collection

+
+
+ {(recentCollection || []).slice(0, 5).map(host => ( +
+
+ {host.friendly_name || host.hostname} +
+
+ {host.last_update ? formatRelativeTime(host.last_update) : 'Never'} +
+
+ ))} + {(!recentCollection || recentCollection.length === 0) && ( +
+ No hosts found +
+ )}
diff --git a/frontend/src/pages/HostDetail.jsx b/frontend/src/pages/HostDetail.jsx index 3ddc644..b4b1b9b 100644 --- a/frontend/src/pages/HostDetail.jsx +++ b/frontend/src/pages/HostDetail.jsx @@ -712,13 +712,17 @@ const HostDetail = () => {
-
-
+
+
+ ) case 'last_update': return ( diff --git a/frontend/src/pages/Options.jsx b/frontend/src/pages/Options.jsx index ef49563..0f8a9d0 100644 --- a/frontend/src/pages/Options.jsx +++ b/frontend/src/pages/Options.jsx @@ -382,7 +382,7 @@ const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => { type="text" value={formData.color} onChange={handleChange} - className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500" + className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400" placeholder="#3B82F6" />
@@ -484,7 +484,7 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => { type="text" value={formData.color} onChange={handleChange} - className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500" + className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400" placeholder="#3B82F6" />
diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx index ca035c7..9c30c8e 100644 --- a/frontend/src/pages/Profile.jsx +++ b/frontend/src/pages/Profile.jsx @@ -33,7 +33,9 @@ const Profile = () => { const [profileData, setProfileData] = useState({ username: user?.username || '', - email: user?.email || '' + email: user?.email || '', + first_name: user?.first_name || '', + last_name: user?.last_name || '' }) const [passwordData, setPasswordData] = useState({ @@ -141,7 +143,11 @@ const Profile = () => {
-

{user?.username}

+

+ {user?.first_name && user?.last_name + ? `${user.first_name} ${user.last_name}` + : user?.first_name || user?.username} +

{user?.email}

{
+ +
+ +
+ +
+
+ +
+ +
+ +
+
diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 6523b70..561a403 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -460,24 +460,81 @@ const Settings = () => { - { - handleInputChange('updateInterval', parseInt(e.target.value) || 60); - }} - className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${ - errors.updateInterval ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600' - }`} - placeholder="60" - /> + + {/* Numeric input (concise width) */} +
+ { + const val = parseInt(e.target.value); + if (!isNaN(val)) { + handleInputChange('updateInterval', Math.min(1440, Math.max(5, val))); + } else { + handleInputChange('updateInterval', 60); + } + }} + className={`w-28 border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${ + errors.updateInterval ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600' + }`} + placeholder="60" + /> +
+ + {/* Quick presets */} +
+ {[15, 30, 60, 120, 360, 720, 1440].map((m) => ( + + ))} +
+ + {/* Range slider */} +
+ handleInputChange('updateInterval', parseInt(e.target.value))} + className="w-full accent-primary-600" + aria-label="Update interval slider" + /> +
+ {errors.updateInterval && (

{errors.updateInterval}

)} -

- How often agents should check for updates (5-1440 minutes). This affects new installations. + + {/* Helper text */} +

+ Effective cadence:{' '} + {(() => { + const mins = parseInt(formData.updateInterval) || 60; + if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'}`; + const hrs = Math.floor(mins / 60); + const rem = mins % 60; + return `${hrs} hour${hrs === 1 ? '' : 's'}${rem ? ` ${rem} min` : ''}`; + })()} +
+ +

+ This affects new installations and will update existing ones when they next reach out.

diff --git a/frontend/src/pages/Users.jsx b/frontend/src/pages/Users.jsx index ac7b883..d25f20a 100644 --- a/frontend/src/pages/Users.jsx +++ b/frontend/src/pages/Users.jsx @@ -256,6 +256,8 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => { username: '', email: '', password: '', + first_name: '', + last_name: '', role: 'user' }) const [isLoading, setIsLoading] = useState(false) @@ -267,7 +269,12 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => { setError('') try { - const response = await adminUsersAPI.create(formData) + // Only send role if roles are available from API + const payload = { username: formData.username, email: formData.email, password: formData.password } + if (roles && Array.isArray(roles) && roles.length > 0) { + payload.role = formData.role + } + const response = await adminUsersAPI.create(payload) onUserCreated() } catch (err) { setError(err.response?.data?.error || 'Failed to create user') @@ -319,6 +326,33 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => { /> +
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+