mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-23 16:13:57 +00:00
Created toggle for enable / disable user signup flow with user role
Fixed numbers mismatching in host cards Fixed issues with the settings file Fixed layouts on hosts/packages/repos Added ability to delete multiple hosts at once Fixed Dark mode styling in areas Removed console debugging messages Done some other stuff ...
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,40 @@ if [[ $EUID -ne 0 ]]; then
|
||||
error "This script must be run as root (use sudo)"
|
||||
fi
|
||||
|
||||
# Install required dependencies
|
||||
info "📦 Installing required dependencies..."
|
||||
|
||||
# Detect package manager and install jq
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
# Debian/Ubuntu
|
||||
apt-get update >/dev/null 2>&1
|
||||
apt-get install -y jq curl >/dev/null 2>&1
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
# CentOS/RHEL 7
|
||||
yum install -y jq curl >/dev/null 2>&1
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
# CentOS/RHEL 8+/Fedora
|
||||
dnf install -y jq curl >/dev/null 2>&1
|
||||
elif command -v zypper >/dev/null 2>&1; then
|
||||
# openSUSE
|
||||
zypper install -y jq curl >/dev/null 2>&1
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
# Arch Linux
|
||||
pacman -S --noconfirm jq curl >/dev/null 2>&1
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
# Alpine Linux
|
||||
apk add --no-cache jq curl >/dev/null 2>&1
|
||||
else
|
||||
warning "Could not detect package manager. Please ensure 'jq' and 'curl' are installed manually."
|
||||
fi
|
||||
|
||||
# Verify jq installation
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
error "Failed to install 'jq'. Please install it manually: https://stedolan.github.io/jq/download/"
|
||||
fi
|
||||
|
||||
success "Dependencies installed successfully!"
|
||||
|
||||
# Default server URL (will be replaced by backend with configured URL)
|
||||
PATCHMON_URL="http://localhost:3001"
|
||||
|
||||
@@ -137,6 +171,7 @@ fi
|
||||
success "🎉 PatchMon Agent installation complete!"
|
||||
echo ""
|
||||
echo "📋 Installation Summary:"
|
||||
echo " • Dependencies installed: jq, curl"
|
||||
echo " • Agent installed: /usr/local/bin/patchmon-agent.sh"
|
||||
echo " • Agent version: $AGENT_VERSION"
|
||||
if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then
|
||||
|
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "settings" ADD COLUMN "signup_enabled" BOOLEAN NOT NULL DEFAULT false;
|
@@ -168,6 +168,7 @@ model settings {
|
||||
last_update_check DateTime?
|
||||
latest_version String?
|
||||
update_available Boolean @default(false)
|
||||
signup_enabled Boolean @default(false)
|
||||
}
|
||||
|
||||
model update_history {
|
||||
|
@@ -34,7 +34,7 @@ router.get('/check-admin-users', async (req, res) => {
|
||||
router.post('/setup-admin', [
|
||||
body('username').isLength({ min: 1 }).withMessage('Username is required'),
|
||||
body('email').isEmail().withMessage('Valid email is required'),
|
||||
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters')
|
||||
body('password').isLength({ min: 8 }).withMessage('Password must be at least 8 characters for security')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -425,6 +425,17 @@ router.post('/admin/users/:userId/reset-password', authenticateToken, requireMan
|
||||
}
|
||||
});
|
||||
|
||||
// Check if signup is enabled (public endpoint)
|
||||
router.get('/signup-enabled', async (req, res) => {
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
res.json({ signupEnabled: settings?.signup_enabled || false });
|
||||
} catch (error) {
|
||||
console.error('Error checking signup status:', error);
|
||||
res.status(500).json({ error: 'Failed to check signup status' });
|
||||
}
|
||||
});
|
||||
|
||||
// Public signup endpoint
|
||||
router.post('/signup', [
|
||||
body('username').isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
|
||||
@@ -432,6 +443,12 @@ router.post('/signup', [
|
||||
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
// Check if signup is enabled
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (!settings?.signup_enabled) {
|
||||
return res.status(403).json({ error: 'User signup is currently disabled' });
|
||||
}
|
||||
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
|
@@ -36,15 +36,12 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
|
||||
osDistribution,
|
||||
updateTrends
|
||||
] = await Promise.all([
|
||||
// Total hosts count
|
||||
prisma.hosts.count({
|
||||
where: { status: 'active' }
|
||||
}),
|
||||
// Total hosts count (all hosts regardless of status)
|
||||
prisma.hosts.count(),
|
||||
|
||||
// Hosts needing updates (distinct hosts with packages needing updates)
|
||||
prisma.hosts.count({
|
||||
where: {
|
||||
status: 'active',
|
||||
host_packages: {
|
||||
some: {
|
||||
needs_update: true
|
||||
|
@@ -755,7 +755,15 @@ router.get('/admin/list', authenticateToken, requireManageHosts, async (req, res
|
||||
api_id: true,
|
||||
agent_version: true,
|
||||
auto_update: true,
|
||||
created_at: true
|
||||
created_at: true,
|
||||
host_group_id: true,
|
||||
host_groups: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { created_at: 'desc' }
|
||||
});
|
||||
@@ -767,20 +775,124 @@ router.get('/admin/list', authenticateToken, requireManageHosts, async (req, res
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to delete multiple hosts
|
||||
router.delete('/bulk', authenticateToken, requireManageHosts, [
|
||||
body('hostIds').isArray({ min: 1 }).withMessage('At least one host ID is required'),
|
||||
body('hostIds.*').isLength({ min: 1 }).withMessage('Each host ID must be provided')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { hostIds } = req.body;
|
||||
|
||||
// Verify all hosts exist before deletion
|
||||
const existingHosts = await prisma.hosts.findMany({
|
||||
where: { id: { in: hostIds } },
|
||||
select: { id: true, friendly_name: true }
|
||||
});
|
||||
|
||||
if (existingHosts.length !== hostIds.length) {
|
||||
const foundIds = existingHosts.map(h => h.id);
|
||||
const missingIds = hostIds.filter(id => !foundIds.includes(id));
|
||||
return res.status(404).json({
|
||||
error: 'Some hosts not found',
|
||||
missingIds
|
||||
});
|
||||
}
|
||||
|
||||
// Delete all hosts (cascade will handle related data)
|
||||
const deleteResult = await prisma.hosts.deleteMany({
|
||||
where: { id: { in: hostIds } }
|
||||
});
|
||||
|
||||
// Check if all hosts were actually deleted
|
||||
if (deleteResult.count !== hostIds.length) {
|
||||
console.warn(`Expected to delete ${hostIds.length} hosts, but only deleted ${deleteResult.count}`);
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: `${deleteResult.count} host${deleteResult.count !== 1 ? 's' : ''} deleted successfully`,
|
||||
deletedCount: deleteResult.count,
|
||||
requestedCount: hostIds.length,
|
||||
deletedHosts: existingHosts.map(h => ({ id: h.id, friendly_name: h.friendly_name }))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Bulk host deletion error:', error);
|
||||
|
||||
// Handle specific Prisma errors
|
||||
if (error.code === 'P2025') {
|
||||
return res.status(404).json({
|
||||
error: 'Some hosts were not found or already deleted',
|
||||
details: 'The hosts may have been deleted by another process or do not exist'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'P2003') {
|
||||
return res.status(400).json({
|
||||
error: 'Cannot delete hosts due to foreign key constraints',
|
||||
details: 'Some hosts have related data that prevents deletion'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Failed to delete hosts',
|
||||
details: error.message || 'An unexpected error occurred'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to delete host
|
||||
router.delete('/:hostId', authenticateToken, requireManageHosts, async (req, res) => {
|
||||
try {
|
||||
const { hostId } = req.params;
|
||||
|
||||
// Check if host exists first
|
||||
const existingHost = await prisma.hosts.findUnique({
|
||||
where: { id: hostId },
|
||||
select: { id: true, friendly_name: true }
|
||||
});
|
||||
|
||||
if (!existingHost) {
|
||||
return res.status(404).json({
|
||||
error: 'Host not found',
|
||||
details: 'The host may have been deleted or does not exist'
|
||||
});
|
||||
}
|
||||
|
||||
// Delete host and all related data (cascade)
|
||||
await prisma.hosts.delete({
|
||||
where: { id: hostId }
|
||||
});
|
||||
|
||||
res.json({ message: 'Host deleted successfully' });
|
||||
res.json({
|
||||
message: 'Host deleted successfully',
|
||||
deletedHost: { id: existingHost.id, friendly_name: existingHost.friendly_name }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Host deletion error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete host' });
|
||||
|
||||
// Handle specific Prisma errors
|
||||
if (error.code === 'P2025') {
|
||||
return res.status(404).json({
|
||||
error: 'Host not found',
|
||||
details: 'The host may have been deleted or does not exist'
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'P2003') {
|
||||
return res.status(400).json({
|
||||
error: 'Cannot delete host due to foreign key constraints',
|
||||
details: 'The host has related data that prevents deletion'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Failed to delete host',
|
||||
details: error.message || 'An unexpected error occurred'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
@@ -112,11 +112,19 @@ router.get('/', async (req, res) => {
|
||||
|
||||
return {
|
||||
...pkg,
|
||||
affectedHostsCount: pkg._count.hostPackages,
|
||||
affectedHosts: affectedHosts.map(hp => ({
|
||||
hostId: hp.host.id,
|
||||
friendlyName: hp.host.friendly_name,
|
||||
osType: hp.host.os_type,
|
||||
currentVersion: hp.current_version,
|
||||
availableVersion: hp.available_version,
|
||||
isSecurityUpdate: hp.is_security_update
|
||||
})),
|
||||
stats: {
|
||||
totalInstalls: pkg._count.hostPackages,
|
||||
updatesNeeded: updatesCount,
|
||||
securityUpdates: securityCount,
|
||||
affectedHosts: affectedHosts.map(hp => hp.host)
|
||||
securityUpdates: securityCount
|
||||
}
|
||||
};
|
||||
})
|
||||
|
@@ -47,16 +47,16 @@ router.put('/roles/:role', authenticateToken, requireManageSettings, async (req,
|
||||
try {
|
||||
const { role } = req.params;
|
||||
const {
|
||||
canViewDashboard,
|
||||
canViewHosts,
|
||||
canManageHosts,
|
||||
canViewPackages,
|
||||
canManagePackages,
|
||||
canViewUsers,
|
||||
canManageUsers,
|
||||
canViewReports,
|
||||
canExportData,
|
||||
canManageSettings
|
||||
can_view_dashboard,
|
||||
can_view_hosts,
|
||||
can_manage_hosts,
|
||||
can_view_packages,
|
||||
can_manage_packages,
|
||||
can_view_users,
|
||||
can_manage_users,
|
||||
can_view_reports,
|
||||
can_export_data,
|
||||
can_manage_settings
|
||||
} = req.body;
|
||||
|
||||
// Prevent modifying admin role permissions (admin should always have full access)
|
||||
@@ -67,31 +67,31 @@ router.put('/roles/:role', authenticateToken, requireManageSettings, async (req,
|
||||
const permissions = await prisma.role_permissions.upsert({
|
||||
where: { role },
|
||||
update: {
|
||||
can_view_dashboard: canViewDashboard,
|
||||
can_view_hosts: canViewHosts,
|
||||
can_manage_hosts: canManageHosts,
|
||||
can_view_packages: canViewPackages,
|
||||
can_manage_packages: canManagePackages,
|
||||
can_view_users: canViewUsers,
|
||||
can_manage_users: canManageUsers,
|
||||
can_view_reports: canViewReports,
|
||||
can_export_data: canExportData,
|
||||
can_manage_settings: canManageSettings,
|
||||
can_view_dashboard: can_view_dashboard,
|
||||
can_view_hosts: can_view_hosts,
|
||||
can_manage_hosts: can_manage_hosts,
|
||||
can_view_packages: can_view_packages,
|
||||
can_manage_packages: can_manage_packages,
|
||||
can_view_users: can_view_users,
|
||||
can_manage_users: can_manage_users,
|
||||
can_view_reports: can_view_reports,
|
||||
can_export_data: can_export_data,
|
||||
can_manage_settings: can_manage_settings,
|
||||
updated_at: new Date()
|
||||
},
|
||||
create: {
|
||||
id: require('uuid').v4(),
|
||||
role,
|
||||
can_view_dashboard: canViewDashboard,
|
||||
can_view_hosts: canViewHosts,
|
||||
can_manage_hosts: canManageHosts,
|
||||
can_view_packages: canViewPackages,
|
||||
can_manage_packages: canManagePackages,
|
||||
can_view_users: canViewUsers,
|
||||
can_manage_users: canManageUsers,
|
||||
can_view_reports: canViewReports,
|
||||
can_export_data: canExportData,
|
||||
can_manage_settings: canManageSettings,
|
||||
can_view_dashboard: can_view_dashboard,
|
||||
can_view_hosts: can_view_hosts,
|
||||
can_manage_hosts: can_manage_hosts,
|
||||
can_view_packages: can_view_packages,
|
||||
can_manage_packages: can_manage_packages,
|
||||
can_view_users: can_view_users,
|
||||
can_manage_users: can_manage_users,
|
||||
can_view_reports: can_view_reports,
|
||||
can_export_data: can_export_data,
|
||||
can_manage_settings: can_manage_settings,
|
||||
updated_at: new Date()
|
||||
}
|
||||
});
|
||||
|
@@ -40,7 +40,9 @@ async function triggerCrontabUpdates() {
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
const settings = await prisma.settings.findFirst();
|
||||
const settings = await prisma.settings.findFirst({
|
||||
orderBy: { updated_at: 'desc' }
|
||||
});
|
||||
const serverUrl = settings?.server_url || process.env.SERVER_URL || 'http://localhost:3001';
|
||||
const url = new URL(`${serverUrl}/api/v1/hosts/ping`);
|
||||
const isHttps = url.protocol === 'https:';
|
||||
@@ -92,7 +94,9 @@ async function triggerCrontabUpdates() {
|
||||
// Get current settings
|
||||
router.get('/', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
let settings = await prisma.settings.findFirst();
|
||||
let settings = await prisma.settings.findFirst({
|
||||
orderBy: { updated_at: 'desc' }
|
||||
});
|
||||
|
||||
// If no settings exist, create default settings
|
||||
if (!settings) {
|
||||
@@ -106,12 +110,12 @@ router.get('/', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
frontend_url: 'http://localhost:3000',
|
||||
update_interval: 60,
|
||||
auto_update: false,
|
||||
signup_enabled: false,
|
||||
updated_at: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Returning settings:', settings);
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
console.error('Settings fetch error:', error);
|
||||
@@ -127,6 +131,7 @@ router.put('/', authenticateToken, requireManageSettings, [
|
||||
body('frontendUrl').isLength({ min: 1 }).withMessage('Frontend URL is required'),
|
||||
body('updateInterval').isInt({ min: 5, max: 1440 }).withMessage('Update interval must be between 5 and 1440 minutes'),
|
||||
body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean'),
|
||||
body('signupEnabled').isBoolean().withMessage('Signup enabled must be a boolean'),
|
||||
body('githubRepoUrl').optional().isLength({ min: 1 }).withMessage('GitHub repo URL must be a non-empty string'),
|
||||
body('repositoryType').optional().isIn(['public', 'private']).withMessage('Repository type must be public or private'),
|
||||
body('sshKeyPath').optional().custom((value) => {
|
||||
@@ -140,36 +145,23 @@ router.put('/', authenticateToken, requireManageSettings, [
|
||||
})
|
||||
], async (req, res) => {
|
||||
try {
|
||||
console.log('Settings update request body:', req.body);
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
console.log('Validation errors:', errors.array());
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, repositoryType, sshKeyPath } = req.body;
|
||||
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, repositoryType, sshKeyPath });
|
||||
console.log('GitHub repo URL received:', githubRepoUrl, 'Type:', typeof githubRepoUrl);
|
||||
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, signupEnabled, githubRepoUrl, repositoryType, sshKeyPath } = req.body;
|
||||
|
||||
// Construct server URL from components
|
||||
const serverUrl = `${serverProtocol}://${serverHost}:${serverPort}`;
|
||||
|
||||
let settings = await prisma.settings.findFirst();
|
||||
let settings = await prisma.settings.findFirst({
|
||||
orderBy: { updated_at: 'desc' }
|
||||
});
|
||||
|
||||
if (settings) {
|
||||
// Update existing settings
|
||||
console.log('Updating existing settings with data:', {
|
||||
serverUrl,
|
||||
serverProtocol,
|
||||
serverHost,
|
||||
serverPort,
|
||||
frontendUrl,
|
||||
updateInterval: updateInterval || 60,
|
||||
autoUpdate: autoUpdate || false,
|
||||
githubRepoUrl: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
repositoryType: repositoryType || 'public'
|
||||
});
|
||||
console.log('Final githubRepoUrl value being saved:', githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git');
|
||||
const oldUpdateInterval = settings.update_interval;
|
||||
|
||||
settings = await prisma.settings.update({
|
||||
@@ -182,13 +174,13 @@ router.put('/', authenticateToken, requireManageSettings, [
|
||||
frontend_url: frontendUrl,
|
||||
update_interval: updateInterval || 60,
|
||||
auto_update: autoUpdate || false,
|
||||
signup_enabled: signupEnabled || false,
|
||||
github_repo_url: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
repository_type: repositoryType || 'public',
|
||||
ssh_key_path: sshKeyPath || null,
|
||||
updated_at: new Date()
|
||||
}
|
||||
});
|
||||
console.log('Settings updated successfully:', settings);
|
||||
|
||||
// If update interval changed, trigger crontab updates on all hosts with auto-update enabled
|
||||
if (oldUpdateInterval !== (updateInterval || 60)) {
|
||||
@@ -207,6 +199,7 @@ router.put('/', authenticateToken, requireManageSettings, [
|
||||
frontend_url: frontendUrl,
|
||||
update_interval: updateInterval || 60,
|
||||
auto_update: autoUpdate || false,
|
||||
signup_enabled: signupEnabled || false,
|
||||
github_repo_url: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
repository_type: repositoryType || 'public',
|
||||
ssh_key_path: sshKeyPath || null,
|
||||
@@ -228,7 +221,9 @@ router.put('/', authenticateToken, requireManageSettings, [
|
||||
// Get server URL for public use (used by installation scripts)
|
||||
router.get('/server-url', async (req, res) => {
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
const settings = await prisma.settings.findFirst({
|
||||
orderBy: { updated_at: 'desc' }
|
||||
});
|
||||
|
||||
if (!settings) {
|
||||
return res.json({ server_url: 'http://localhost:3001' });
|
||||
@@ -244,7 +239,9 @@ router.get('/server-url', async (req, res) => {
|
||||
// Get update interval policy for agents (public endpoint)
|
||||
router.get('/update-interval', async (req, res) => {
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
const settings = await prisma.settings.findFirst({
|
||||
orderBy: { updated_at: 'desc' }
|
||||
});
|
||||
|
||||
if (!settings) {
|
||||
return res.json({ updateInterval: 60 });
|
||||
@@ -263,7 +260,9 @@ router.get('/update-interval', async (req, res) => {
|
||||
// Get auto-update policy for agents (public endpoint)
|
||||
router.get('/auto-update', async (req, res) => {
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
const settings = await prisma.settings.findFirst({
|
||||
orderBy: { updated_at: 'desc' }
|
||||
});
|
||||
|
||||
if (!settings) {
|
||||
return res.json({ autoUpdate: false });
|
||||
|
@@ -24,12 +24,8 @@ function AppRoutes() {
|
||||
const { needsFirstTimeSetup, checkingSetup, isAuthenticated } = useAuth()
|
||||
const isAuth = isAuthenticated() // Call the function to get boolean value
|
||||
|
||||
// Debug logging
|
||||
console.log('AppRoutes state:', { needsFirstTimeSetup, checkingSetup, isAuthenticated: isAuth })
|
||||
|
||||
// Show loading while checking if setup is needed
|
||||
if (checkingSetup) {
|
||||
console.log('Showing loading screen...')
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
@@ -42,12 +38,9 @@ function AppRoutes() {
|
||||
|
||||
// Show first-time setup if no admin users exist
|
||||
if (needsFirstTimeSetup && !isAuth) {
|
||||
console.log('Showing FirstTimeAdminSetup component...')
|
||||
return <FirstTimeAdminSetup />
|
||||
}
|
||||
|
||||
console.log('Showing normal routes (Login/Dashboard)...')
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
@@ -28,9 +28,11 @@ import {
|
||||
Settings as SettingsIcon
|
||||
} from 'lucide-react';
|
||||
import { dashboardPreferencesAPI } from '../utils/api';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
// Sortable Card Item Component
|
||||
const SortableCardItem = ({ card, onToggle }) => {
|
||||
const { isDark } = useTheme();
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -50,7 +52,7 @@ const SortableCardItem = ({ card, onToggle }) => {
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-center justify-between p-3 bg-white border border-secondary-200 rounded-lg ${
|
||||
className={`flex items-center justify-between p-3 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg ${
|
||||
isDragging ? 'shadow-lg' : 'shadow-sm'
|
||||
}`}
|
||||
>
|
||||
@@ -58,12 +60,12 @@ const SortableCardItem = ({ card, onToggle }) => {
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="text-secondary-400 hover:text-secondary-600 cursor-grab active:cursor-grabbing"
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium text-secondary-900">
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{card.title}
|
||||
</div>
|
||||
</div>
|
||||
@@ -73,8 +75,8 @@ const SortableCardItem = ({ card, onToggle }) => {
|
||||
onClick={() => onToggle(card.cardId)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
|
||||
card.enabled
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
|
||||
? 'bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800'
|
||||
: 'bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600'
|
||||
}`}
|
||||
>
|
||||
{card.enabled ? (
|
||||
@@ -97,6 +99,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
const [cards, setCards] = useState([]);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { isDark } = useTheme();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
@@ -212,24 +215,24 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity" onClick={onClose} />
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="inline-block align-bottom bg-white dark:bg-secondary-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white dark:bg-secondary-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsIcon className="h-5 w-5 text-primary-600" />
|
||||
<h3 className="text-lg font-medium text-secondary-900">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Dashboard Settings
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-secondary-400 hover:text-secondary-600"
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-secondary-600 mb-6">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
|
||||
Customize your dashboard by reordering cards and toggling their visibility.
|
||||
Drag cards to reorder them, and click the visibility toggle to show/hide cards.
|
||||
</p>
|
||||
@@ -259,7 +262,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updatePreferencesMutation.isPending}
|
||||
@@ -284,7 +287,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-secondary-700 hover:bg-secondary-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Reset to Defaults
|
||||
@@ -292,7 +295,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-secondary-700 hover:bg-secondary-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
@@ -30,15 +30,19 @@ const FirstTimeAdminSetup = () => {
|
||||
return false
|
||||
}
|
||||
if (!formData.email.trim()) {
|
||||
setError('Email is required')
|
||||
setError('Email address is required')
|
||||
return false
|
||||
}
|
||||
if (!formData.email.includes('@')) {
|
||||
setError('Please enter a valid email address')
|
||||
|
||||
// Enhanced email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(formData.email.trim())) {
|
||||
setError('Please enter a valid email address (e.g., user@example.com)')
|
||||
return false
|
||||
}
|
||||
if (formData.password.length < 6) {
|
||||
setError('Password must be at least 6 characters')
|
||||
|
||||
if (formData.password.length < 8) {
|
||||
setError('Password must be at least 8 characters for security')
|
||||
return false
|
||||
}
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
@@ -186,7 +190,7 @@ const FirstTimeAdminSetup = () => {
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className="input w-full"
|
||||
placeholder="Enter your password (min 6 characters)"
|
||||
placeholder="Enter your password (min 8 characters)"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { Edit2, Check, X, ChevronDown } from 'lucide-react';
|
||||
|
||||
const InlineGroupEdit = ({
|
||||
@@ -88,11 +88,8 @@ const InlineGroupEdit = ({
|
||||
const handleSave = async () => {
|
||||
if (disabled || isLoading) return;
|
||||
|
||||
console.log('handleSave called:', { selectedValue, originalValue: value, changed: selectedValue !== value });
|
||||
|
||||
// Check if value actually changed
|
||||
if (selectedValue === value) {
|
||||
console.log('No change detected, closing edit mode');
|
||||
setIsEditing(false);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
@@ -102,15 +99,12 @@ const InlineGroupEdit = ({
|
||||
setError('');
|
||||
|
||||
try {
|
||||
console.log('Calling onSave with:', selectedValue);
|
||||
await onSave(selectedValue);
|
||||
console.log('Save successful');
|
||||
// Update the local value to match the saved value
|
||||
setSelectedValue(selectedValue);
|
||||
setIsEditing(false);
|
||||
setIsOpen(false);
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err);
|
||||
setError(err.message || 'Failed to save');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -127,22 +121,23 @@ const InlineGroupEdit = ({
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayValue = () => {
|
||||
console.log('getDisplayValue called with:', { value, options });
|
||||
const displayValue = useMemo(() => {
|
||||
if (!value) {
|
||||
console.log('No value, returning Ungrouped');
|
||||
return 'Ungrouped';
|
||||
}
|
||||
const option = options.find(opt => opt.id === value);
|
||||
console.log('Found option:', option);
|
||||
return option ? option.name : 'Unknown Group';
|
||||
};
|
||||
}, [value, options]);
|
||||
|
||||
const getDisplayColor = () => {
|
||||
const displayColor = useMemo(() => {
|
||||
if (!value) return 'bg-secondary-100 text-secondary-800';
|
||||
const option = options.find(opt => opt.id === value);
|
||||
return option ? `text-white` : 'bg-secondary-100 text-secondary-800';
|
||||
};
|
||||
}, [value, options]);
|
||||
|
||||
const selectedOption = useMemo(() => {
|
||||
return options.find(opt => opt.id === value);
|
||||
}, [value, options]);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
@@ -241,10 +236,10 @@ const InlineGroupEdit = ({
|
||||
return (
|
||||
<div className={`flex items-center gap-2 group ${className}`}>
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getDisplayColor()}`}
|
||||
style={value ? { backgroundColor: options.find(opt => opt.id === value)?.color } : {}}
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${displayColor}`}
|
||||
style={value ? { backgroundColor: selectedOption?.color } : {}}
|
||||
>
|
||||
{getDisplayValue()}
|
||||
{displayValue}
|
||||
</span>
|
||||
{!disabled && (
|
||||
<button
|
||||
|
@@ -23,7 +23,12 @@ import {
|
||||
Plus,
|
||||
Activity,
|
||||
Cog,
|
||||
FileText
|
||||
FileText,
|
||||
Github,
|
||||
MessageCircle,
|
||||
Mail,
|
||||
Star,
|
||||
Globe
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
@@ -40,6 +45,7 @@ const Layout = ({ children }) => {
|
||||
return saved ? JSON.parse(saved) : false
|
||||
})
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const [githubStars, setGithubStars] = useState(null)
|
||||
const location = useLocation()
|
||||
const { user, logout, canViewHosts, canManageHosts, canViewPackages, canViewUsers, canManageUsers, canManageSettings } = useAuth()
|
||||
const { updateAvailable } = useUpdateNotification()
|
||||
@@ -133,10 +139,56 @@ 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 () => {
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/repos/9technologygroup/patchmon.net')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setGithubStars(data.stargazers_count)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub stars:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Short format for navigation area
|
||||
const formatRelativeTimeShort = (date) => {
|
||||
if (!date) return 'Never'
|
||||
|
||||
const now = new Date()
|
||||
const diff = now - new Date(date)
|
||||
const dateObj = new Date(date)
|
||||
|
||||
// Check if date is valid
|
||||
if (isNaN(dateObj.getTime())) return 'Invalid date'
|
||||
|
||||
const diff = now - dateObj
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
@@ -167,6 +219,11 @@ const Layout = ({ children }) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch GitHub stars on component mount
|
||||
useEffect(() => {
|
||||
fetchGitHubStars()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-secondary-50">
|
||||
{/* Mobile sidebar */}
|
||||
@@ -425,6 +482,7 @@ const Layout = ({ children }) => {
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
|
||||
{/* Profile Section - Bottom of Sidebar */}
|
||||
<div className="border-t border-secondary-200 dark:border-secondary-600">
|
||||
{!sidebarCollapsed ? (
|
||||
@@ -560,20 +618,54 @@ const Layout = ({ children }) => {
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
||||
{/* Customize Dashboard Button - Only show on Dashboard page */}
|
||||
{location.pathname === '/' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
// This will be handled by the Dashboard component
|
||||
const event = new CustomEvent('openDashboardSettings');
|
||||
window.dispatchEvent(event);
|
||||
}}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
{/* External Links */}
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href="https://github.com/9technologygroup/patchmon.net"
|
||||
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"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Customize Dashboard
|
||||
<Github className="h-5 w-5 flex-shrink-0" />
|
||||
{githubStars !== null && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Star className="h-3 w-3 fill-current text-yellow-500" />
|
||||
<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"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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="Discord"
|
||||
>
|
||||
<MessageCircle className="h-5 w-5" />
|
||||
</a>
|
||||
<button
|
||||
onClick={copyEmailToClipboard}
|
||||
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"
|
||||
>
|
||||
<Mail className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href="https://patchmon.net"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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="Visit patchmon.net"
|
||||
>
|
||||
<Globe className="h-5 w-5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -18,10 +18,6 @@ export const AuthProvider = ({ children }) => {
|
||||
const [permissionsLoading, setPermissionsLoading] = useState(false)
|
||||
const [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false)
|
||||
|
||||
// Debug: Log when needsFirstTimeSetup changes
|
||||
useEffect(() => {
|
||||
console.log('needsFirstTimeSetup changed to:', needsFirstTimeSetup)
|
||||
}, [needsFirstTimeSetup])
|
||||
const [checkingSetup, setCheckingSetup] = useState(true)
|
||||
|
||||
// Initialize auth state from localStorage
|
||||
@@ -227,21 +223,17 @@ export const AuthProvider = ({ children }) => {
|
||||
// Check if any admin users exist (for first-time setup)
|
||||
const checkAdminUsersExist = useCallback(async () => {
|
||||
try {
|
||||
console.log('Making API call to check admin users...')
|
||||
const response = await fetch('/api/v1/auth/check-admin-users', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
console.log('Admin check response:', data) // Debug log
|
||||
console.log('hasAdminUsers:', data.hasAdminUsers, 'Setting needsFirstTimeSetup to:', !data.hasAdminUsers)
|
||||
setNeedsFirstTimeSetup(!data.hasAdminUsers)
|
||||
} else {
|
||||
console.log('Admin check failed:', response.status, response.statusText) // Debug log
|
||||
// If endpoint doesn't exist or fails, assume setup is needed
|
||||
setNeedsFirstTimeSetup(true)
|
||||
}
|
||||
@@ -256,12 +248,9 @@ export const AuthProvider = ({ children }) => {
|
||||
|
||||
// Check for admin users on initial load
|
||||
useEffect(() => {
|
||||
console.log('AuthContext useEffect triggered:', { token: !!token, user: !!user })
|
||||
if (!token && !user) {
|
||||
console.log('Calling checkAdminUsersExist...')
|
||||
checkAdminUsersExist()
|
||||
} else {
|
||||
console.log('Skipping admin check - user already authenticated')
|
||||
setCheckingSetup(false)
|
||||
}
|
||||
}, [token, user, checkAdminUsersExist])
|
||||
|
@@ -9,13 +9,15 @@ import {
|
||||
TrendingUp,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
WifiOff
|
||||
WifiOff,
|
||||
Settings
|
||||
} from 'lucide-react'
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title } from 'chart.js'
|
||||
import { Pie, Bar } from 'react-chartjs-2'
|
||||
import { dashboardAPI, dashboardPreferencesAPI, settingsAPI, formatRelativeTime } from '../utils/api'
|
||||
import DashboardSettingsModal from '../components/DashboardSettingsModal'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title)
|
||||
@@ -25,6 +27,7 @@ const Dashboard = () => {
|
||||
const [cardPreferences, setCardPreferences] = useState([])
|
||||
const navigate = useNavigate()
|
||||
const { isDark } = useTheme()
|
||||
const { user } = useAuth()
|
||||
|
||||
// Navigation handlers
|
||||
const handleTotalHostsClick = () => {
|
||||
@@ -52,17 +55,61 @@ const Dashboard = () => {
|
||||
}
|
||||
|
||||
const handleOSDistributionClick = () => {
|
||||
navigate('/hosts', { replace: true })
|
||||
navigate('/hosts?showFilters=true', { replace: true })
|
||||
}
|
||||
|
||||
const handleUpdateStatusClick = () => {
|
||||
navigate('/hosts', { replace: true })
|
||||
navigate('/hosts?filter=needsUpdates', { replace: true })
|
||||
}
|
||||
|
||||
const handlePackagePriorityClick = () => {
|
||||
navigate('/packages?filter=security')
|
||||
}
|
||||
|
||||
// Chart click handlers
|
||||
const handleOSChartClick = (event, elements) => {
|
||||
if (elements.length > 0) {
|
||||
const elementIndex = elements[0].index
|
||||
const osName = stats.charts.osDistribution[elementIndex].name.toLowerCase()
|
||||
navigate(`/hosts?osFilter=${osName}&showFilters=true`, { replace: true })
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateStatusChartClick = (event, elements) => {
|
||||
if (elements.length > 0) {
|
||||
const elementIndex = elements[0].index
|
||||
const statusName = stats.charts.updateStatusDistribution[elementIndex].name
|
||||
|
||||
// Map status names to filter parameters
|
||||
let filter = ''
|
||||
if (statusName.toLowerCase().includes('needs updates')) {
|
||||
filter = 'needsUpdates'
|
||||
} else if (statusName.toLowerCase().includes('up to date')) {
|
||||
filter = 'upToDate'
|
||||
} else if (statusName.toLowerCase().includes('stale')) {
|
||||
filter = 'stale'
|
||||
}
|
||||
|
||||
if (filter) {
|
||||
navigate(`/hosts?filter=${filter}`, { replace: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePackagePriorityChartClick = (event, elements) => {
|
||||
if (elements.length > 0) {
|
||||
const elementIndex = elements[0].index
|
||||
const priorityName = stats.charts.packageUpdateDistribution[elementIndex].name
|
||||
|
||||
// Map priority names to filter parameters
|
||||
if (priorityName.toLowerCase().includes('security')) {
|
||||
navigate('/packages?filter=security', { replace: true })
|
||||
} else if (priorityName.toLowerCase().includes('outdated')) {
|
||||
navigate('/packages?filter=outdated', { replace: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to format the update interval threshold
|
||||
const formatUpdateIntervalThreshold = () => {
|
||||
if (!settings?.updateInterval) return '24 hours'
|
||||
@@ -373,7 +420,7 @@ const Dashboard = () => {
|
||||
>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Update Status</h3>
|
||||
<div className="h-64">
|
||||
<Pie data={updateStatusChartData} options={chartOptions} />
|
||||
<Pie data={updateStatusChartData} options={updateStatusChartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -386,7 +433,7 @@ const Dashboard = () => {
|
||||
>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Package Priority</h3>
|
||||
<div className="h-64">
|
||||
<Pie data={packagePriorityChartData} options={chartOptions} />
|
||||
<Pie data={packagePriorityChartData} options={packagePriorityChartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -469,6 +516,39 @@ const Dashboard = () => {
|
||||
}
|
||||
},
|
||||
},
|
||||
onClick: handleOSChartClick,
|
||||
}
|
||||
|
||||
const updateStatusChartOptions = {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: isDark ? '#ffffff' : '#374151',
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
onClick: handleUpdateStatusChartClick,
|
||||
}
|
||||
|
||||
const packagePriorityChartOptions = {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: isDark ? '#ffffff' : '#374151',
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
onClick: handlePackagePriorityChartClick,
|
||||
}
|
||||
|
||||
const barChartOptions = {
|
||||
@@ -582,12 +662,22 @@ const Dashboard = () => {
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">Dashboard</h1>
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
|
||||
Welcome back, {user?.first_name || user?.username || 'User'} 👋
|
||||
</h1>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Overview of your PatchMon infrastructure
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowSettingsModal(true)}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
title="Customize dashboard layout"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Customize Dashboard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
|
@@ -211,9 +211,9 @@ const HostDetail = () => {
|
||||
<span className="text-xs font-medium">Last updated:</span>
|
||||
<span>{formatRelativeTime(host.last_update)}</span>
|
||||
</div>
|
||||
<div className={`flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(isStale, host.stats.outdatedPackages > 0)}`}>
|
||||
{getStatusIcon(isStale, host.stats.outdatedPackages > 0)}
|
||||
{getStatusText(isStale, host.stats.outdatedPackages > 0)}
|
||||
<div className={`flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(isStale, host.stats.outdated_packages > 0)}`}>
|
||||
{getStatusIcon(isStale, host.stats.outdated_packages > 0)}
|
||||
{getStatusText(isStale, host.stats.outdated_packages > 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -333,12 +333,12 @@ const HostDetail = () => {
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">Host Group</p>
|
||||
{host.hostGroup ? (
|
||||
{host.host_groups ? (
|
||||
<span
|
||||
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: host.hostGroup.color }}
|
||||
style={{ backgroundColor: host.host_groups.color }}
|
||||
>
|
||||
{host.hostGroup.name}
|
||||
{host.host_groups.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-200">
|
||||
@@ -355,18 +355,6 @@ const HostDetail = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{host.ip && (
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">IP Address</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.ip}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">Last Update</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white text-sm">{formatRelativeTime(host.last_update)}</p>
|
||||
</div>
|
||||
|
||||
{host.agent_version && (
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -720,7 +708,7 @@ const HostDetail = () => {
|
||||
{/* Package Statistics */}
|
||||
<div className="card">
|
||||
<div className="px-4 py-2.5 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<h3 className="text-sm font-medium text-secondary-900 dark:text-white">Package Statistics</h3>
|
||||
<h3 className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Package Statistics</h3>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
@@ -728,7 +716,7 @@ const HostDetail = () => {
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-primary-100 dark:bg-primary-800 rounded-lg mx-auto mb-2">
|
||||
<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.totalPackages}</p>
|
||||
<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>
|
||||
|
||||
@@ -740,7 +728,7 @@ const HostDetail = () => {
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-warning-100 dark:bg-warning-800 rounded-lg mx-auto mb-2 group-hover:bg-warning-200 dark:group-hover:bg-warning-700 transition-colors">
|
||||
<Clock className="h-6 w-6 text-warning-600 dark:text-warning-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.outdatedPackages}</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.outdated_packages}</p>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">Outdated Packages</p>
|
||||
</button>
|
||||
|
||||
@@ -752,7 +740,7 @@ const HostDetail = () => {
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-danger-100 dark:bg-danger-800 rounded-lg mx-auto mb-2 group-hover:bg-danger-200 dark:group-hover:bg-danger-700 transition-colors">
|
||||
<Shield className="h-6 w-6 text-danger-600 dark:text-danger-400" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.securityUpdates}</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.security_updates}</p>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">Security Updates</p>
|
||||
</button>
|
||||
</div>
|
||||
@@ -797,8 +785,40 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
|
||||
const serverUrl = serverUrlData?.server_url || 'http://localhost:3001'
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
const copyToClipboard = async (text) => {
|
||||
try {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback for older browsers or non-secure contexts
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-999999px'
|
||||
textArea.style.top = '-999999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
if (!successful) {
|
||||
throw new Error('Copy command failed')
|
||||
}
|
||||
} catch (err) {
|
||||
// If all else fails, show the text in a prompt
|
||||
prompt('Copy this command:', text)
|
||||
} finally {
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy to clipboard:', err)
|
||||
// Show the text in a prompt as last resort
|
||||
prompt('Copy this command:', text)
|
||||
}
|
||||
}
|
||||
|
||||
const getSetupCommands = () => {
|
||||
@@ -1035,12 +1055,12 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={host.apiId}
|
||||
value={host.api_id}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copyToClipboard(host.apiId)}
|
||||
onClick={() => copyToClipboard(host.api_id)}
|
||||
className="btn-outline flex items-center gap-1"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
@@ -1054,7 +1074,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={host.apiKey}
|
||||
value={host.api_key}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
|
||||
/>
|
||||
@@ -1065,7 +1085,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copyToClipboard(host.apiKey)}
|
||||
onClick={() => copyToClipboard(host.api_key)}
|
||||
className="btn-outline flex items-center gap-1"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
|
@@ -121,16 +121,16 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
{/* No Group Option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, hostGroupId: '' })}
|
||||
onClick={() => setFormData({ ...formData, host_group_id: '' })}
|
||||
className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
|
||||
formData.hostGroupId === ''
|
||||
formData.host_group_id === ''
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
|
||||
: 'border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium">No Group</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">Ungrouped</div>
|
||||
{formData.hostGroupId === '' && (
|
||||
{formData.host_group_id === '' && (
|
||||
<div className="absolute top-2 right-2 w-3 h-3 rounded-full bg-primary-500 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white"></div>
|
||||
</div>
|
||||
@@ -142,9 +142,9 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, hostGroupId: group.id })}
|
||||
onClick={() => setFormData({ ...formData, host_group_id: group.id })}
|
||||
className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
|
||||
formData.hostGroupId === group.id
|
||||
formData.host_group_id === group.id
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
|
||||
: 'border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500'
|
||||
}`}
|
||||
@@ -159,7 +159,7 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
<div className="text-xs font-medium truncate max-w-full">{group.name}</div>
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">Group</div>
|
||||
{formData.hostGroupId === group.id && (
|
||||
{formData.host_group_id === group.id && (
|
||||
<div className="absolute top-2 right-2 w-3 h-3 rounded-full bg-primary-500 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white"></div>
|
||||
</div>
|
||||
@@ -214,9 +214,43 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
}
|
||||
}, [host?.isNewHost])
|
||||
|
||||
const copyToClipboard = (text, label) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
alert(`${label} copied to clipboard!`)
|
||||
const copyToClipboard = async (text, label) => {
|
||||
try {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
alert(`${label} copied to clipboard!`)
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback for older browsers or non-secure contexts
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-999999px'
|
||||
textArea.style.top = '-999999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
if (successful) {
|
||||
alert(`${label} copied to clipboard!`)
|
||||
} else {
|
||||
throw new Error('Copy command failed')
|
||||
}
|
||||
} catch (err) {
|
||||
// If all else fails, show the text in a prompt
|
||||
prompt(`Copy this ${label.toLowerCase()}:`, text)
|
||||
} finally {
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy to clipboard:', err)
|
||||
// Show the text in a prompt as last resort
|
||||
prompt(`Copy this ${label.toLowerCase()}:`, text)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch server URL from settings
|
||||
@@ -604,6 +638,7 @@ const Hosts = () => {
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [selectedHosts, setSelectedHosts] = useState([])
|
||||
const [showBulkAssignModal, setShowBulkAssignModal] = useState(false)
|
||||
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
@@ -622,6 +657,9 @@ const Hosts = () => {
|
||||
// Handle URL filter parameters
|
||||
useEffect(() => {
|
||||
const filter = searchParams.get('filter')
|
||||
const showFiltersParam = searchParams.get('showFilters')
|
||||
const osFilterParam = searchParams.get('osFilter')
|
||||
|
||||
if (filter === 'needsUpdates') {
|
||||
setShowFilters(true)
|
||||
setStatusFilter('all')
|
||||
@@ -634,6 +672,18 @@ const Hosts = () => {
|
||||
setShowFilters(true)
|
||||
setStatusFilter('active')
|
||||
// We'll filter hosts that are up to date in the filtering logic
|
||||
} else if (filter === 'stale') {
|
||||
setShowFilters(true)
|
||||
setStatusFilter('all')
|
||||
// We'll filter hosts that are stale in the filtering logic
|
||||
} else if (showFiltersParam === 'true') {
|
||||
setShowFilters(true)
|
||||
}
|
||||
|
||||
// Handle OS filter parameter
|
||||
if (osFilterParam) {
|
||||
setShowFilters(true)
|
||||
setOsFilter(osFilterParam)
|
||||
}
|
||||
|
||||
// Handle add host action from navigation
|
||||
@@ -732,7 +782,7 @@ const Hosts = () => {
|
||||
// Ensure hostGroupId is set correctly
|
||||
return {
|
||||
...updatedHost,
|
||||
hostGroupId: updatedHost.hostGroup?.id || null
|
||||
hostGroupId: updatedHost.host_groups?.id || null
|
||||
};
|
||||
}
|
||||
return host;
|
||||
@@ -771,9 +821,6 @@ const Hosts = () => {
|
||||
});
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
console.log('updateHostGroupMutation success:', data);
|
||||
console.log('Updated host data:', data.host);
|
||||
console.log('Host group in response:', data.host.hostGroup);
|
||||
|
||||
// Update the cache with the new host data
|
||||
queryClient.setQueryData(['hosts'], (oldData) => {
|
||||
@@ -785,7 +832,7 @@ const Hosts = () => {
|
||||
// Ensure hostGroupId is set correctly
|
||||
const updatedHost = {
|
||||
...data.host,
|
||||
hostGroupId: data.host.hostGroup?.id || null
|
||||
hostGroupId: data.host.host_groups?.id || null
|
||||
};
|
||||
console.log('Updated host with hostGroupId:', updatedHost);
|
||||
return updatedHost;
|
||||
@@ -804,6 +851,19 @@ const Hosts = () => {
|
||||
}
|
||||
})
|
||||
|
||||
const bulkDeleteMutation = useMutation({
|
||||
mutationFn: (hostIds) => adminHostsAPI.deleteBulk(hostIds),
|
||||
onSuccess: (data) => {
|
||||
console.log('Bulk delete success:', data);
|
||||
queryClient.invalidateQueries(['hosts']);
|
||||
setSelectedHosts([]);
|
||||
setShowBulkDeleteModal(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Bulk delete error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions for bulk selection
|
||||
const handleSelectHost = (hostId) => {
|
||||
setSelectedHosts(prev =>
|
||||
@@ -825,6 +885,10 @@ const Hosts = () => {
|
||||
bulkUpdateGroupMutation.mutate({ hostIds: selectedHosts, hostGroupId })
|
||||
}
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
bulkDeleteMutation.mutate(selectedHosts)
|
||||
}
|
||||
|
||||
// Table filtering and sorting logic
|
||||
const filteredAndSortedHosts = React.useMemo(() => {
|
||||
if (!hosts) return []
|
||||
@@ -838,8 +902,8 @@ const Hosts = () => {
|
||||
|
||||
// Group filter
|
||||
const matchesGroup = groupFilter === 'all' ||
|
||||
(groupFilter === 'ungrouped' && !host.hostGroup) ||
|
||||
(groupFilter !== 'ungrouped' && host.hostGroup?.id === groupFilter)
|
||||
(groupFilter === 'ungrouped' && !host.host_groups) ||
|
||||
(groupFilter !== 'ungrouped' && host.host_groups?.id === groupFilter)
|
||||
|
||||
// Status filter
|
||||
const matchesStatus = statusFilter === 'all' || (host.effectiveStatus || host.status) === statusFilter
|
||||
@@ -847,12 +911,13 @@ const Hosts = () => {
|
||||
// OS filter
|
||||
const matchesOs = osFilter === 'all' || host.os_type?.toLowerCase() === osFilter.toLowerCase()
|
||||
|
||||
// URL filter for hosts needing updates, inactive hosts, or up-to-date hosts
|
||||
// URL filter for hosts needing updates, inactive hosts, up-to-date hosts, or stale hosts
|
||||
const filter = searchParams.get('filter')
|
||||
const matchesUrlFilter =
|
||||
(filter !== 'needsUpdates' || (host.updatesCount && host.updatesCount > 0)) &&
|
||||
(filter !== 'inactive' || (host.effectiveStatus || host.status) === 'inactive') &&
|
||||
(filter !== 'upToDate' || (!host.isStale && host.updatesCount === 0))
|
||||
(filter !== 'upToDate' || (!host.isStale && host.updatesCount === 0)) &&
|
||||
(filter !== 'stale' || host.isStale)
|
||||
|
||||
// Hide stale filter
|
||||
const matchesHideStale = !hideStale || !host.isStale
|
||||
@@ -878,8 +943,8 @@ const Hosts = () => {
|
||||
bValue = b.ip?.toLowerCase() || 'zzz_no_ip'
|
||||
break
|
||||
case 'group':
|
||||
aValue = a.hostGroup?.name || 'zzz_ungrouped'
|
||||
bValue = b.hostGroup?.name || 'zzz_ungrouped'
|
||||
aValue = a.host_groups?.name || 'zzz_ungrouped'
|
||||
bValue = b.host_groups?.name || 'zzz_ungrouped'
|
||||
break
|
||||
case 'os':
|
||||
aValue = a.os_type?.toLowerCase() || 'zzz_unknown'
|
||||
@@ -929,7 +994,7 @@ const Hosts = () => {
|
||||
let groupKey
|
||||
switch (groupBy) {
|
||||
case 'group':
|
||||
groupKey = host.hostGroup?.name || 'Ungrouped'
|
||||
groupKey = host.host_groups?.name || 'Ungrouped'
|
||||
break
|
||||
case 'status':
|
||||
groupKey = (host.effectiveStatus || host.status).charAt(0).toUpperCase() + (host.effectiveStatus || host.status).slice(1)
|
||||
@@ -1055,16 +1120,10 @@ const Hosts = () => {
|
||||
</div>
|
||||
)
|
||||
case 'group':
|
||||
console.log('Rendering group for host:', {
|
||||
hostId: host.id,
|
||||
hostGroupId: host.hostGroupId,
|
||||
hostGroup: host.hostGroup,
|
||||
availableGroups: hostGroups
|
||||
});
|
||||
return (
|
||||
<InlineGroupEdit
|
||||
key={`${host.id}-${host.hostGroup?.id || 'ungrouped'}-${host.hostGroup?.name || 'ungrouped'}`}
|
||||
value={host.hostGroup?.id}
|
||||
key={`${host.id}-${host.host_groups?.id || 'ungrouped'}-${host.host_groups?.name || 'ungrouped'}`}
|
||||
value={host.host_groups?.id}
|
||||
onSave={(newGroupId) => updateHostGroupMutation.mutate({ hostId: host.id, hostGroupId: newGroupId })}
|
||||
options={hostGroups || []}
|
||||
placeholder="Select group..."
|
||||
@@ -1232,9 +1291,9 @@ const Hosts = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">Hosts</h1>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
@@ -1262,7 +1321,7 @@ const Hosts = () => {
|
||||
</div>
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
|
||||
<div
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleTotalHostsClick}
|
||||
@@ -1320,8 +1379,8 @@ const Hosts = () => {
|
||||
</div>
|
||||
|
||||
{/* Hosts List */}
|
||||
<div className="card">
|
||||
<div className="px-4 py-4 sm:p-4">
|
||||
<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
<div className="px-4 py-4 sm:p-4 flex-1 flex flex-col overflow-hidden min-h-0">
|
||||
<div className="flex items-center justify-end mb-4">
|
||||
{selectedHosts.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -1335,6 +1394,13 @@ const Hosts = () => {
|
||||
<Users className="h-4 w-4" />
|
||||
Assign to Group
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowBulkDeleteModal(true)}
|
||||
className="btn-danger flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedHosts([])}
|
||||
className="text-sm text-secondary-500 hover:text-secondary-700"
|
||||
@@ -1471,17 +1537,27 @@ const Hosts = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(!hosts || hosts.length === 0) ? (
|
||||
<div className="text-center py-8">
|
||||
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500">No hosts registered yet</p>
|
||||
<p className="text-sm text-secondary-400 mt-2">
|
||||
Click "Add Host" to manually register a new host and get API credentials
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{Object.entries(groupedHosts).map(([groupName, groupHosts]) => (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{(!hosts || hosts.length === 0) ? (
|
||||
<div className="text-center py-8">
|
||||
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500">No hosts registered yet</p>
|
||||
<p className="text-sm text-secondary-400 mt-2">
|
||||
Click "Add Host" to manually register a new host and get API credentials
|
||||
</p>
|
||||
</div>
|
||||
) : filteredAndSortedHosts.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Search className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500">No hosts match your current filters</p>
|
||||
<p className="text-sm text-secondary-400 mt-2">
|
||||
Try adjusting your search terms or filters to see more results
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="space-y-6">
|
||||
{Object.entries(groupedHosts).map(([groupName, groupHosts]) => (
|
||||
<div key={groupName} className="space-y-3">
|
||||
{/* Group Header */}
|
||||
{groupBy !== 'none' && (
|
||||
@@ -1628,11 +1704,13 @@ const Hosts = () => {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<AddHostModal
|
||||
@@ -1653,6 +1731,17 @@ const Hosts = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bulk Delete Modal */}
|
||||
{showBulkDeleteModal && (
|
||||
<BulkDeleteModal
|
||||
selectedHosts={selectedHosts}
|
||||
hosts={hosts}
|
||||
onClose={() => setShowBulkDeleteModal(false)}
|
||||
onDelete={handleBulkDelete}
|
||||
isLoading={bulkDeleteMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Column Settings Modal */}
|
||||
{showColumnSettings && (
|
||||
<ColumnSettingsModal
|
||||
@@ -1756,6 +1845,85 @@ const BulkAssignModal = ({ selectedHosts, hosts, onClose, onAssign, isLoading })
|
||||
)
|
||||
}
|
||||
|
||||
// Bulk Delete Modal Component
|
||||
const BulkDeleteModal = ({ selectedHosts, hosts, onClose, onDelete, isLoading }) => {
|
||||
const selectedHostNames = hosts
|
||||
.filter(host => selectedHosts.includes(host.id))
|
||||
.map(host => host.friendly_name || host.hostname || host.id)
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
onDelete()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Delete Hosts</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-600" />
|
||||
<h4 className="text-sm font-medium text-danger-800 dark:text-danger-200">
|
||||
Warning: This action cannot be undone
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-4">
|
||||
You are about to permanently delete {selectedHosts.length} host{selectedHosts.length !== 1 ? 's' : ''}.
|
||||
This will remove all host data, including package information, update history, and API credentials.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-2">
|
||||
Hosts to be deleted:
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto bg-secondary-50 dark:bg-secondary-700 rounded-md p-3">
|
||||
{selectedHostNames.map((friendlyName, index) => (
|
||||
<div key={index} className="text-sm text-secondary-700 dark:text-secondary-300">
|
||||
• {friendlyName}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-danger"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Deleting...' : `Delete ${selectedHosts.length} Host${selectedHosts.length !== 1 ? 's' : ''}`}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Column Settings Modal Component
|
||||
const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => {
|
||||
const [draggedIndex, setDraggedIndex] = useState(null)
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Lock, User, AlertCircle, Smartphone, ArrowLeft, Mail } from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
@@ -19,10 +19,29 @@ const Login = () => {
|
||||
const [error, setError] = useState('')
|
||||
const [requiresTfa, setRequiresTfa] = useState(false)
|
||||
const [tfaUsername, setTfaUsername] = useState('')
|
||||
const [signupEnabled, setSignupEnabled] = useState(false)
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { login } = useAuth()
|
||||
|
||||
// Check if signup is enabled
|
||||
useEffect(() => {
|
||||
const checkSignupEnabled = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/signup-enabled')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setSignupEnabled(data.signupEnabled)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check signup status:', error)
|
||||
// Default to disabled on error for security
|
||||
setSignupEnabled(false)
|
||||
}
|
||||
}
|
||||
checkSignupEnabled()
|
||||
}, [])
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
@@ -135,6 +154,10 @@ const Login = () => {
|
||||
}
|
||||
|
||||
const toggleMode = () => {
|
||||
// Only allow signup mode if signup is enabled
|
||||
if (!signupEnabled && !isSignupMode) {
|
||||
return // Don't allow switching to signup if disabled
|
||||
}
|
||||
setIsSignupMode(!isSignupMode)
|
||||
setFormData({
|
||||
username: '',
|
||||
@@ -269,18 +292,20 @@ const Login = () => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-secondary-600">
|
||||
{isSignupMode ? 'Already have an account?' : "Don't have an account?"}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline"
|
||||
>
|
||||
{isSignupMode ? 'Sign in' : 'Sign up'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
{signupEnabled && (
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-secondary-600">
|
||||
{isSignupMode ? 'Already have an account?' : "Don't have an account?"}{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleMode}
|
||||
className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline"
|
||||
>
|
||||
{isSignupMode ? 'Sign in' : 'Sign up'}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
) : (
|
||||
<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}>
|
||||
|
@@ -71,8 +71,9 @@ const Packages = () => {
|
||||
|
||||
// Handle affected hosts click
|
||||
const handleAffectedHostsClick = (pkg) => {
|
||||
const hostIds = pkg.affectedHosts.map(host => host.hostId)
|
||||
const hostNames = pkg.affectedHosts.map(host => host.friendlyName)
|
||||
const affectedHosts = pkg.affectedHosts || []
|
||||
const hostIds = affectedHosts.map(host => host.hostId)
|
||||
const hostNames = affectedHosts.map(host => host.friendlyName)
|
||||
|
||||
// Create URL with selected hosts and filter
|
||||
const params = new URLSearchParams()
|
||||
@@ -128,8 +129,9 @@ const Packages = () => {
|
||||
(securityFilter === 'security' && pkg.isSecurityUpdate) ||
|
||||
(securityFilter === 'regular' && !pkg.isSecurityUpdate)
|
||||
|
||||
const affectedHosts = pkg.affectedHosts || []
|
||||
const matchesHost = hostFilter === 'all' ||
|
||||
pkg.affectedHosts.some(host => host.hostId === hostFilter)
|
||||
affectedHosts.some(host => host.hostId === hostFilter)
|
||||
|
||||
return matchesSearch && matchesCategory && matchesSecurity && matchesHost
|
||||
})
|
||||
@@ -148,8 +150,8 @@ const Packages = () => {
|
||||
bValue = b.latestVersion?.toLowerCase() || ''
|
||||
break
|
||||
case 'affectedHosts':
|
||||
aValue = a.affectedHostsCount || 0
|
||||
bValue = b.affectedHostsCount || 0
|
||||
aValue = a.affectedHostsCount || a.affectedHosts?.length || 0
|
||||
bValue = b.affectedHostsCount || b.affectedHosts?.length || 0
|
||||
break
|
||||
case 'priority':
|
||||
aValue = a.isSecurityUpdate ? 0 : 1 // Security updates first
|
||||
@@ -241,14 +243,15 @@ const Packages = () => {
|
||||
</div>
|
||||
)
|
||||
case 'affectedHosts':
|
||||
const affectedHostsCount = pkg.affectedHostsCount || pkg.affectedHosts?.length || 0
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleAffectedHostsClick(pkg)}
|
||||
className="text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-1 -m-1 transition-colors group"
|
||||
title={`Click to view all ${pkg.affectedHostsCount} affected hosts`}
|
||||
title={`Click to view all ${affectedHostsCount} affected hosts`}
|
||||
>
|
||||
<div className="text-sm text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
|
||||
{pkg.affectedHostsCount} host{pkg.affectedHostsCount !== 1 ? 's' : ''}
|
||||
{affectedHostsCount} host{affectedHostsCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
@@ -278,7 +281,8 @@ const Packages = () => {
|
||||
// Calculate unique affected hosts
|
||||
const uniqueAffectedHosts = new Set()
|
||||
packages?.forEach(pkg => {
|
||||
pkg.affectedHosts.forEach(host => {
|
||||
const affectedHosts = pkg.affectedHosts || []
|
||||
affectedHosts.forEach(host => {
|
||||
uniqueAffectedHosts.add(host.hostId)
|
||||
})
|
||||
})
|
||||
@@ -458,7 +462,7 @@ const Packages = () => {
|
||||
>
|
||||
<option value="all">All Hosts</option>
|
||||
{hosts?.map(host => (
|
||||
<option key={host.id} value={host.id}>{host.friendlyName}</option>
|
||||
<option key={host.id} value={host.id}>{host.friendly_name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Shield,
|
||||
@@ -145,17 +145,22 @@ const Permissions = () => {
|
||||
const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDelete }) => {
|
||||
const [permissions, setPermissions] = useState(role)
|
||||
|
||||
// Sync permissions state with role prop when it changes
|
||||
useEffect(() => {
|
||||
setPermissions(role)
|
||||
}, [role])
|
||||
|
||||
const permissionFields = [
|
||||
{ key: 'canViewDashboard', label: 'View Dashboard', icon: BarChart3, description: 'Access to the main dashboard' },
|
||||
{ key: 'canViewHosts', label: 'View Hosts', icon: Server, description: 'See host information and status' },
|
||||
{ key: 'canManageHosts', label: 'Manage Hosts', icon: Edit, description: 'Add, edit, and delete hosts' },
|
||||
{ key: 'canViewPackages', label: 'View Packages', icon: Package, description: 'See package information' },
|
||||
{ key: 'canManagePackages', label: 'Manage Packages', icon: Settings, description: 'Edit package details' },
|
||||
{ key: 'canViewUsers', label: 'View Users', icon: Users, description: 'See user list and details' },
|
||||
{ key: 'canManageUsers', label: 'Manage Users', icon: Shield, description: 'Add, edit, and delete users' },
|
||||
{ key: 'canViewReports', label: 'View Reports', icon: BarChart3, description: 'Access to reports and analytics' },
|
||||
{ key: 'canExportData', label: 'Export Data', icon: Download, description: 'Download data and reports' },
|
||||
{ key: 'canManageSettings', label: 'Manage Settings', icon: Settings, description: 'System configuration access' }
|
||||
{ key: 'can_view_dashboard', label: 'View Dashboard', icon: BarChart3, description: 'Access to the main dashboard' },
|
||||
{ key: 'can_view_hosts', label: 'View Hosts', icon: Server, description: 'See host information and status' },
|
||||
{ key: 'can_manage_hosts', label: 'Manage Hosts', icon: Edit, description: 'Add, edit, and delete hosts' },
|
||||
{ key: 'can_view_packages', label: 'View Packages', icon: Package, description: 'See package information' },
|
||||
{ key: 'can_manage_packages', label: 'Manage Packages', icon: Settings, description: 'Edit package details' },
|
||||
{ key: 'can_view_users', label: 'View Users', icon: Users, description: 'See user list and details' },
|
||||
{ key: 'can_manage_users', label: 'Manage Users', icon: Shield, description: 'Add, edit, and delete users' },
|
||||
{ key: 'can_view_reports', label: 'View Reports', icon: BarChart3, description: 'Access to reports and analytics' },
|
||||
{ key: 'can_export_data', label: 'Export Data', icon: Download, description: 'Download data and reports' },
|
||||
{ key: 'can_manage_settings', label: 'Manage Settings', icon: Settings, description: 'System configuration access' }
|
||||
]
|
||||
|
||||
const handlePermissionChange = (key, value) => {
|
||||
@@ -196,7 +201,7 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="inline-flex items-center px-3 py-1 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50"
|
||||
className="inline-flex items-center px-3 py-1 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Cancel
|
||||
@@ -240,7 +245,7 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => handlePermissionChange(field.key, e.target.checked)}
|
||||
disabled={!isEditing || (isAdminRole && field.key === 'canManageUsers')}
|
||||
disabled={!isEditing || (isAdminRole && field.key === 'can_manage_users')}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
@@ -268,16 +273,16 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
|
||||
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
role: '',
|
||||
canViewDashboard: true,
|
||||
canViewHosts: true,
|
||||
canManageHosts: false,
|
||||
canViewPackages: true,
|
||||
canManagePackages: false,
|
||||
canViewUsers: false,
|
||||
canManageUsers: false,
|
||||
canViewReports: true,
|
||||
canExportData: false,
|
||||
canManageSettings: false
|
||||
can_view_dashboard: true,
|
||||
can_view_hosts: true,
|
||||
can_manage_hosts: false,
|
||||
can_view_packages: true,
|
||||
can_manage_packages: false,
|
||||
can_view_users: false,
|
||||
can_manage_users: false,
|
||||
can_view_reports: true,
|
||||
can_export_data: false,
|
||||
can_manage_settings: false
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
@@ -309,12 +314,12 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-medium text-secondary-900 mb-4">Add New Role</h3>
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Add New Role</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Role Name
|
||||
</label>
|
||||
<input
|
||||
@@ -323,25 +328,25 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
required
|
||||
value={formData.role}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full border-secondary-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
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"
|
||||
placeholder="e.g., host_manager, readonly"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-secondary-500">Use lowercase with underscores (e.g., host_manager)</p>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">Use lowercase with underscores (e.g., host_manager)</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white">Permissions</h4>
|
||||
{[
|
||||
{ key: 'canViewDashboard', label: 'View Dashboard' },
|
||||
{ key: 'canViewHosts', label: 'View Hosts' },
|
||||
{ key: 'canManageHosts', label: 'Manage Hosts' },
|
||||
{ key: 'canViewPackages', label: 'View Packages' },
|
||||
{ key: 'canManagePackages', label: 'Manage Packages' },
|
||||
{ key: 'canViewUsers', label: 'View Users' },
|
||||
{ key: 'canManageUsers', label: 'Manage Users' },
|
||||
{ key: 'canViewReports', label: 'View Reports' },
|
||||
{ key: 'canExportData', label: 'Export Data' },
|
||||
{ key: 'canManageSettings', label: 'Manage Settings' }
|
||||
{ key: 'can_view_dashboard', label: 'View Dashboard' },
|
||||
{ key: 'can_view_hosts', label: 'View Hosts' },
|
||||
{ key: 'can_manage_hosts', label: 'Manage Hosts' },
|
||||
{ key: 'can_view_packages', label: 'View Packages' },
|
||||
{ key: 'can_manage_packages', label: 'Manage Packages' },
|
||||
{ key: 'can_view_users', label: 'View Users' },
|
||||
{ key: 'can_manage_users', label: 'Manage Users' },
|
||||
{ key: 'can_view_reports', label: 'View Reports' },
|
||||
{ key: 'can_export_data', label: 'Export Data' },
|
||||
{ key: 'can_manage_settings', label: 'Manage Settings' }
|
||||
].map((permission) => (
|
||||
<div key={permission.key} className="flex items-center">
|
||||
<input
|
||||
@@ -351,7 +356,7 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
onChange={handleInputChange}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-secondary-700">
|
||||
<label className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200">
|
||||
{permission.label}
|
||||
</label>
|
||||
</div>
|
||||
@@ -359,8 +364,8 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
|
||||
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -368,7 +373,7 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-secondary-700 bg-white border border-secondary-300 rounded-md hover:bg-secondary-50"
|
||||
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
@@ -517,9 +517,45 @@ const TfaTab = () => {
|
||||
regenerateBackupCodesMutation.mutate()
|
||||
}
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setMessage({ type: 'success', text: 'Copied to clipboard!' })
|
||||
const copyToClipboard = async (text) => {
|
||||
try {
|
||||
// Try modern clipboard API first
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setMessage({ type: 'success', text: 'Copied to clipboard!' })
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback for older browsers or non-secure contexts
|
||||
const textArea = document.createElement('textarea')
|
||||
textArea.value = text
|
||||
textArea.style.position = 'fixed'
|
||||
textArea.style.left = '-999999px'
|
||||
textArea.style.top = '-999999px'
|
||||
document.body.appendChild(textArea)
|
||||
textArea.focus()
|
||||
textArea.select()
|
||||
|
||||
try {
|
||||
const successful = document.execCommand('copy')
|
||||
if (successful) {
|
||||
setMessage({ type: 'success', text: 'Copied to clipboard!' })
|
||||
} else {
|
||||
throw new Error('Copy command failed')
|
||||
}
|
||||
} catch (err) {
|
||||
// If all else fails, show the text in a prompt
|
||||
prompt('Copy this text:', text)
|
||||
setMessage({ type: 'info', text: 'Text shown in prompt for manual copying' })
|
||||
} finally {
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to copy to clipboard:', err)
|
||||
// Show the text in a prompt as last resort
|
||||
prompt('Copy this text:', text)
|
||||
setMessage({ type: 'info', text: 'Text shown in prompt for manual copying' })
|
||||
}
|
||||
}
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
|
@@ -19,7 +19,8 @@ import {
|
||||
ArrowDown,
|
||||
X,
|
||||
GripVertical,
|
||||
Check
|
||||
Check,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { repositoryAPI } from '../utils/api';
|
||||
|
||||
@@ -60,7 +61,7 @@ const Repositories = () => {
|
||||
};
|
||||
|
||||
// Fetch repositories
|
||||
const { data: repositories = [], isLoading, error } = useQuery({
|
||||
const { data: repositories = [], isLoading, error, refetch, isFetching } = useQuery({
|
||||
queryKey: ['repositories'],
|
||||
queryFn: () => repositoryAPI.list().then(res => res.data)
|
||||
});
|
||||
@@ -132,14 +133,6 @@ const Repositories = () => {
|
||||
repo.url.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
repo.distribution.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
// Debug logging
|
||||
console.log('Filtering repo:', {
|
||||
name: repo.name,
|
||||
isSecure: repo.isSecure,
|
||||
filterType,
|
||||
url: repo.url
|
||||
});
|
||||
|
||||
// Check security based on URL if isSecure property doesn't exist
|
||||
const isSecure = repo.isSecure !== undefined ? repo.isSecure : repo.url.startsWith('https://');
|
||||
|
||||
@@ -151,13 +144,6 @@ const Repositories = () => {
|
||||
(filterStatus === 'active' && repo.is_active === true) ||
|
||||
(filterStatus === 'inactive' && repo.is_active === false);
|
||||
|
||||
console.log('Filter results:', {
|
||||
matchesSearch,
|
||||
matchesType,
|
||||
matchesStatus,
|
||||
final: matchesSearch && matchesType && matchesStatus
|
||||
});
|
||||
|
||||
return matchesSearch && matchesType && matchesStatus;
|
||||
});
|
||||
|
||||
@@ -211,6 +197,26 @@ const Repositories = () => {
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">Repositories</h1>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
|
||||
Manage and monitor your package repositories
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
disabled={isFetching}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
title="Refresh repositories data"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
|
||||
{isFetching ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6 flex-shrink-0">
|
||||
@@ -437,7 +443,7 @@ const Repositories = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1 text-sm text-secondary-900 dark:text-white">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>{repo.hostCount}</span>
|
||||
<span>{repo.host_count}</span>
|
||||
</div>
|
||||
)
|
||||
case 'actions':
|
||||
|
@@ -310,10 +310,10 @@ const RepositoryDetail = () => {
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Hosts Using This Repository ({repository.hostRepositories?.length || 0})
|
||||
Hosts Using This Repository ({repository.host_repositories?.length || 0})
|
||||
</h2>
|
||||
</div>
|
||||
{!repository.hostRepositories || repository.hostRepositories.length === 0 ? (
|
||||
{!repository.host_repositories || repository.host_repositories.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<Server className="mx-auto h-12 w-12 text-secondary-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">No hosts using this repository</h3>
|
||||
@@ -323,28 +323,28 @@ const RepositoryDetail = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
{repository.hostRepositories.map((hostRepo) => (
|
||||
{repository.host_repositories.map((hostRepo) => (
|
||||
<div key={hostRepo.id} className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
hostRepo.host.status === 'active'
|
||||
hostRepo.hosts.status === 'active'
|
||||
? 'bg-green-500'
|
||||
: hostRepo.host.status === 'pending'
|
||||
: hostRepo.hosts.status === 'pending'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
}`} />
|
||||
<div>
|
||||
<Link
|
||||
to={`/hosts/${hostRepo.host.id}`}
|
||||
to={`/hosts/${hostRepo.hosts.id}`}
|
||||
className="text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
{hostRepo.host.friendly_name}
|
||||
{hostRepo.hosts.friendly_name}
|
||||
</Link>
|
||||
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
|
||||
<span>IP: {hostRepo.host.ip}</span>
|
||||
<span>OS: {hostRepo.host.osType} {hostRepo.host.osVersion}</span>
|
||||
<span>Last Update: {new Date(hostRepo.host.lastUpdate).toLocaleDateString()}</span>
|
||||
<span>IP: {hostRepo.hosts.ip}</span>
|
||||
<span>OS: {hostRepo.hosts.os_type} {hostRepo.hosts.os_version}</span>
|
||||
<span>Last Update: {new Date(hostRepo.hosts.last_update).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -352,7 +352,7 @@ const RepositoryDetail = () => {
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">Last Checked</div>
|
||||
<div className="text-sm text-secondary-900 dark:text-white">
|
||||
{new Date(hostRepo.lastChecked).toLocaleDateString()}
|
||||
{new Date(hostRepo.last_checked).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -13,6 +13,7 @@ const Settings = () => {
|
||||
frontendUrl: 'http://localhost:3000',
|
||||
updateInterval: 60,
|
||||
autoUpdate: false,
|
||||
signupEnabled: false,
|
||||
githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
repositoryType: 'public',
|
||||
sshKeyPath: '',
|
||||
@@ -72,8 +73,6 @@ const Settings = () => {
|
||||
// Update form data when settings are loaded
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
console.log('Settings loaded:', settings);
|
||||
console.log('updateInterval from settings:', settings.update_interval);
|
||||
const newFormData = {
|
||||
serverProtocol: settings.server_protocol || 'http',
|
||||
serverHost: settings.server_host || 'localhost',
|
||||
@@ -81,12 +80,12 @@ const Settings = () => {
|
||||
frontendUrl: settings.frontend_url || 'http://localhost:3000',
|
||||
updateInterval: settings.update_interval || 60,
|
||||
autoUpdate: settings.auto_update || false,
|
||||
signupEnabled: settings.signup_enabled === true ? true : false, // Explicit boolean conversion
|
||||
githubRepoUrl: settings.github_repo_url || 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
repositoryType: settings.repository_type || 'public',
|
||||
sshKeyPath: settings.ssh_key_path || '',
|
||||
useCustomSshKey: !!settings.ssh_key_path
|
||||
};
|
||||
console.log('Setting form data to:', newFormData);
|
||||
setFormData(newFormData);
|
||||
setIsDirty(false);
|
||||
}
|
||||
@@ -95,34 +94,14 @@ const Settings = () => {
|
||||
// Update settings mutation
|
||||
const updateSettingsMutation = useMutation({
|
||||
mutationFn: (data) => {
|
||||
console.log('Mutation called with data:', data);
|
||||
return settingsAPI.update(data).then(res => {
|
||||
console.log('API response:', res);
|
||||
return res.data;
|
||||
});
|
||||
return settingsAPI.update(data).then(res => res.data);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
console.log('Mutation success:', data);
|
||||
console.log('Invalidating queries and updating form data');
|
||||
queryClient.invalidateQueries(['settings']);
|
||||
// Update form data with the returned data
|
||||
setFormData({
|
||||
serverProtocol: data.settings?.server_protocol || data.server_protocol || 'http',
|
||||
serverHost: data.settings?.server_host || data.server_host || 'localhost',
|
||||
serverPort: data.settings?.server_port || data.server_port || 3001,
|
||||
frontendUrl: data.settings?.frontend_url || data.frontend_url || 'http://localhost:3000',
|
||||
updateInterval: data.settings?.update_interval || data.update_interval || 60,
|
||||
autoUpdate: data.settings?.auto_update || data.auto_update || false,
|
||||
githubRepoUrl: data.settings?.github_repo_url || data.github_repo_url || 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
repositoryType: data.settings?.repository_type || data.repository_type || 'public',
|
||||
sshKeyPath: data.settings?.ssh_key_path || data.ssh_key_path || '',
|
||||
useCustomSshKey: !!(data.settings?.ssh_key_path || data.ssh_key_path)
|
||||
});
|
||||
setIsDirty(false);
|
||||
setErrors({});
|
||||
},
|
||||
onError: (error) => {
|
||||
console.log('Mutation error:', error);
|
||||
if (error.response?.data?.errors) {
|
||||
setErrors(error.response.data.errors.reduce((acc, err) => {
|
||||
acc[err.path] = err.msg;
|
||||
@@ -138,20 +117,12 @@ const Settings = () => {
|
||||
const { data: agentVersions, isLoading: agentVersionsLoading, error: agentVersionsError } = useQuery({
|
||||
queryKey: ['agentVersions'],
|
||||
queryFn: () => {
|
||||
console.log('Fetching agent versions...');
|
||||
return agentVersionAPI.list().then(res => {
|
||||
console.log('Agent versions API response:', res);
|
||||
return res.data;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Debug agent versions
|
||||
useEffect(() => {
|
||||
console.log('Agent versions data:', agentVersions);
|
||||
console.log('Agent versions loading:', agentVersionsLoading);
|
||||
console.log('Agent versions error:', agentVersionsError);
|
||||
}, [agentVersions, agentVersionsLoading, agentVersionsError]);
|
||||
|
||||
// Load current version on component mount
|
||||
useEffect(() => {
|
||||
@@ -213,7 +184,7 @@ const Settings = () => {
|
||||
currentVersion: data.currentVersion,
|
||||
latestVersion: data.latestVersion,
|
||||
isUpdateAvailable: data.isUpdateAvailable,
|
||||
lastUpdateCheck: data.lastUpdateCheck,
|
||||
last_update_check: data.last_update_check,
|
||||
checking: false,
|
||||
error: null
|
||||
});
|
||||
@@ -264,10 +235,8 @@ const Settings = () => {
|
||||
};
|
||||
|
||||
const handleInputChange = (field, value) => {
|
||||
console.log(`handleInputChange: ${field} = ${value}`);
|
||||
setFormData(prev => {
|
||||
const newData = { ...prev, [field]: value };
|
||||
console.log('New form data:', newData);
|
||||
return newData;
|
||||
});
|
||||
setIsDirty(true);
|
||||
@@ -316,10 +285,7 @@ const Settings = () => {
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
console.log('Saving settings:', formData);
|
||||
if (validateForm()) {
|
||||
console.log('Validation passed, calling mutation');
|
||||
|
||||
// Prepare data for submission
|
||||
const dataToSubmit = { ...formData };
|
||||
if (!dataToSubmit.useCustomSshKey) {
|
||||
@@ -328,10 +294,7 @@ const Settings = () => {
|
||||
// Remove the frontend-only field
|
||||
delete dataToSubmit.useCustomSshKey;
|
||||
|
||||
console.log('Submitting data with githubRepoUrl:', dataToSubmit.githubRepoUrl);
|
||||
updateSettingsMutation.mutate(dataToSubmit);
|
||||
} else {
|
||||
console.log('Validation failed:', errors);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -489,7 +452,6 @@ const Settings = () => {
|
||||
max="1440"
|
||||
value={formData.updateInterval}
|
||||
onChange={(e) => {
|
||||
console.log('Update interval input changed:', e.target.value);
|
||||
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 ${
|
||||
@@ -523,6 +485,24 @@ const Settings = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* User Signup Setting */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.signupEnabled}
|
||||
onChange={(e) => handleInputChange('signupEnabled', e.target.checked)}
|
||||
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
|
||||
/>
|
||||
Enable User Self-Registration
|
||||
</div>
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
When enabled, users can create their own accounts through the signup page. When disabled, only administrators can create user accounts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Security Notice */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
@@ -970,14 +950,14 @@ const Settings = () => {
|
||||
</div>
|
||||
|
||||
{/* Last Checked Time */}
|
||||
{versionInfo.lastUpdateCheck && (
|
||||
{versionInfo.last_update_check && (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Last Checked</span>
|
||||
</div>
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{new Date(versionInfo.lastUpdateCheck).toLocaleString()}
|
||||
{new Date(versionInfo.last_update_check).toLocaleString()}
|
||||
</span>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
|
||||
Updates are checked automatically every 24 hours
|
||||
|
@@ -60,6 +60,7 @@ export const adminHostsAPI = {
|
||||
create: (data) => api.post('/hosts/create', data),
|
||||
list: () => api.get('/hosts/admin/list'),
|
||||
delete: (hostId) => api.delete(`/hosts/${hostId}`),
|
||||
deleteBulk: (hostIds) => api.delete('/hosts/bulk', { data: { hostIds } }),
|
||||
regenerateCredentials: (hostId) => api.post(`/hosts/${hostId}/regenerate-credentials`),
|
||||
updateGroup: (hostId, hostGroupId) => api.put(`/hosts/${hostId}/group`, { hostGroupId }),
|
||||
bulkUpdateGroup: (hostIds, hostGroupId) => api.put('/hosts/bulk/group', { hostIds, hostGroupId }),
|
||||
|
Reference in New Issue
Block a user