From f23f075e41d5f0eb514dca5f83d59125e2a54989 Mon Sep 17 00:00:00 2001 From: Muhammad Ibrahim Date: Mon, 22 Sep 2025 21:31:14 +0100 Subject: [PATCH] Added more dashboard cards Fixed permissions roles creation bug On initial deployment, made it so the agent being populated will be set as default and current Fixed host detail to include package numbers Added ability to add full name - fixed loads of other bugs caused by camelcase to snake_Case migration --- agents/patchmon-agent.sh | 5 +- .../migration.sql | 3 + backend/prisma/schema.prisma | 2 + backend/src/routes/authRoutes.js | 29 ++- .../src/routes/dashboardPreferencesRoutes.js | 18 +- backend/src/routes/dashboardRoutes.js | 82 ++++++- backend/src/routes/hostGroupRoutes.js | 30 ++- backend/src/routes/permissionsRoutes.js | 6 +- backend/src/server.js | 12 + .../src/components/DashboardSettingsModal.jsx | 43 +++- frontend/src/components/Layout.jsx | 45 +--- frontend/src/pages/Dashboard.jsx | 231 ++++++++++++++++-- frontend/src/pages/HostDetail.jsx | 27 +- frontend/src/pages/Hosts.jsx | 25 +- frontend/src/pages/Options.jsx | 4 +- frontend/src/pages/Profile.jsx | 42 +++- frontend/src/pages/Settings.jsx | 87 +++++-- frontend/src/pages/Users.jsx | 67 ++++- frontend/src/utils/api.js | 2 + 19 files changed, 619 insertions(+), 141 deletions(-) create mode 100644 backend/prisma/migrations/20250922201205_add_user_name_fields/migration.sql 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 }) => { /> +
+
+ + +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+
+