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
This commit is contained in:
Muhammad Ibrahim
2025-09-22 21:31:14 +01:00
parent 9b76d9f81a
commit f23f075e41
19 changed files with 619 additions and 141 deletions

View File

@@ -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"

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "first_name" TEXT,
ADD COLUMN "last_name" TEXT;

View File

@@ -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?

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;

View File

@@ -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'
}
});

View File

@@ -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: {

View File

@@ -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()

View File

@@ -67,6 +67,9 @@ const SortableCardItem = ({ card, onToggle }) => {
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{card.title}
{card.typeLabel ? (
<span className="ml-2 text-xs font-normal text-secondary-500 dark:text-secondary-400">({card.typeLabel})</span>
) : null}
</div>
</div>
</div>
@@ -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);
}

View File

@@ -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}
</span>
{user?.role === 'admin' && (
<span className="inline-flex items-center rounded-full bg-primary-100 px-1.5 py-0.5 text-xs font-medium text-primary-800">
@@ -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"
>
<Github className="h-5 w-5 flex-shrink-0" />
{githubStars !== null && (
@@ -634,11 +608,6 @@ const Layout = ({ children }) => {
<span className="text-sm font-medium">{githubStars}</span>
</div>
)}
{/* Tooltip */}
<div className="absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 text-sm rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none whitespace-nowrap z-50">
Star us on GitHub!
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-100"></div>
</div>
</a>
<a
href="https://discord.gg/DDKQeW6mnq"
@@ -649,13 +618,13 @@ const Layout = ({ children }) => {
>
<MessageCircle className="h-5 w-5" />
</a>
<button
onClick={copyEmailToClipboard}
<a
href="mailto:support@patchmon.net"
className="flex items-center justify-center w-10 h-10 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"
title="Copy support@patchmon.net"
title="Email support@patchmon.net"
>
<Mail className="h-5 w-5" />
</button>
</a>
<a
href="https://patchmon.net"
target="_blank"

View File

@@ -10,7 +10,10 @@ import {
RefreshCw,
Clock,
WifiOff,
Settings
Settings,
Users,
Folder,
GitBranch
} from 'lucide-react'
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title } from 'chart.js'
import { Pie, Bar } from 'react-chartjs-2'
@@ -54,6 +57,19 @@ const Dashboard = () => {
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 (
<div
className="card p-4"
>
<div className="flex items-center">
<div className="flex-shrink-0">
<TrendingUp className="h-5 w-5 text-success-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">Up to date</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.upToDateHosts}/{stats.cards.totalHosts}
</p>
</div>
</div>
</div>
);
case 'totalHosts':
return (
<div
@@ -307,6 +365,57 @@ const Dashboard = () => {
</div>
</div>
);
case 'totalHostGroups':
return (
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200" onClick={handleHostGroupsClick}>
<div className="flex items-center">
<div className="flex-shrink-0">
<Folder className="h-5 w-5 text-primary-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">Host Groups</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.totalHostGroups}
</p>
</div>
</div>
</div>
);
case 'totalUsers':
return (
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200" onClick={handleUsersClick}>
<div className="flex items-center">
<div className="flex-shrink-0">
<Users className="h-5 w-5 text-success-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">Users</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.totalUsers}
</p>
</div>
</div>
</div>
);
case 'totalRepos':
return (
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200" onClick={handleRepositoriesClick}>
<div className="flex items-center">
<div className="flex-shrink-0">
<GitBranch className="h-5 w-5 text-warning-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">Repositories</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.totalRepos}
</p>
</div>
</div>
</div>
);
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 (
<div className="card p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Quick Stats</h3>
<TrendingUp className="h-5 w-5 text-success-500" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">System Overview</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-primary-600">
{((stats.cards.hostsNeedingUpdates / stats.cards.totalHosts) * 100).toFixed(1)}%
{updatePercentage}%
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Need Updates</div>
<div className="text-xs text-secondary-400 dark:text-secondary-500">
{stats.cards.hostsNeedingUpdates}/{stats.cards.totalHosts} hosts
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Hosts need updates</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-danger-600">
{stats.cards.securityUpdates}
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Security updates pending</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Security Issues</div>
<div className="text-xs text-secondary-400 dark:text-secondary-500">
{securityPercentage}% of updates
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-success-600">
{stats.cards.totalHosts - stats.cards.erroredHosts}
{onlinePercentage}%
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Hosts online</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Online</div>
<div className="text-xs text-secondary-400 dark:text-secondary-500">
{onlineHosts}/{stats.cards.totalHosts} hosts
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-secondary-600">
{avgPackagesPerHost}
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Avg per Host</div>
<div className="text-xs text-secondary-400 dark:text-secondary-500">
outdated packages
</div>
</div>
</div>
</div>
);
case 'recentUsers':
return (
<div className="card p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Recent Users Logged in</h3>
<div className="h-64 overflow-y-auto">
<div className="space-y-3">
{(recentUsers || []).slice(0, 5).map(u => (
<div key={u.id} className="flex items-center justify-between py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-b-0">
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{u.username}
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-400">
{u.last_login ? formatRelativeTime(u.last_login) : 'Never'}
</div>
</div>
))}
{(!recentUsers || recentUsers.length === 0) && (
<div className="text-center text-secondary-500 dark:text-secondary-400 py-4">
No users found
</div>
)}
</div>
</div>
</div>
);
case 'recentCollection':
return (
<div className="card p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Recent Collection</h3>
<div className="h-64 overflow-y-auto">
<div className="space-y-3">
{(recentCollection || []).slice(0, 5).map(host => (
<div key={host.id} className="flex items-center justify-between py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-b-0">
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{host.friendly_name || host.hostname}
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-400">
{host.last_update ? formatRelativeTime(host.last_update) : 'Never'}
</div>
</div>
))}
{(!recentCollection || recentCollection.length === 0) && (
<div className="text-center text-secondary-500 dark:text-secondary-400 py-4">
No hosts found
</div>
)}
</div>
</div>
</div>

View File

@@ -712,13 +712,17 @@ const HostDetail = () => {
</div>
<div className="p-4">
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-primary-50 dark:bg-primary-900/20 rounded-lg">
<div className="flex items-center justify-center w-12 h-12 bg-primary-100 dark:bg-primary-800 rounded-lg mx-auto mb-2">
<button
onClick={() => navigate(`/packages?host=${hostId}`)}
className="text-center p-4 bg-primary-50 dark:bg-primary-900/20 rounded-lg hover:bg-primary-100 dark:hover:bg-primary-900/30 transition-colors group"
title="View all packages for this host"
>
<div className="flex items-center justify-center w-12 h-12 bg-primary-100 dark:bg-primary-800 rounded-lg mx-auto mb-2 group-hover:bg-primary-200 dark:group-hover:bg-primary-700 transition-colors">
<Package className="h-6 w-6 text-primary-600 dark:text-primary-400" />
</div>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.total_packages}</p>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Total Packages</p>
</div>
</button>
<button
onClick={() => navigate(`/packages?host=${hostId}`)}
@@ -822,6 +826,11 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
}
const getSetupCommands = () => {
// Get current time for crontab scheduling
const now = new Date()
const currentMinute = now.getMinutes()
const currentHour = now.getHours()
return `# Run this on the target host: ${host?.friendly_name}
echo "🔄 Setting up PatchMon agent..."
@@ -845,14 +854,14 @@ sudo /usr/local/bin/patchmon-agent.sh test
echo "📊 Sending initial package data..."
sudo /usr/local/bin/patchmon-agent.sh update
# Setup crontab
echo "⏰ Setting up hourly crontab..."
echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -
# Setup crontab starting at current time
echo "⏰ Setting up hourly crontab starting at ${currentHour.toString().padStart(2, '0')}:${currentMinute.toString().padStart(2, '0')}..."
echo "${currentMinute} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -
echo "✅ PatchMon agent setup complete!"
echo " - Agent installed: /usr/local/bin/patchmon-agent.sh"
echo " - Config directory: /etc/patchmon/"
echo " - Updates: Every hour via crontab"
echo " - Updates: Every hour via crontab (starting at ${currentHour.toString().padStart(2, '0')}:${currentMinute.toString().padStart(2, '0')})"
echo " - View logs: tail -f /var/log/patchmon-agent.log"`
}
@@ -1027,12 +1036,12 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="flex items-center gap-2">
<input
type="text"
value='echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -'
value={`echo "${new Date().getMinutes()} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -`}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => copyToClipboard('echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -')}
onClick={() => copyToClipboard(`echo "${new Date().getMinutes()} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -`)}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />

View File

@@ -263,6 +263,11 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
const serverUrl = settings?.server_url || window.location.origin.replace(':3000', ':3001')
const getSetupCommands = () => {
// Get current time for crontab scheduling
const now = new Date()
const currentMinute = now.getMinutes()
const currentHour = now.getHours()
return {
oneLine: `curl -sSL ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host?.api_id}" "${host?.api_key}"`,
@@ -281,8 +286,8 @@ sudo /usr/local/bin/patchmon-agent.sh test`,
initialUpdate: `# Send initial package data
sudo /usr/local/bin/patchmon-agent.sh update`,
crontab: `# Add to crontab for hourly updates (run as root)
echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -`,
crontab: `# Add to crontab for hourly updates starting at current time (run as root)
echo "${currentMinute} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -`,
fullSetup: `#!/bin/bash
# Complete PatchMon Agent Setup Script
@@ -309,14 +314,14 @@ sudo /usr/local/bin/patchmon-agent.sh test
echo "📊 Sending initial package data..."
sudo /usr/local/bin/patchmon-agent.sh update
# Setup crontab
echo "⏰ Setting up hourly crontab..."
echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -
# Setup crontab starting at current time
echo "⏰ Setting up hourly crontab starting at ${currentHour.toString().padStart(2, '0')}:${currentMinute.toString().padStart(2, '0')}..."
echo "${currentMinute} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -
echo "✅ PatchMon agent setup complete!"
echo " - Agent installed: /usr/local/bin/patchmon-agent.sh"
echo " - Config directory: /etc/patchmon/"
echo " - Updates: Every hour via crontab"
echo " - Updates: Every hour via crontab (starting at ${currentHour.toString().padStart(2, '0')}:${currentMinute.toString().padStart(2, '0')})"
echo " - View logs: tail -f /var/log/patchmon-agent.log"`
}
}
@@ -1167,9 +1172,13 @@ const Hosts = () => {
)
case 'updates':
return (
<div className="text-sm text-secondary-900 dark:text-white">
<button
onClick={() => navigate(`/packages?host=${host.id}`)}
className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 font-medium hover:underline"
title="View packages for this host"
>
{host.updatesCount || 0}
</div>
</button>
)
case 'last_update':
return (

View File

@@ -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"
/>
</div>
@@ -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"
/>
</div>

View File

@@ -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 = () => {
</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">{user?.username}</h3>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
{user?.first_name && user?.last_name
? `${user.first_name} ${user.last_name}`
: user?.first_name || user?.username}
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300">{user?.email}</p>
<div className="mt-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
@@ -251,6 +257,38 @@ const Profile = () => {
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
</div>
</div>
<div>
<label htmlFor="first_name" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200">
First Name
</label>
<div className="mt-1">
<input
type="text"
name="first_name"
id="first_name"
value={profileData.first_name}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
</div>
</div>
<div>
<label htmlFor="last_name" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200">
Last Name
</label>
<div className="mt-1">
<input
type="text"
name="last_name"
id="last_name"
value={profileData.last_name}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
</div>
</div>
</div>
</div>

View File

@@ -460,24 +460,81 @@ const Settings = () => {
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Agent Update Interval (minutes)
</label>
<input
type="number"
min="5"
max="1440"
value={formData.updateInterval}
onChange={(e) => {
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) */}
<div className="flex items-center gap-2">
<input
type="number"
min="5"
max="1440"
step="5"
value={formData.updateInterval}
onChange={(e) => {
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"
/>
</div>
{/* Quick presets */}
<div className="mt-3 flex flex-wrap items-center gap-2">
{[15, 30, 60, 120, 360, 720, 1440].map((m) => (
<button
key={m}
type="button"
onClick={() => 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`}
</button>
))}
</div>
{/* Range slider */}
<div className="mt-4">
<input
type="range"
min="5"
max="1440"
step="5"
value={formData.updateInterval}
onChange={(e) => handleInputChange('updateInterval', parseInt(e.target.value))}
className="w-full accent-primary-600"
aria-label="Update interval slider"
/>
</div>
{errors.updateInterval && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.updateInterval}</p>
)}
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
How often agents should check for updates (5-1440 minutes). This affects new installations.
{/* Helper text */}
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
<span className="font-medium">Effective cadence:</span>{' '}
{(() => {
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` : ''}`;
})()}
</div>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
This affects new installations and will update existing ones when they next reach out.
</p>
</div>

View File

@@ -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 }) => {
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
First Name
</label>
<input
type="text"
name="first_name"
value={formData.first_name}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Last Name
</label>
<input
type="text"
name="last_name"
value={formData.last_name}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Password
@@ -345,7 +379,7 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
>
{roles && Array.isArray(roles) ? (
{roles && Array.isArray(roles) && roles.length > 0 ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() + role.role.slice(1).replace('_', ' ')}
@@ -393,6 +427,8 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
const [formData, setFormData] = useState({
username: user?.username || '',
email: user?.email || '',
first_name: user?.first_name || '',
last_name: user?.last_name || '',
role: user?.role || 'user',
is_active: user?.is_active ?? true
})
@@ -458,6 +494,33 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
First Name
</label>
<input
type="text"
name="first_name"
value={formData.first_name}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Last Name
</label>
<input
type="text"
name="last_name"
value={formData.last_name}
onChange={handleInputChange}
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Role

View File

@@ -53,6 +53,8 @@ export const dashboardAPI = {
getHosts: () => api.get('/dashboard/hosts'),
getPackages: () => api.get('/dashboard/packages'),
getHostDetail: (hostId) => api.get(`/dashboard/hosts/${hostId}`),
getRecentUsers: () => api.get('/dashboard/recent-users'),
getRecentCollection: () => api.get('/dashboard/recent-collection')
}
// Admin Hosts API (for management interface)