mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-23 07:42:05 +00:00
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:
@@ -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"
|
||||
|
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "first_name" TEXT,
|
||||
ADD COLUMN "last_name" TEXT;
|
@@ -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?
|
||||
|
@@ -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,
|
||||
|
@@ -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);
|
||||
|
@@ -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;
|
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
|
@@ -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: {
|
||||
|
@@ -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()
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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"
|
||||
|
@@ -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>
|
||||
|
@@ -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" />
|
||||
|
@@ -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 (
|
||||
|
@@ -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>
|
||||
|
@@ -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>
|
||||
|
||||
|
@@ -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>
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
Reference in New Issue
Block a user