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 = () => {
-
-
+
+
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) => (
+ handleInputChange('updateInterval', m)}
+ className={`px-3 py-1.5 rounded-full text-xs font-medium border ${
+ formData.updateInterval === m
+ ? 'bg-primary-600 text-white border-primary-600'
+ : 'bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 border-secondary-300 dark:border-secondary-600 hover:bg-secondary-50 dark:hover:bg-secondary-600'
+ }`}
+ aria-label={`Set ${m} minutes`}
+ >
+ {m % 60 === 0 ? `${m / 60}h` : `${m}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 }) => {
/>
+
+
+
+