improved table views and added more host information

This commit is contained in:
Muhammad Ibrahim
2025-09-20 10:56:59 +01:00
parent 216c9dbefa
commit adb207fef9
43 changed files with 6376 additions and 684 deletions
+128 -3
View File
@@ -1,12 +1,12 @@
#!/bin/bash
# PatchMon Agent Script
# PatchMon Agent Script v1.2.5
# This script sends package update information to the PatchMon server using API credentials
# Configuration
PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}"
API_VERSION="v1"
AGENT_VERSION="1.2.4"
AGENT_VERSION="1.2.5"
CONFIG_FILE="/etc/patchmon/agent.conf"
CREDENTIALS_FILE="/etc/patchmon/credentials"
LOG_FILE="/var/log/patchmon-agent.log"
@@ -656,6 +656,114 @@ get_yum_packages() {
done <<< "$installed"
}
# Get hardware information
get_hardware_info() {
local cpu_model=""
local cpu_cores=0
local ram_installed=0
local swap_size=0
local disk_details="[]"
# CPU Information
if command -v lscpu >/dev/null 2>&1; then
cpu_model=$(lscpu | grep "Model name" | cut -d':' -f2 | xargs)
cpu_cores=$(lscpu | grep "^CPU(s):" | cut -d':' -f2 | xargs)
elif [[ -f /proc/cpuinfo ]]; then
cpu_model=$(grep "model name" /proc/cpuinfo | head -1 | cut -d':' -f2 | xargs)
cpu_cores=$(grep -c "^processor" /proc/cpuinfo)
fi
# Memory Information
if command -v free >/dev/null 2>&1; then
ram_installed=$(free -g | grep "^Mem:" | awk '{print $2}')
swap_size=$(free -g | grep "^Swap:" | awk '{print $2}')
elif [[ -f /proc/meminfo ]]; then
ram_installed=$(grep "MemTotal" /proc/meminfo | awk '{print int($2/1024/1024)}')
swap_size=$(grep "SwapTotal" /proc/meminfo | awk '{print int($2/1024/1024)}')
fi
# Disk Information
if command -v lsblk >/dev/null 2>&1; then
disk_details=$(lsblk -J -o NAME,SIZE,TYPE,MOUNTPOINT | jq -c '[.blockdevices[] | select(.type == "disk") | {name: .name, size: .size, mountpoint: .mountpoint}]')
elif command -v df >/dev/null 2>&1; then
disk_details=$(df -h | grep -E "^/dev/" | awk '{print "{\"name\":\""$1"\",\"size\":\""$2"\",\"mountpoint\":\""$6"\"}"}' | jq -s .)
fi
echo "{\"cpuModel\":\"$cpu_model\",\"cpuCores\":$cpu_cores,\"ramInstalled\":$ram_installed,\"swapSize\":$swap_size,\"diskDetails\":$disk_details}"
}
# Get network information
get_network_info() {
local gateway_ip=""
local dns_servers="[]"
local network_interfaces="[]"
# Gateway IP
if command -v ip >/dev/null 2>&1; then
gateway_ip=$(ip route | grep default | head -1 | awk '{print $3}')
elif command -v route >/dev/null 2>&1; then
gateway_ip=$(route -n | grep '^0.0.0.0' | head -1 | awk '{print $2}')
fi
# DNS Servers
if [[ -f /etc/resolv.conf ]]; then
dns_servers=$(grep "nameserver" /etc/resolv.conf | awk '{print $2}' | jq -R . | jq -s .)
fi
# Network Interfaces
if command -v ip >/dev/null 2>&1; then
network_interfaces=$(ip -j addr show | jq -c '[.[] | {name: .ifname, type: .link_type, addresses: [.addr_info[]? | {address: .local, family: .family}]}]')
elif command -v ifconfig >/dev/null 2>&1; then
network_interfaces=$(ifconfig -a | grep -E "^[a-zA-Z]" | awk '{print $1}' | jq -R . | jq -s .)
fi
echo "{\"gatewayIp\":\"$gateway_ip\",\"dnsServers\":$dns_servers,\"networkInterfaces\":$network_interfaces}"
}
# Get system information
get_system_info() {
local kernel_version=""
local selinux_status=""
local system_uptime=""
local load_average="[]"
# Kernel Version
if [[ -f /proc/version ]]; then
kernel_version=$(cat /proc/version | awk '{print $3}')
elif command -v uname >/dev/null 2>&1; then
kernel_version=$(uname -r)
fi
# SELinux Status
if command -v getenforce >/dev/null 2>&1; then
selinux_status=$(getenforce 2>/dev/null | tr '[:upper:]' '[:lower:]')
elif [[ -f /etc/selinux/config ]]; then
selinux_status=$(grep "^SELINUX=" /etc/selinux/config | cut -d'=' -f2 | tr '[:upper:]' '[:lower:]')
else
selinux_status="disabled"
fi
# System Uptime
if [[ -f /proc/uptime ]]; then
local uptime_seconds=$(cat /proc/uptime | awk '{print int($1)}')
local days=$((uptime_seconds / 86400))
local hours=$(((uptime_seconds % 86400) / 3600))
local minutes=$(((uptime_seconds % 3600) / 60))
system_uptime="${days}d ${hours}h ${minutes}m"
elif command -v uptime >/dev/null 2>&1; then
system_uptime=$(uptime | awk -F'up ' '{print $2}' | awk -F', load' '{print $1}')
fi
# Load Average
if [[ -f /proc/loadavg ]]; then
load_average=$(cat /proc/loadavg | awk '{print "["$1","$2","$3"]"}')
elif command -v uptime >/dev/null 2>&1; then
load_average=$(uptime | awk -F'load average: ' '{print "["$2"]"}' | tr -d ' ')
fi
echo "{\"kernelVersion\":\"$kernel_version\",\"selinuxStatus\":\"$selinux_status\",\"systemUptime\":\"$system_uptime\",\"loadAverage\":$load_average}"
}
# Send package update to server
send_update() {
load_credentials
@@ -666,14 +774,27 @@ send_update() {
info "Collecting repository information..."
local repositories_json=$(get_repository_info)
info "Collecting hardware information..."
local hardware_json=$(get_hardware_info)
info "Collecting network information..."
local network_json=$(get_network_info)
info "Collecting system information..."
local system_json=$(get_system_info)
info "Sending update to PatchMon server..."
local payload=$(cat <<EOF
# Merge all JSON objects into one
local merged_json=$(echo "$hardware_json $network_json $system_json" | jq -s '.[0] * .[1] * .[2]')
# Create the base payload and merge with system info
local base_payload=$(cat <<EOF
{
"packages": $packages_json,
"repositories": $repositories_json,
"osType": "$OS_TYPE",
"osVersion": "$OS_VERSION",
"hostname": "$HOSTNAME",
"ip": "$IP_ADDRESS",
"architecture": "$ARCHITECTURE",
"agentVersion": "$AGENT_VERSION"
@@ -681,6 +802,10 @@ send_update() {
EOF
)
# Merge the base payload with the system information
local payload=$(echo "$base_payload $merged_json" | jq -s '.[0] * .[1]')
local response=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "X-API-ID: $API_ID" \
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
+1
View File
@@ -0,0 +1 @@
+67
View File
@@ -0,0 +1,67 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function checkAgentVersion() {
try {
// Check current agent version in database
const agentVersion = await prisma.agentVersion.findFirst({
where: { version: '1.2.5' }
});
if (agentVersion) {
console.log('✅ Agent version 1.2.5 found in database');
console.log('Version:', agentVersion.version);
console.log('Is Default:', agentVersion.isDefault);
console.log('Script Content Length:', agentVersion.scriptContent?.length || 0);
console.log('Created At:', agentVersion.createdAt);
console.log('Updated At:', agentVersion.updatedAt);
// Check if script content contains the current version
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('AGENT_VERSION="1.2.5"')) {
console.log('✅ Script content contains correct version 1.2.5');
} else {
console.log('❌ Script content does not contain version 1.2.5');
}
// Check if script content contains system info functions
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('get_hardware_info()')) {
console.log('✅ Script content contains hardware info function');
} else {
console.log('❌ Script content missing hardware info function');
}
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('get_network_info()')) {
console.log('✅ Script content contains network info function');
} else {
console.log('❌ Script content missing network info function');
}
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('get_system_info()')) {
console.log('✅ Script content contains system info function');
} else {
console.log('❌ Script content missing system info function');
}
} else {
console.log('❌ Agent version 1.2.5 not found in database');
}
// List all agent versions
console.log('\n=== All Agent Versions ===');
const allVersions = await prisma.agentVersion.findMany({
orderBy: { createdAt: 'desc' }
});
allVersions.forEach(version => {
console.log(`Version: ${version.version}, Default: ${version.isDefault}, Length: ${version.scriptContent?.length || 0}`);
});
} catch (error) {
console.error('❌ Error checking agent version:', error);
} finally {
await prisma.$disconnect();
}
}
checkAgentVersion();
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
@@ -0,0 +1,2 @@
-- RenameIndex
ALTER INDEX "hosts_hostname_key" RENAME TO "hosts_friendly_name_key";
@@ -0,0 +1,2 @@
-- Rename hostname column to friendly_name in hosts table
ALTER TABLE "hosts" RENAME COLUMN "hostname" TO "friendly_name";
@@ -0,0 +1,14 @@
-- AlterTable
ALTER TABLE "hosts" ADD COLUMN "cpu_cores" INTEGER,
ADD COLUMN "cpu_model" TEXT,
ADD COLUMN "disk_details" JSONB,
ADD COLUMN "dns_servers" JSONB,
ADD COLUMN "gateway_ip" TEXT,
ADD COLUMN "hostname" TEXT,
ADD COLUMN "kernel_version" TEXT,
ADD COLUMN "load_average" JSONB,
ADD COLUMN "network_interfaces" JSONB,
ADD COLUMN "ram_installed" INTEGER,
ADD COLUMN "selinux_status" TEXT,
ADD COLUMN "swap_size" INTEGER,
ADD COLUMN "system_uptime" TEXT;
+21 -1
View File
@@ -67,7 +67,8 @@ model HostGroup {
model Host {
id String @id @default(cuid())
hostname String @unique
friendlyName String @unique @map("friendly_name")
hostname String? // Actual system hostname from agent
ip String?
osType String @map("os_type")
osVersion String @map("os_version")
@@ -79,6 +80,25 @@ model Host {
hostGroupId String? @map("host_group_id") // Optional group association
agentVersion String? @map("agent_version") // Agent script version
autoUpdate Boolean @map("auto_update") @default(true) // Enable auto-update for this host
// Hardware Information
cpuModel String? @map("cpu_model") // CPU model name
cpuCores Int? @map("cpu_cores") // Number of CPU cores
ramInstalled Int? @map("ram_installed") // RAM in GB
swapSize Int? @map("swap_size") // Swap size in GB
diskDetails Json? @map("disk_details") // Array of disk objects
// Network Information
gatewayIp String? @map("gateway_ip") // Gateway IP address
dnsServers Json? @map("dns_servers") // Array of DNS servers
networkInterfaces Json? @map("network_interfaces") // Array of network interface objects
// System Information
kernelVersion String? @map("kernel_version") // Kernel version
selinuxStatus String? @map("selinux_status") // SELinux status (enabled/disabled/permissive)
systemUptime String? @map("system_uptime") // System uptime
loadAverage Json? @map("load_average") // Load average (1min, 5min, 15min)
createdAt DateTime @map("created_at") @default(now())
updatedAt DateTime @map("updated_at") @updatedAt
+80
View File
@@ -0,0 +1,80 @@
/**
* Database configuration for multiple instances
* Optimizes connection pooling to prevent "too many connections" errors
*/
const { PrismaClient } = require('@prisma/client');
// Parse DATABASE_URL and add connection pooling parameters
function getOptimizedDatabaseUrl() {
const originalUrl = process.env.DATABASE_URL;
if (!originalUrl) {
throw new Error('DATABASE_URL environment variable is required');
}
// Parse the URL
const url = new URL(originalUrl);
// Add connection pooling parameters for multiple instances
url.searchParams.set('connection_limit', '5'); // Reduced from default 10
url.searchParams.set('pool_timeout', '10'); // 10 seconds
url.searchParams.set('connect_timeout', '10'); // 10 seconds
url.searchParams.set('idle_timeout', '300'); // 5 minutes
url.searchParams.set('max_lifetime', '1800'); // 30 minutes
return url.toString();
}
// Create optimized Prisma client
function createPrismaClient() {
const optimizedUrl = getOptimizedDatabaseUrl();
return new PrismaClient({
datasources: {
db: {
url: optimizedUrl
}
},
log: process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['warn', 'error'],
errorFormat: 'pretty'
});
}
// Connection health check
async function checkDatabaseConnection(prisma) {
try {
await prisma.$queryRaw`SELECT 1`;
return true;
} catch (error) {
console.error('Database connection failed:', error.message);
return false;
}
}
// Graceful disconnect with retry
async function disconnectPrisma(prisma, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
await prisma.$disconnect();
console.log('Database disconnected successfully');
return;
} catch (error) {
console.error(`Disconnect attempt ${i + 1} failed:`, error.message);
if (i === maxRetries - 1) {
console.error('Failed to disconnect from database after all retries');
} else {
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
}
}
}
}
module.exports = {
createPrismaClient,
checkDatabaseConnection,
disconnectPrisma,
getOptimizedDatabaseUrl
};
+11 -2
View File
@@ -162,6 +162,7 @@ router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
// Show all hosts regardless of status
select: {
id: true,
friendlyName: true,
hostname: true,
ip: true,
osType: true,
@@ -200,6 +201,13 @@ router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
}
});
// Get total packages count for this host
const totalPackagesCount = await prisma.hostPackage.count({
where: {
hostId: host.id
}
});
// Get the agent update interval setting for stale calculation
const settings = await prisma.settings.findFirst();
const updateIntervalMinutes = settings?.updateInterval || 60;
@@ -217,6 +225,7 @@ router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
return {
...host,
updatesCount,
totalPackagesCount,
isStale,
effectiveStatus
};
@@ -256,7 +265,7 @@ router.get('/packages', authenticateToken, requireViewPackages, async (req, res)
host: {
select: {
id: true,
hostname: true,
friendlyName: true,
osType: true
}
}
@@ -278,7 +287,7 @@ router.get('/packages', authenticateToken, requireViewPackages, async (req, res)
isSecurityUpdate: pkg.hostPackages.some(hp => hp.isSecurityUpdate),
affectedHosts: pkg.hostPackages.map(hp => ({
hostId: hp.host.id,
hostname: hp.host.hostname,
friendlyName: hp.host.friendlyName,
osType: hp.host.osType,
currentVersion: hp.currentVersion,
availableVersion: hp.availableVersion,
+3 -2
View File
@@ -41,6 +41,7 @@ router.get('/:id', authenticateToken, async (req, res) => {
hosts: {
select: {
id: true,
friendlyName: true,
hostname: true,
ip: true,
osType: true,
@@ -201,7 +202,7 @@ router.get('/:id/hosts', authenticateToken, async (req, res) => {
where: { hostGroupId: id },
select: {
id: true,
hostname: true,
friendlyName: true,
ip: true,
osType: true,
osVersion: true,
@@ -211,7 +212,7 @@ router.get('/:id/hosts', authenticateToken, async (req, res) => {
createdAt: true
},
orderBy: {
hostname: 'asc'
friendlyName: 'asc'
}
});
+123 -12
View File
@@ -133,7 +133,7 @@ const validateApiCredentials = async (req, res, next) => {
// Admin endpoint to create a new host manually (replaces auto-registration)
router.post('/create', authenticateToken, requireManageHosts, [
body('hostname').isLength({ min: 1 }).withMessage('Hostname is required'),
body('friendlyName').isLength({ min: 1 }).withMessage('Friendly name is required'),
body('hostGroupId').optional()
], async (req, res) => {
try {
@@ -142,14 +142,14 @@ router.post('/create', authenticateToken, requireManageHosts, [
return res.status(400).json({ errors: errors.array() });
}
const { hostname, hostGroupId } = req.body;
const { friendlyName, hostGroupId } = req.body;
// Generate unique API credentials for this host
const { apiId, apiKey } = generateApiCredentials();
// Check if host already exists
const existingHost = await prisma.host.findUnique({
where: { hostname }
where: { friendlyName }
});
if (existingHost) {
@@ -170,7 +170,7 @@ router.post('/create', authenticateToken, requireManageHosts, [
// Create new host with API credentials - system info will be populated when agent connects
const host = await prisma.host.create({
data: {
hostname,
friendlyName,
osType: 'unknown', // Will be updated when agent connects
osVersion: 'unknown', // Will be updated when agent connects
ip: null, // Will be updated when agent connects
@@ -194,7 +194,7 @@ router.post('/create', authenticateToken, requireManageHosts, [
res.status(201).json({
message: 'Host created successfully',
hostId: host.id,
hostname: host.hostname,
friendlyName: host.friendlyName,
apiId: host.apiId,
apiKey: host.apiKey,
hostGroup: host.hostGroup,
@@ -223,7 +223,22 @@ router.post('/update', validateApiCredentials, [
body('packages.*.availableVersion').optional().isLength({ min: 1 }),
body('packages.*.needsUpdate').isBoolean().withMessage('needsUpdate must be boolean'),
body('packages.*.isSecurityUpdate').optional().isBoolean().withMessage('isSecurityUpdate must be boolean'),
body('agentVersion').optional().isLength({ min: 1 }).withMessage('Agent version must be a non-empty string')
body('agentVersion').optional().isLength({ min: 1 }).withMessage('Agent version must be a non-empty string'),
// Hardware Information
body('cpuModel').optional().isString().withMessage('CPU model must be a string'),
body('cpuCores').optional().isInt({ min: 1 }).withMessage('CPU cores must be a positive integer'),
body('ramInstalled').optional().isInt({ min: 1 }).withMessage('RAM installed must be a positive integer'),
body('swapSize').optional().isInt({ min: 0 }).withMessage('Swap size must be a non-negative integer'),
body('diskDetails').optional().isArray().withMessage('Disk details must be an array'),
// Network Information
body('gatewayIp').optional().isIP().withMessage('Gateway IP must be a valid IP address'),
body('dnsServers').optional().isArray().withMessage('DNS servers must be an array'),
body('networkInterfaces').optional().isArray().withMessage('Network interfaces must be an array'),
// System Information
body('kernelVersion').optional().isString().withMessage('Kernel version must be a string'),
body('selinuxStatus').optional().isIn(['enabled', 'disabled', 'permissive']).withMessage('SELinux status must be enabled, disabled, or permissive'),
body('systemUptime').optional().isString().withMessage('System uptime must be a string'),
body('loadAverage').optional().isArray().withMessage('Load average must be an array')
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -234,14 +249,35 @@ router.post('/update', validateApiCredentials, [
const { packages, repositories } = req.body;
const host = req.hostRecord;
// Update host last update timestamp and OS info if provided
// Update host last update timestamp and system info if provided
const updateData = { lastUpdate: new Date() };
// Basic system info
if (req.body.osType) updateData.osType = req.body.osType;
if (req.body.osVersion) updateData.osVersion = req.body.osVersion;
if (req.body.hostname) updateData.hostname = req.body.hostname;
if (req.body.ip) updateData.ip = req.body.ip;
if (req.body.architecture) updateData.architecture = req.body.architecture;
if (req.body.agentVersion) updateData.agentVersion = req.body.agentVersion;
// Hardware Information
if (req.body.cpuModel) updateData.cpuModel = req.body.cpuModel;
if (req.body.cpuCores) updateData.cpuCores = req.body.cpuCores;
if (req.body.ramInstalled) updateData.ramInstalled = req.body.ramInstalled;
if (req.body.swapSize !== undefined) updateData.swapSize = req.body.swapSize;
if (req.body.diskDetails) updateData.diskDetails = req.body.diskDetails;
// Network Information
if (req.body.gatewayIp) updateData.gatewayIp = req.body.gatewayIp;
if (req.body.dnsServers) updateData.dnsServers = req.body.dnsServers;
if (req.body.networkInterfaces) updateData.networkInterfaces = req.body.networkInterfaces;
// System Information
if (req.body.kernelVersion) updateData.kernelVersion = req.body.kernelVersion;
if (req.body.selinuxStatus) updateData.selinuxStatus = req.body.selinuxStatus;
if (req.body.systemUptime) updateData.systemUptime = req.body.systemUptime;
if (req.body.loadAverage) updateData.loadAverage = req.body.loadAverage;
// If this is the first update (status is 'pending'), change to 'active'
if (host.status === 'pending') {
updateData.status = 'active';
@@ -454,6 +490,7 @@ router.get('/info', validateApiCredentials, async (req, res) => {
where: { id: req.hostRecord.id },
select: {
id: true,
friendlyName: true,
hostname: true,
ip: true,
osType: true,
@@ -485,12 +522,12 @@ router.post('/ping', validateApiCredentials, async (req, res) => {
const response = {
message: 'Ping successful',
timestamp: new Date().toISOString(),
hostname: req.hostRecord.hostname
friendlyName: req.hostRecord.friendlyName
};
// Check if this is a crontab update trigger
if (req.body.triggerCrontabUpdate && req.hostRecord.autoUpdate) {
console.log(`Triggering crontab update for host: ${req.hostRecord.hostname}`);
console.log(`Triggering crontab update for host: ${req.hostRecord.friendlyName}`);
response.crontabUpdate = {
shouldUpdate: true,
message: 'Update interval changed, please run: /usr/local/bin/patchmon-agent.sh update-crontab',
@@ -568,7 +605,7 @@ router.put('/bulk/group', authenticateToken, requireManageHosts, [
// Check if all hosts exist
const existingHosts = await prisma.host.findMany({
where: { id: { in: hostIds } },
select: { id: true, hostname: true }
select: { id: true, friendlyName: true }
});
if (existingHosts.length !== hostIds.length) {
@@ -593,7 +630,7 @@ router.put('/bulk/group', authenticateToken, requireManageHosts, [
where: { id: { in: hostIds } },
select: {
id: true,
hostname: true,
friendlyName: true,
hostGroup: {
select: {
id: true,
@@ -681,6 +718,7 @@ router.get('/admin/list', authenticateToken, requireManageHosts, async (req, res
const hosts = await prisma.host.findMany({
select: {
id: true,
friendlyName: true,
hostname: true,
ip: true,
osType: true,
@@ -742,7 +780,7 @@ router.patch('/:hostId/auto-update', authenticateToken, requireManageHosts, [
message: `Host auto-update ${autoUpdate ? 'enabled' : 'disabled'} successfully`,
host: {
id: host.id,
hostname: host.hostname,
friendlyName: host.friendlyName,
autoUpdate: host.autoUpdate
}
});
@@ -934,4 +972,77 @@ router.delete('/agent/versions/:versionId', authenticateToken, requireManageSett
}
});
// Update host friendly name (admin only)
router.patch('/:hostId/friendly-name', authenticateToken, requireManageHosts, [
body('friendlyName').isLength({ min: 1, max: 100 }).withMessage('Friendly name must be between 1 and 100 characters')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { hostId } = req.params;
const { friendlyName } = req.body;
// Check if host exists
const host = await prisma.host.findUnique({
where: { id: hostId }
});
if (!host) {
return res.status(404).json({ error: 'Host not found' });
}
// Check if friendly name is already taken by another host
const existingHost = await prisma.host.findFirst({
where: {
friendlyName: friendlyName,
id: { not: hostId }
}
});
if (existingHost) {
return res.status(400).json({ error: 'Friendly name is already taken by another host' });
}
// Update the friendly name
const updatedHost = await prisma.host.update({
where: { id: hostId },
data: { friendlyName },
select: {
id: true,
friendlyName: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
architecture: true,
lastUpdate: true,
status: true,
hostGroupId: true,
agentVersion: true,
autoUpdate: true,
createdAt: true,
updatedAt: true,
hostGroup: {
select: {
id: true,
name: true,
color: true
}
}
}
});
res.json({
message: 'Friendly name updated successfully',
host: updatedHost
});
} catch (error) {
console.error('Update friendly name error:', error);
res.status(500).json({ error: 'Failed to update friendly name' });
}
});
module.exports = router;
+1
View File
@@ -100,6 +100,7 @@ router.get('/', async (req, res) => {
host: {
select: {
id: true,
friendlyName: true,
hostname: true,
osType: true
}
+7 -6
View File
@@ -17,7 +17,7 @@ router.get('/', authenticateToken, requireViewHosts, async (req, res) => {
host: {
select: {
id: true,
hostname: true,
friendlyName: true,
status: true
}
}
@@ -43,7 +43,7 @@ router.get('/', authenticateToken, requireViewHosts, async (req, res) => {
activeHostCount: repo.hostRepositories.filter(hr => hr.host.status === 'active').length,
hosts: repo.hostRepositories.map(hr => ({
id: hr.host.id,
hostname: hr.host.hostname,
friendlyName: hr.host.friendlyName,
status: hr.host.status,
isEnabled: hr.isEnabled,
lastChecked: hr.lastChecked
@@ -69,7 +69,7 @@ router.get('/host/:hostId', authenticateToken, requireViewHosts, async (req, res
host: {
select: {
id: true,
hostname: true
friendlyName: true
}
}
},
@@ -100,6 +100,7 @@ router.get('/:repositoryId', authenticateToken, requireViewHosts, async (req, re
host: {
select: {
id: true,
friendlyName: true,
hostname: true,
ip: true,
osType: true,
@@ -111,7 +112,7 @@ router.get('/:repositoryId', authenticateToken, requireViewHosts, async (req, re
},
orderBy: {
host: {
hostname: 'asc'
friendlyName: 'asc'
}
}
}
@@ -197,14 +198,14 @@ router.patch('/host/:hostId/repository/:repositoryId', authenticateToken, requir
repository: true,
host: {
select: {
hostname: true
friendlyName: true
}
}
}
});
res.json({
message: `Repository ${isEnabled ? 'enabled' : 'disabled'} for host ${hostRepository.host.hostname}`,
message: `Repository ${isEnabled ? 'enabled' : 'disabled'} for host ${hostRepository.host.friendlyName}`,
hostRepository
});
} catch (error) {
+6 -6
View File
@@ -20,7 +20,7 @@ async function triggerCrontabUpdates() {
},
select: {
id: true,
hostname: true,
friendlyName: true,
apiId: true,
apiKey: true
}
@@ -32,7 +32,7 @@ async function triggerCrontabUpdates() {
// This is done by sending a ping with a special flag
for (const host of hosts) {
try {
console.log(`Triggering crontab update for host: ${host.hostname}`);
console.log(`Triggering crontab update for host: ${host.friendlyName}`);
// We'll use the existing ping endpoint but add a special parameter
// The agent will detect this and run update-crontab command
@@ -64,20 +64,20 @@ async function triggerCrontabUpdates() {
const req = client.request(options, (res) => {
if (res.statusCode === 200) {
console.log(`Successfully triggered crontab update for ${host.hostname}`);
console.log(`Successfully triggered crontab update for ${host.friendlyName}`);
} else {
console.error(`Failed to trigger crontab update for ${host.hostname}: ${res.statusCode}`);
console.error(`Failed to trigger crontab update for ${host.friendlyName}: ${res.statusCode}`);
}
});
req.on('error', (error) => {
console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
console.error(`Error triggering crontab update for ${host.friendlyName}:`, error.message);
});
req.write(postData);
req.end();
} catch (error) {
console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
console.error(`Error triggering crontab update for ${host.friendlyName}:`, error.message);
}
}
+40 -20
View File
@@ -3,7 +3,7 @@ const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const { PrismaClient } = require('@prisma/client');
const { createPrismaClient, checkDatabaseConnection, disconnectPrisma } = require('./config/database');
const winston = require('winston');
// Import routes
@@ -20,8 +20,8 @@ const versionRoutes = require('./routes/versionRoutes');
const tfaRoutes = require('./routes/tfaRoutes');
const updateScheduler = require('./services/updateScheduler');
// Initialize Prisma client
const prisma = new PrismaClient();
// Initialize Prisma client with optimized connection pooling for multiple instances
const prisma = createPrismaClient();
// Initialize logger - only if logging is enabled
const logger = process.env.ENABLE_LOGGING === 'true' ? winston.createLogger({
@@ -157,33 +157,53 @@ app.use('*', (req, res) => {
});
// Graceful shutdown
process.on('SIGTERM', async () => {
if (process.env.ENABLE_LOGGING === 'true') {
logger.info('SIGTERM received, shutting down gracefully');
}
updateScheduler.stop();
await prisma.$disconnect();
process.exit(0);
});
process.on('SIGINT', async () => {
if (process.env.ENABLE_LOGGING === 'true') {
logger.info('SIGINT received, shutting down gracefully');
}
updateScheduler.stop();
await prisma.$disconnect();
await disconnectPrisma(prisma);
process.exit(0);
});
// Start server
app.listen(PORT, () => {
process.on('SIGTERM', async () => {
if (process.env.ENABLE_LOGGING === 'true') {
logger.info(`Server running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV}`);
logger.info('SIGTERM received, shutting down gracefully');
}
// Start update scheduler
updateScheduler.start();
updateScheduler.stop();
await disconnectPrisma(prisma);
process.exit(0);
});
// Start server with database health check
async function startServer() {
try {
// Check database connection before starting server
const isConnected = await checkDatabaseConnection(prisma);
if (!isConnected) {
console.error('❌ Database connection failed. Server not started.');
process.exit(1);
}
if (process.env.ENABLE_LOGGING === 'true') {
logger.info('✅ Database connection successful');
}
app.listen(PORT, () => {
if (process.env.ENABLE_LOGGING === 'true') {
logger.info(`Server running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV}`);
}
// Start update scheduler
updateScheduler.start();
});
} catch (error) {
console.error('❌ Failed to start server:', error.message);
process.exit(1);
}
}
startServer();
module.exports = app;
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -24,6 +24,7 @@
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.20.1"
},
"devDependencies": {
+157
View File
@@ -0,0 +1,157 @@
import React, { useState, useRef, useEffect } from 'react';
import { Edit2, Check, X } from 'lucide-react';
import { Link } from 'react-router-dom';
const InlineEdit = ({
value,
onSave,
onCancel,
placeholder = "Enter value...",
maxLength = 100,
className = "",
disabled = false,
validate = null,
linkTo = null
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(value);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const inputRef = useRef(null);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
useEffect(() => {
setEditValue(value);
}, [value]);
const handleEdit = () => {
if (disabled) return;
setIsEditing(true);
setEditValue(value);
setError('');
};
const handleCancel = () => {
setIsEditing(false);
setEditValue(value);
setError('');
if (onCancel) onCancel();
};
const handleSave = async () => {
if (disabled || isLoading) return;
// Validate if validator function provided
if (validate) {
const validationError = validate(editValue);
if (validationError) {
setError(validationError);
return;
}
}
// Check if value actually changed
if (editValue.trim() === value.trim()) {
setIsEditing(false);
return;
}
setIsLoading(true);
setError('');
try {
await onSave(editValue.trim());
setIsEditing(false);
} catch (err) {
setError(err.message || 'Failed to save');
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
};
if (isEditing) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
maxLength={maxLength}
disabled={isLoading}
className={`flex-1 px-2 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${
error ? 'border-red-500' : ''
} ${isLoading ? 'opacity-50' : ''}`}
/>
<button
onClick={handleSave}
disabled={isLoading || editValue.trim() === ''}
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Save"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={handleCancel}
disabled={isLoading}
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
{error && (
<span className="text-xs text-red-600 dark:text-red-400">{error}</span>
)}
</div>
);
}
const displayValue = linkTo ? (
<Link
to={linkTo}
className="text-sm font-medium text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 cursor-pointer transition-colors"
title="View details"
>
{value}
</Link>
) : (
<span className="text-sm font-medium text-secondary-900 dark:text-white">
{value}
</span>
);
return (
<div className={`flex items-center gap-2 group ${className}`}>
{displayValue}
{!disabled && (
<button
onClick={handleEdit}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
title="Edit"
>
<Edit2 className="h-3 w-3" />
</button>
)}
</div>
);
};
export default InlineEdit;
+262
View File
@@ -0,0 +1,262 @@
import React, { useState, useRef, useEffect } from 'react';
import { Edit2, Check, X, ChevronDown } from 'lucide-react';
const InlineGroupEdit = ({
value,
onSave,
onCancel,
options = [],
className = "",
disabled = false,
placeholder = "Select group..."
}) => {
const [isEditing, setIsEditing] = useState(false);
const [selectedValue, setSelectedValue] = useState(value);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
const dropdownRef = useRef(null);
const buttonRef = useRef(null);
useEffect(() => {
if (isEditing && dropdownRef.current) {
dropdownRef.current.focus();
}
}, [isEditing]);
useEffect(() => {
setSelectedValue(value);
// Force re-render when value changes
if (!isEditing) {
setIsOpen(false);
}
}, [value, isEditing]);
// Calculate dropdown position
const calculateDropdownPosition = () => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX,
width: rect.width
});
}
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
if (isOpen) {
calculateDropdownPosition();
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('resize', calculateDropdownPosition);
window.addEventListener('scroll', calculateDropdownPosition);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('resize', calculateDropdownPosition);
window.removeEventListener('scroll', calculateDropdownPosition);
};
}
}, [isOpen]);
const handleEdit = () => {
if (disabled) return;
setIsEditing(true);
setSelectedValue(value);
setError('');
// Automatically open dropdown when editing starts
setTimeout(() => {
setIsOpen(true);
}, 0);
};
const handleCancel = () => {
setIsEditing(false);
setSelectedValue(value);
setError('');
setIsOpen(false);
if (onCancel) onCancel();
};
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;
}
setIsLoading(true);
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);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
};
const getDisplayValue = () => {
console.log('getDisplayValue called with:', { value, options });
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';
};
const getDisplayColor = () => {
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';
};
if (isEditing) {
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<button
ref={buttonRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
disabled={isLoading}
className={`w-full px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent flex items-center justify-between ${
error ? 'border-red-500' : ''
} ${isLoading ? 'opacity-50' : ''}`}
>
<span className="truncate">
{selectedValue ? options.find(opt => opt.id === selectedValue)?.name || 'Unknown Group' : 'Ungrouped'}
</span>
<ChevronDown className="h-4 w-4 flex-shrink-0" />
</button>
{isOpen && (
<div
className="fixed z-50 bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-lg max-h-60 overflow-auto"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
minWidth: '200px'
}}
>
<div className="py-1">
<button
type="button"
onClick={() => {
setSelectedValue(null);
setIsOpen(false);
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
selectedValue === null ? 'bg-primary-50 dark:bg-primary-900/20' : ''
}`}
>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
Ungrouped
</span>
</button>
{options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
setSelectedValue(option.id);
setIsOpen(false);
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
selectedValue === option.id ? 'bg-primary-50 dark:bg-primary-900/20' : ''
}`}
>
<span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: option.color }}
>
{option.name}
</span>
</button>
))}
</div>
</div>
)}
</div>
<button
onClick={handleSave}
disabled={isLoading}
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Save"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={handleCancel}
disabled={isLoading}
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
</div>
{error && (
<span className="text-xs text-red-600 dark:text-red-400 mt-1 block">{error}</span>
)}
</div>
);
}
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 } : {}}
>
{getDisplayValue()}
</span>
{!disabled && (
<button
onClick={handleEdit}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
title="Edit group"
>
<Edit2 className="h-3 w-3" />
</button>
)}
</div>
);
};
export default InlineGroupEdit;
+14 -9
View File
@@ -20,7 +20,10 @@ import {
GitBranch,
Wrench,
Container,
Plus
Plus,
Activity,
Cog,
FileText
} from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
@@ -65,7 +68,7 @@ const Layout = ({ children }) => {
...(canViewHosts() ? [{ name: 'Hosts', href: '/hosts', icon: Server }] : []),
...(canViewPackages() ? [{ name: 'Packages', href: '/packages', icon: Package }] : []),
...(canViewHosts() ? [{ name: 'Repos', href: '/repositories', icon: GitBranch }] : []),
{ name: 'Services', href: '/services', icon: Wrench, comingSoon: true },
{ name: 'Services', href: '/services', icon: Activity, comingSoon: true },
{ name: 'Docker', href: '/docker', icon: Container, comingSoon: true },
{ name: 'Reporting', href: '/reporting', icon: BarChart3, comingSoon: true },
]
@@ -80,17 +83,18 @@ const Layout = ({ children }) => {
{
section: 'Settings',
items: [
...(canManageHosts() ? [{
name: 'PatchMon Options',
href: '/options',
icon: Settings
}] : []),
{ name: 'Audit Log', href: '/audit-log', icon: FileText, comingSoon: true },
...(canManageSettings() ? [{
name: 'Server Config',
href: '/settings',
icon: Settings,
icon: Wrench,
showUpgradeIcon: updateAvailable
}] : []),
...(canManageHosts() ? [{
name: 'Options',
href: '/options',
icon: Settings
}] : []),
]
}
]
@@ -110,7 +114,8 @@ const Layout = ({ children }) => {
if (path === '/users') return 'Users'
if (path === '/permissions') return 'Permissions'
if (path === '/settings') return 'Settings'
if (path === '/options') return 'Options'
if (path === '/options') return 'PatchMon Options'
if (path === '/audit-log') return 'Audit Log'
if (path === '/profile') return 'My Profile'
if (path.startsWith('/hosts/')) return 'Host Details'
if (path.startsWith('/packages/')) return 'Package Details'
+3 -3
View File
@@ -28,7 +28,7 @@ const Dashboard = () => {
// Navigation handlers
const handleTotalHostsClick = () => {
navigate('/hosts')
navigate('/hosts', { replace: true })
}
const handleHostsNeedingUpdatesClick = () => {
@@ -52,11 +52,11 @@ const Dashboard = () => {
}
const handleOSDistributionClick = () => {
navigate('/hosts')
navigate('/hosts', { replace: true })
}
const handleUpdateStatusClick = () => {
navigate('/hosts')
navigate('/hosts', { replace: true })
}
const handlePackagePriorityClick = () => {
+514 -208
View File
@@ -23,9 +23,19 @@ import {
ToggleLeft,
ToggleRight,
Edit,
Check
Check,
ChevronDown,
ChevronUp,
Cpu,
MemoryStick,
Globe,
Wifi,
Terminal,
Activity
} from 'lucide-react'
import { dashboardAPI, adminHostsAPI, settingsAPI, formatRelativeTime, formatDate } from '../utils/api'
import { OSIcon } from '../utils/osIcons.jsx'
import InlineEdit from '../components/InlineEdit'
const HostDetail = () => {
const { hostId } = useParams()
@@ -33,8 +43,9 @@ const HostDetail = () => {
const queryClient = useQueryClient()
const [showCredentialsModal, setShowCredentialsModal] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [isEditingHostname, setIsEditingHostname] = useState(false)
const [editedHostname, setEditedHostname] = useState('')
const [isEditingFriendlyName, setIsEditingFriendlyName] = useState(false)
const [editedFriendlyName, setEditedFriendlyName] = useState('')
const [showAllUpdates, setShowAllUpdates] = useState(false)
const { data: host, isLoading, error, refetch } = useQuery({
queryKey: ['host', hostId],
@@ -67,8 +78,16 @@ const HostDetail = () => {
}
})
const updateFriendlyNameMutation = useMutation({
mutationFn: (friendlyName) => adminHostsAPI.updateFriendlyName(hostId, friendlyName).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries(['host', hostId])
queryClient.invalidateQueries(['hosts'])
}
})
const handleDeleteHost = async () => {
if (window.confirm(`Are you sure you want to delete host "${host.hostname}"? This action cannot be undone.`)) {
if (window.confirm(`Are you sure you want to delete host "${host.friendlyName}"? This action cannot be undone.`)) {
try {
await deleteHostMutation.mutateAsync(hostId)
} catch (error) {
@@ -162,46 +181,49 @@ const HostDetail = () => {
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Link to="/hosts" className="text-secondary-500 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-200">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">{host.hostname}</h1>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowCredentialsModal(true)}
className="btn-outline flex items-center gap-2"
>
<Key className="h-4 w-4" />
View Credentials
</button>
<button
onClick={() => setShowDeleteModal(true)}
className="btn-danger flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Delete Host
</button>
</div>
</div>
{/* Host Information */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Basic Info */}
<div className="card p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Host Information</h3>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Host Information</h3>
<div className="flex items-center gap-2">
<Link to="/hosts" className="text-secondary-500 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-200">
<ArrowLeft className="h-5 w-5" />
</Link>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Hostname</p>
<p className="font-medium text-secondary-900 dark:text-white">{host.hostname}</p>
<div className="flex-1">
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-1">Friendly Name</p>
<InlineEdit
value={host.friendlyName}
onSave={(newName) => updateFriendlyNameMutation.mutate(newName)}
placeholder="Enter friendly name..."
maxLength={100}
validate={(value) => {
if (!value.trim()) return 'Friendly name is required';
if (value.trim().length < 1) return 'Friendly name must be at least 1 character';
if (value.trim().length > 100) return 'Friendly name must be less than 100 characters';
return null;
}}
className="w-full"
/>
</div>
</div>
{host.hostname && (
<div className="flex items-center gap-3">
<Server className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">System Hostname</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">{host.hostname}</p>
</div>
</div>
)}
<div className="flex items-center gap-3">
<Shield className="h-5 w-5 text-secondary-400" />
<div>
@@ -225,7 +247,10 @@ const HostDetail = () => {
<Monitor className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Operating System</p>
<p className="font-medium text-secondary-900 dark:text-white">{host.osType} {host.osVersion}</p>
<div className="flex items-center gap-2">
<OSIcon osType={host.osType} className="h-5 w-5" />
<p className="font-medium text-secondary-900 dark:text-white">{host.osType} {host.osVersion}</p>
</div>
</div>
</div>
@@ -289,6 +314,24 @@ const HostDetail = () => {
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex items-center gap-3 pt-4 mt-4 border-t border-secondary-200 dark:border-secondary-600">
<button
onClick={() => setShowCredentialsModal(true)}
className="btn-outline flex items-center gap-2"
>
<Key className="h-4 w-4" />
Deploy Agent
</button>
<button
onClick={() => setShowDeleteModal(true)}
className="btn-danger flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Delete Host
</button>
</div>
</div>
{/* Statistics */}
@@ -303,13 +346,17 @@ const HostDetail = () => {
<p className="text-sm text-secondary-500 dark:text-secondary-300">Total Packages</p>
</div>
<div className="text-center">
<div className="flex items-center justify-center w-12 h-12 bg-warning-100 rounded-lg mx-auto mb-2">
<button
onClick={() => navigate(`/packages?host=${hostId}`)}
className="text-center w-full p-2 rounded-lg hover:bg-warning-50 dark:hover:bg-warning-900/20 transition-colors group"
title="View outdated packages for this host"
>
<div className="flex items-center justify-center w-12 h-12 bg-warning-100 rounded-lg mx-auto mb-2 group-hover:bg-warning-200 dark:group-hover:bg-warning-800 transition-colors">
<Clock className="h-6 w-6 text-warning-600" />
</div>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.outdatedPackages}</p>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Outdated</p>
</div>
</button>
<div className="text-center">
<div className="flex items-center justify-center w-12 h-12 bg-danger-100 rounded-lg mx-auto mb-2">
@@ -330,124 +377,277 @@ const HostDetail = () => {
</div>
</div>
{/* Packages */}
<div className="card">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Packages</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Package
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Current Version
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Available Version
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Status
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{host.hostPackages?.map((hostPackage) => (
<tr key={hostPackage.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Package className="h-4 w-4 text-secondary-400 mr-3" />
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{hostPackage.package.name}
</div>
{hostPackage.package.description && (
<div className="text-sm text-secondary-500 dark:text-secondary-300">
{hostPackage.package.description}
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
{hostPackage.currentVersion}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
{hostPackage.availableVersion || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{hostPackage.needsUpdate ? (
<div className="flex items-center gap-2">
<span className={`badge ${hostPackage.isSecurityUpdate ? 'badge-danger' : 'badge-warning'}`}>
{hostPackage.isSecurityUpdate ? 'Security Update' : 'Update Available'}
</span>
{hostPackage.isSecurityUpdate && (
<Shield className="h-4 w-4 text-danger-600" />
)}
</div>
) : (
<span className="badge-success">Up to date</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{host.hostPackages?.length === 0 && (
<div className="text-center py-8">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">No packages found</p>
</div>
)}
</div>
{/* Update History */}
<div className="card">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Update History</h3>
</div>
<div className="p-6">
{host.updateHistory?.length > 0 ? (
<div className="space-y-4">
{host.updateHistory.map((update, index) => (
<div key={update.id} className="flex items-center justify-between py-3 border-b border-secondary-100 dark:border-secondary-700 last:border-0">
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${update.status === 'success' ? 'bg-success-500' : 'bg-danger-500'}`} />
<div>
<p className="text-sm font-medium text-secondary-900 dark:text-white">
{update.status === 'success' ? 'Update Successful' : 'Update Failed'}
</p>
<p className="text-xs text-secondary-500 dark:text-secondary-300">
{formatDate(update.timestamp)}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm text-secondary-900 dark:text-white">
{update.packagesCount} packages
</p>
{update.securityCount > 0 && (
<p className="text-xs text-danger-600">
{update.securityCount} security updates
</p>
)}
</div>
{/* Hardware Information */}
{(host.cpuModel || host.ramInstalled || host.diskDetails) && (
<div className="card p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Hardware Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{host.cpuModel && (
<div className="flex items-center gap-3">
<Cpu className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">CPU Model</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.cpuModel}</p>
</div>
))}
</div>
) : (
<div className="text-center py-8">
<Calendar className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">No update history available</p>
</div>
)}
{host.cpuCores && (
<div className="flex items-center gap-3">
<Cpu className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">CPU Cores</p>
<p className="font-medium text-secondary-900 dark:text-white">{host.cpuCores}</p>
</div>
</div>
)}
{host.ramInstalled && (
<div className="flex items-center gap-3">
<MemoryStick className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">RAM Installed</p>
<p className="font-medium text-secondary-900 dark:text-white">{host.ramInstalled} GB</p>
</div>
</div>
)}
{host.swapSize !== undefined && (
<div className="flex items-center gap-3">
<HardDrive className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Swap Size</p>
<p className="font-medium text-secondary-900 dark:text-white">{host.swapSize} GB</p>
</div>
</div>
)}
</div>
{host.diskDetails && Array.isArray(host.diskDetails) && host.diskDetails.length > 0 && (
<div className="mt-4 pt-4 border-t border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">Disk Details</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{host.diskDetails.map((disk, index) => (
<div key={index} className="bg-secondary-50 dark:bg-secondary-700 p-3 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<HardDrive className="h-4 w-4 text-secondary-500" />
<span className="font-medium text-secondary-900 dark:text-white text-sm">{disk.name}</span>
</div>
<p className="text-xs text-secondary-600 dark:text-secondary-300">Size: {disk.size}</p>
{disk.mountpoint && (
<p className="text-xs text-secondary-600 dark:text-secondary-300">Mount: {disk.mountpoint}</p>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Network Information */}
{(host.gatewayIp || host.dnsServers || host.networkInterfaces) && (
<div className="card p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Network Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{host.gatewayIp && (
<div className="flex items-center gap-3">
<Globe className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Gateway IP</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">{host.gatewayIp}</p>
</div>
</div>
)}
{host.dnsServers && Array.isArray(host.dnsServers) && host.dnsServers.length > 0 && (
<div className="flex items-center gap-3">
<Globe className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">DNS Servers</p>
<div className="space-y-1">
{host.dnsServers.map((dns, index) => (
<p key={index} className="font-medium text-secondary-900 dark:text-white font-mono text-sm">{dns}</p>
))}
</div>
</div>
</div>
)}
{host.networkInterfaces && Array.isArray(host.networkInterfaces) && host.networkInterfaces.length > 0 && (
<div className="flex items-center gap-3">
<Wifi className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Network Interfaces</p>
<div className="space-y-1">
{host.networkInterfaces.map((iface, index) => (
<p key={index} className="font-medium text-secondary-900 dark:text-white text-sm">{iface.name}</p>
))}
</div>
</div>
</div>
)}
</div>
</div>
)}
{/* System Information */}
{(host.kernelVersion || host.selinuxStatus || host.systemUptime || host.loadAverage) && (
<div className="card p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">System Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{host.kernelVersion && (
<div className="flex items-center gap-3">
<Terminal className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Kernel Version</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">{host.kernelVersion}</p>
</div>
</div>
)}
{host.selinuxStatus && (
<div className="flex items-center gap-3">
<Shield className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">SELinux Status</p>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
host.selinuxStatus === 'enabled'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: host.selinuxStatus === 'permissive'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
}`}>
{host.selinuxStatus}
</span>
</div>
</div>
)}
{host.systemUptime && (
<div className="flex items-center gap-3">
<Clock className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">System Uptime</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.systemUptime}</p>
</div>
</div>
)}
{host.loadAverage && Array.isArray(host.loadAverage) && host.loadAverage.length > 0 && (
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-secondary-400" />
<div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Load Average</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.loadAverage.map((load, index) => (
<span key={index}>
{load.toFixed(2)}
{index < host.loadAverage.length - 1 && ', '}
</span>
))}
</p>
</div>
</div>
)}
</div>
</div>
)}
{/* Update History */}
<div className="w-1/2">
<div className="card max-h-96">
<div className="px-4 py-3 border-b border-secondary-200 dark:border-secondary-600">
<h3 className="text-base font-medium text-secondary-900 dark:text-white">Agent Update History</h3>
</div>
<div className="overflow-x-auto max-h-80">
{host.updateHistory?.length > 0 ? (
<>
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Status
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Date
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Packages
</th>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Security
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{(showAllUpdates ? host.updateHistory : host.updateHistory.slice(0, 3)).map((update, index) => (
<tr key={update.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700">
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center gap-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${update.status === 'success' ? 'bg-success-500' : 'bg-danger-500'}`} />
<span className={`text-xs font-medium ${
update.status === 'success'
? 'text-success-700 dark:text-success-300'
: 'text-danger-700 dark:text-danger-300'
}`}>
{update.status === 'success' ? 'Success' : 'Failed'}
</span>
</div>
</td>
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
{formatDate(update.timestamp)}
</td>
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
{update.packagesCount}
</td>
<td className="px-4 py-2 whitespace-nowrap">
{update.securityCount > 0 ? (
<div className="flex items-center gap-1">
<Shield className="h-3 w-3 text-danger-600" />
<span className="text-xs text-danger-600 font-medium">
{update.securityCount}
</span>
</div>
) : (
<span className="text-xs text-secondary-500 dark:text-secondary-400">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
{host.updateHistory.length > 3 && (
<div className="px-4 py-2 border-t border-secondary-200 dark:border-secondary-600 bg-secondary-50 dark:bg-secondary-700">
<button
onClick={() => setShowAllUpdates(!showAllUpdates)}
className="flex items-center gap-1.5 text-xs text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium"
>
{showAllUpdates ? (
<>
<ChevronUp className="h-3 w-3" />
Show Less
</>
) : (
<>
<ChevronDown className="h-3 w-3" />
Show All ({host.updateHistory.length} total)
</>
)}
</button>
</div>
)}
</>
) : (
<div className="text-center py-6">
<Calendar className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">No update history available</p>
</div>
)}
</div>
</div>
</div>
{/* Credentials Modal */}
@@ -476,7 +676,7 @@ const HostDetail = () => {
// Credentials Modal Component
const CredentialsModal = ({ host, isOpen, onClose }) => {
const [showApiKey, setShowApiKey] = useState(false)
const [activeTab, setActiveTab] = useState('credentials')
const [activeTab, setActiveTab] = useState('quick-install')
const { data: serverUrlData } = useQuery({
queryKey: ['serverUrl'],
@@ -490,7 +690,7 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
}
const getSetupCommands = () => {
return `# Run this on the target host: ${host?.hostname}
return `# Run this on the target host: ${host?.friendlyName}
echo "🔄 Setting up PatchMon agent..."
@@ -532,7 +732,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<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 p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Host Setup - {host.hostname}</h3>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Host Setup - {host.friendlyName}</h3>
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300">
<X className="h-5 w-5" />
</button>
@@ -541,16 +741,6 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
{/* Tabs */}
<div className="border-b border-secondary-200 dark:border-secondary-600 mb-6">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('credentials')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'credentials'
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500'
}`}
>
API Credentials
</button>
<button
onClick={() => setActiveTab('quick-install')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
@@ -561,10 +751,168 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
>
Quick Install
</button>
<button
onClick={() => setActiveTab('credentials')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'credentials'
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500'
}`}
>
API Credentials
</button>
</nav>
</div>
{/* Tab Content */}
{activeTab === 'quick-install' && (
<div className="space-y-4">
<div className="bg-primary-50 dark:bg-primary-900 border border-primary-200 dark:border-primary-700 rounded-lg p-4">
<h4 className="text-sm font-medium text-primary-900 dark:text-primary-200 mb-2">One-Line Installation</h4>
<p className="text-sm text-primary-700 dark:text-primary-300 mb-3">
Copy and run this command on the target host to automatically install and configure the PatchMon agent:
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={`curl -s ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host.apiId}" "${host.apiKey}"`}
readOnly
className="flex-1 px-3 py-2 border border-primary-300 dark:border-primary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => copyToClipboard(`curl -s ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host.apiId}" "${host.apiKey}"`)}
className="btn-primary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">Manual Installation</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-3">
If you prefer to install manually, follow these steps:
</p>
<div className="space-y-3">
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">1. Download Agent Script</h5>
<div className="flex items-center gap-2">
<input
type="text"
value={`curl -o /tmp/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download`}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => copyToClipboard(`curl -o /tmp/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download`)}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">2. Install Agent</h5>
<div className="flex items-center gap-2">
<input
type="text"
value="sudo mkdir -p /etc/patchmon && sudo mv /tmp/patchmon-agent.sh /usr/local/bin/patchmon-agent.sh && sudo chmod +x /usr/local/bin/patchmon-agent.sh"
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => copyToClipboard("sudo mkdir -p /etc/patchmon && sudo mv /tmp/patchmon-agent.sh /usr/local/bin/patchmon-agent.sh && sudo chmod +x /usr/local/bin/patchmon-agent.sh")}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">3. Configure Credentials</h5>
<div className="flex items-center gap-2">
<input
type="text"
value={`sudo /usr/local/bin/patchmon-agent.sh configure "${host.apiId}" "${host.apiKey}"`}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => copyToClipboard(`sudo /usr/local/bin/patchmon-agent.sh configure "${host.apiId}" "${host.apiKey}"`)}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">4. Test Configuration</h5>
<div className="flex items-center gap-2">
<input
type="text"
value="sudo /usr/local/bin/patchmon-agent.sh test"
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => copyToClipboard("sudo /usr/local/bin/patchmon-agent.sh test")}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">5. Send Initial Data</h5>
<div className="flex items-center gap-2">
<input
type="text"
value="sudo /usr/local/bin/patchmon-agent.sh update"
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => copyToClipboard("sudo /usr/local/bin/patchmon-agent.sh update")}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-md p-3 border border-secondary-200 dark:border-secondary-600">
<h5 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">6. Setup Crontab (Optional)</h5>
<div className="flex items-center gap-2">
<input
type="text"
value='echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -'
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => copyToClipboard('echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -')}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === 'credentials' && (
<div className="space-y-6">
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4">
@@ -630,48 +978,6 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
</div>
)}
{activeTab === 'quick-install' && (
<div className="space-y-4">
<div className="bg-primary-50 dark:bg-primary-900 border border-primary-200 dark:border-primary-700 rounded-lg p-4">
<h4 className="text-sm font-medium text-primary-900 dark:text-primary-200 mb-2">One-Line Installation</h4>
<p className="text-sm text-primary-700 dark:text-primary-300 mb-3">
Copy and run this command on the target host to automatically install and configure the PatchMon agent:
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={`curl -s ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host.apiId}" "${host.apiKey}"`}
readOnly
className="flex-1 px-3 py-2 border border-primary-300 dark:border-primary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => copyToClipboard(`curl -s ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host.apiId}" "${host.apiKey}"`)}
className="btn-primary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy
</button>
</div>
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">Manual Installation</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-3">
If you prefer manual installation, run these commands on the target host:
</p>
<pre className="bg-secondary-900 dark:bg-secondary-800 text-secondary-100 dark:text-secondary-200 p-4 rounded-md text-sm overflow-x-auto">
<code>{commands}</code>
</pre>
<button
onClick={() => copyToClipboard(commands)}
className="mt-3 btn-outline flex items-center gap-1"
>
<Copy className="h-4 w-4" />
Copy Commands
</button>
</div>
</div>
)}
<div className="flex justify-end pt-6">
<button onClick={onClose} className="btn-primary">
@@ -707,7 +1013,7 @@ const DeleteConfirmationModal = ({ host, isOpen, onClose, onConfirm, isLoading }
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-300">
Are you sure you want to delete the host{' '}
<span className="font-semibold">"{host.hostname}"</span>?
<span className="font-semibold">"{host.friendlyName}"</span>?
</p>
<div className="mt-3 p-3 bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md">
<p className="text-sm text-danger-800 dark:text-danger-200">
+167 -49
View File
@@ -31,11 +31,14 @@ import {
EyeOff as EyeOffIcon
} from 'lucide-react'
import { dashboardAPI, adminHostsAPI, settingsAPI, hostGroupsAPI, formatRelativeTime } from '../utils/api'
import { OSIcon } from '../utils/osIcons.jsx'
import InlineEdit from '../components/InlineEdit'
import InlineGroupEdit from '../components/InlineGroupEdit'
// Add Host Modal Component
const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
const [formData, setFormData] = useState({
hostname: '',
friendlyName: '',
hostGroupId: ''
})
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -59,7 +62,7 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
const response = await adminHostsAPI.create(formData)
console.log('Host created successfully:', response.data)
onSuccess(response.data)
setFormData({ hostname: '', hostGroupId: '' })
setFormData({ friendlyName: '', hostGroupId: '' })
onClose()
} catch (err) {
console.error('Full error object:', err)
@@ -98,12 +101,12 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">Hostname *</label>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">Friendly Name *</label>
<input
type="text"
required
value={formData.hostname}
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
value={formData.friendlyName}
onChange={(e) => setFormData({ ...formData, friendlyName: e.target.value })}
className="block w-full px-3 py-2.5 text-base border-2 border-secondary-300 dark:border-secondary-600 rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white transition-all duration-200"
placeholder="server.example.com"
/>
@@ -249,7 +252,7 @@ echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo
fullSetup: `#!/bin/bash
# Complete PatchMon Agent Setup Script
# Run this on the target host: ${host?.hostname}
# Run this on the target host: ${host?.friendlyName}
echo "🔄 Setting up PatchMon agent..."
@@ -292,7 +295,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<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-4xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900">Host Setup - {host.hostname}</h3>
<h3 className="text-lg font-medium text-secondary-900">Host Setup - {host.friendlyName}</h3>
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600">
<X className="h-5 w-5" />
</button>
@@ -348,7 +351,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<h4 className="text-sm font-medium text-green-800 mb-2">🚀 One-Line Installation</h4>
<p className="text-sm text-green-700">
Copy and paste this single command on <strong>{host.hostname}</strong> to install and configure the PatchMon agent automatically.
Copy and paste this single command on <strong>{host.friendlyName}</strong> to install and configure the PatchMon agent automatically.
</p>
</div>
@@ -375,7 +378,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<ul className="text-sm text-blue-700 space-y-1">
<li> Downloads the PatchMon installation script</li>
<li> Installs the agent to <code>/usr/local/bin/patchmon-agent.sh</code></li>
<li> Configures API credentials for <strong>{host.hostname}</strong></li>
<li> Configures API credentials for <strong>{host.friendlyName}</strong></li>
<li> Tests the connection to PatchMon server</li>
<li> Sends initial package data</li>
<li> Sets up hourly automatic updates via crontab</li>
@@ -441,7 +444,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="bg-amber-50 border border-amber-200 rounded-md p-4">
<h4 className="text-sm font-medium text-amber-800 mb-2"> Security Note</h4>
<p className="text-sm text-amber-700">
Keep these credentials secure. They provide access to update package information for <strong>{host.hostname}</strong> only.
Keep these credentials secure. They provide access to update package information for <strong>{host.friendlyName}</strong> only.
</p>
</div>
</div>
@@ -452,7 +455,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<h4 className="text-sm font-medium text-blue-800 mb-2">📋 Step-by-Step Setup</h4>
<p className="text-sm text-blue-700">
Follow these commands on <strong>{host.hostname}</strong> to install and configure the PatchMon agent.
Follow these commands on <strong>{host.friendlyName}</strong> to install and configure the PatchMon agent.
</p>
</div>
@@ -549,7 +552,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<h4 className="text-sm font-medium text-green-800 mb-2">🚀 Automated Setup</h4>
<p className="text-sm text-green-700">
Copy this complete setup script to <strong>{host.hostname}</strong> and run it to automatically install and configure everything.
Copy this complete setup script to <strong>{host.friendlyName}</strong> and run it to automatically install and configure everything.
</p>
</div>
@@ -570,7 +573,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="mt-3 text-sm text-secondary-600">
<p><strong>Usage:</strong></p>
<p>1. Copy the script above</p>
<p>2. Save it to a file on {host.hostname} (e.g., <code>setup-patchmon.sh</code>)</p>
<p>2. Save it to a file on {host.friendlyName} (e.g., <code>setup-patchmon.sh</code>)</p>
<p>3. Run: <code>chmod +x setup-patchmon.sh && sudo ./setup-patchmon.sh</code></p>
</div>
</div>
@@ -642,13 +645,24 @@ const Hosts = () => {
newSearchParams.delete('action')
navigate(`/hosts${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ''}`, { replace: true })
}
// Handle selected hosts from packages page
const selected = searchParams.get('selected')
if (selected) {
const hostIds = selected.split(',').filter(Boolean)
setSelectedHosts(hostIds)
// Remove the selected parameter from URL without triggering a page reload
const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.delete('selected')
navigate(`/hosts${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ''}`, { replace: true })
}
}, [searchParams, navigate])
// Column configuration
const [columnConfig, setColumnConfig] = useState(() => {
const defaultConfig = [
{ id: 'select', label: 'Select', visible: true, order: 0 },
{ id: 'host', label: 'Host', visible: true, order: 1 },
{ id: 'host', label: 'Friendly Name', visible: true, order: 1 },
{ id: 'ip', label: 'IP Address', visible: false, order: 2 },
{ id: 'group', label: 'Group', visible: true, order: 3 },
{ id: 'os', label: 'OS', visible: true, order: 4 },
@@ -732,7 +746,28 @@ const Hosts = () => {
const bulkUpdateGroupMutation = useMutation({
mutationFn: ({ hostIds, hostGroupId }) => adminHostsAPI.bulkUpdateGroup(hostIds, hostGroupId),
onSuccess: () => {
onSuccess: (data) => {
console.log('bulkUpdateGroupMutation success:', data);
// Update the cache with the new host data
if (data && data.hosts) {
queryClient.setQueryData(['hosts'], (oldData) => {
if (!oldData) return oldData;
return oldData.map(host => {
const updatedHost = data.hosts.find(h => h.id === host.id);
if (updatedHost) {
// Ensure hostGroupId is set correctly
return {
...updatedHost,
hostGroupId: updatedHost.hostGroup?.id || null
};
}
return host;
});
});
}
// Also invalidate to ensure consistency
queryClient.invalidateQueries(['hosts'])
setSelectedHosts([])
setShowBulkAssignModal(false)
@@ -747,6 +782,55 @@ const Hosts = () => {
}
})
const updateFriendlyNameMutation = useMutation({
mutationFn: ({ hostId, friendlyName }) => adminHostsAPI.updateFriendlyName(hostId, friendlyName).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries(['hosts'])
}
})
const updateHostGroupMutation = useMutation({
mutationFn: ({ hostId, hostGroupId }) => {
console.log('updateHostGroupMutation called with:', { hostId, hostGroupId });
return adminHostsAPI.updateGroup(hostId, hostGroupId).then(res => {
console.log('updateGroup API response:', res);
return res.data;
});
},
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) => {
console.log('Old cache data before update:', oldData);
if (!oldData) return oldData;
const updatedData = oldData.map(host => {
if (host.id === data.host.id) {
console.log('Updating host in cache:', host.id, 'with new data:', data.host);
// Ensure hostGroupId is set correctly
const updatedHost = {
...data.host,
hostGroupId: data.host.hostGroup?.id || null
};
console.log('Updated host with hostGroupId:', updatedHost);
return updatedHost;
}
return host;
});
console.log('New cache data after update:', updatedData);
return updatedData;
});
// Also invalidate to ensure consistency
queryClient.invalidateQueries(['hosts'])
},
onError: (error) => {
console.error('updateHostGroupMutation error:', error);
}
})
// Helper functions for bulk selection
const handleSelectHost = (hostId) => {
setSelectedHosts(prev =>
@@ -775,7 +859,7 @@ const Hosts = () => {
let filtered = hosts.filter(host => {
// Search filter
const matchesSearch = searchTerm === '' ||
host.hostname.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.friendlyName.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.ip?.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.osType?.toLowerCase().includes(searchTerm.toLowerCase())
@@ -808,9 +892,13 @@ const Hosts = () => {
let aValue, bValue
switch (sortField) {
case 'friendlyName':
aValue = a.friendlyName.toLowerCase()
bValue = b.friendlyName.toLowerCase()
break
case 'hostname':
aValue = a.hostname.toLowerCase()
bValue = b.hostname.toLowerCase()
aValue = a.hostname?.toLowerCase() || 'zzz_no_hostname'
bValue = b.hostname?.toLowerCase() || 'zzz_no_hostname'
break
case 'ip':
aValue = a.ip?.toLowerCase() || 'zzz_no_ip'
@@ -929,15 +1017,16 @@ const Hosts = () => {
const resetColumns = () => {
const defaultConfig = [
{ id: 'select', label: 'Select', visible: true, order: 0 },
{ id: 'host', label: 'Host', visible: true, order: 1 },
{ id: 'ip', label: 'IP Address', visible: false, order: 2 },
{ id: 'group', label: 'Group', visible: true, order: 3 },
{ id: 'os', label: 'OS', visible: true, order: 4 },
{ id: 'osVersion', label: 'OS Version', visible: false, order: 5 },
{ id: 'status', label: 'Status', visible: true, order: 6 },
{ id: 'updates', label: 'Updates', visible: true, order: 7 },
{ id: 'lastUpdate', label: 'Last Update', visible: true, order: 8 },
{ id: 'actions', label: 'Actions', visible: true, order: 9 }
{ id: 'host', label: 'Friendly Name', visible: true, order: 1 },
{ id: 'hostname', label: 'System Hostname', visible: true, order: 2 },
{ id: 'ip', label: 'IP Address', visible: false, order: 3 },
{ id: 'group', label: 'Group', visible: true, order: 4 },
{ id: 'os', label: 'OS', visible: true, order: 5 },
{ id: 'osVersion', label: 'OS Version', visible: false, order: 6 },
{ id: 'status', label: 'Status', visible: true, order: 7 },
{ id: 'updates', label: 'Updates', visible: true, order: 8 },
{ id: 'lastUpdate', label: 'Last Update', visible: true, order: 9 },
{ id: 'actions', label: 'Actions', visible: true, order: 10 }
]
updateColumnConfig(defaultConfig)
}
@@ -965,12 +1054,26 @@ const Hosts = () => {
)
case 'host':
return (
<Link
to={`/hosts/${host.id}`}
className="text-sm font-medium text-primary-600 hover:text-primary-900 hover:underline"
>
{host.hostname}
</Link>
<InlineEdit
value={host.friendlyName}
onSave={(newName) => updateFriendlyNameMutation.mutate({ hostId: host.id, friendlyName: newName })}
placeholder="Enter friendly name..."
maxLength={100}
linkTo={`/hosts/${host.id}`}
validate={(value) => {
if (!value.trim()) return 'Friendly name is required';
if (value.trim().length < 1) return 'Friendly name must be at least 1 character';
if (value.trim().length > 100) return 'Friendly name must be less than 100 characters';
return null;
}}
className="w-full"
/>
)
case 'hostname':
return (
<div className="text-sm text-secondary-900 dark:text-white font-mono">
{host.hostname || 'N/A'}
</div>
)
case 'ip':
return (
@@ -979,22 +1082,27 @@ const Hosts = () => {
</div>
)
case 'group':
return host.hostGroup ? (
<span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: host.hostGroup.color }}
>
{host.hostGroup.name}
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
Ungrouped
</span>
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}
onSave={(newGroupId) => updateHostGroupMutation.mutate({ hostId: host.id, hostGroupId: newGroupId })}
options={hostGroups || []}
placeholder="Select group..."
className="w-full"
/>
)
case 'os':
return (
<div className="text-sm text-secondary-900 dark:text-white">
{host.osType}
<div className="flex items-center gap-2 text-sm text-secondary-900 dark:text-white">
<OSIcon osType={host.osType} className="h-4 w-4" />
<span>{host.osType}</span>
</div>
)
case 'osVersion':
@@ -1068,6 +1176,8 @@ const Hosts = () => {
setGroupBy('none')
setHideStale(false)
setShowFilters(false)
// Clear URL parameters to ensure no filters are applied
navigate('/hosts', { replace: true })
}
const handleUpToDateClick = () => {
@@ -1401,6 +1511,14 @@ const Hosts = () => {
)}
</button>
) : column.id === 'host' ? (
<button
onClick={() => handleSort('friendlyName')}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon('friendlyName')}
</button>
) : column.id === 'hostname' ? (
<button
onClick={() => handleSort('hostname')}
className="flex items-center gap-2 hover:text-secondary-700"
@@ -1561,7 +1679,7 @@ const BulkAssignModal = ({ selectedHosts, hosts, onClose, onAssign, isLoading })
const selectedHostNames = hosts
.filter(host => selectedHosts.includes(host.id))
.map(host => host.hostname)
.map(host => host.friendlyName)
const handleSubmit = (e) => {
e.preventDefault()
@@ -1585,9 +1703,9 @@ const BulkAssignModal = ({ selectedHosts, hosts, onClose, onAssign, isLoading })
Assigning {selectedHosts.length} host{selectedHosts.length !== 1 ? 's' : ''}:
</p>
<div className="max-h-32 overflow-y-auto bg-secondary-50 rounded-md p-3">
{selectedHostNames.map((hostname, index) => (
{selectedHostNames.map((friendlyName, index) => (
<div key={index} className="text-sm text-secondary-700">
{hostname}
{friendlyName}
</div>
))}
</div>
+478 -167
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import React, { useState, useEffect, useMemo } from 'react'
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import {
Package,
@@ -9,7 +9,17 @@ import {
Search,
AlertTriangle,
Filter,
ExternalLink
ExternalLink,
ArrowUpDown,
ArrowUp,
ArrowDown,
ChevronDown,
Settings,
Columns,
GripVertical,
X,
Eye as EyeIcon,
EyeOff as EyeOffIcon
} from 'lucide-react'
import { dashboardAPI } from '../utils/api'
@@ -17,7 +27,61 @@ const Packages = () => {
const [searchTerm, setSearchTerm] = useState('')
const [categoryFilter, setCategoryFilter] = useState('all')
const [securityFilter, setSecurityFilter] = useState('all')
const [hostFilter, setHostFilter] = useState('all')
const [sortField, setSortField] = useState('name')
const [sortDirection, setSortDirection] = useState('asc')
const [showColumnSettings, setShowColumnSettings] = useState(false)
const [searchParams] = useSearchParams()
const navigate = useNavigate()
// Handle host filter from URL parameter
useEffect(() => {
const hostParam = searchParams.get('host')
if (hostParam) {
setHostFilter(hostParam)
}
}, [searchParams])
// Column configuration
const [columnConfig, setColumnConfig] = useState(() => {
const defaultConfig = [
{ id: 'name', label: 'Package', visible: true, order: 0 },
{ id: 'affectedHosts', label: 'Affected Hosts', visible: true, order: 1 },
{ id: 'priority', label: 'Priority', visible: true, order: 2 },
{ id: 'latestVersion', label: 'Latest Version', visible: true, order: 3 }
]
const saved = localStorage.getItem('packages-column-config')
if (saved) {
const savedConfig = JSON.parse(saved)
// Merge with defaults to handle new columns
return defaultConfig.map(defaultCol => {
const savedCol = savedConfig.find(col => col.id === defaultCol.id)
return savedCol ? { ...defaultCol, ...savedCol } : defaultCol
})
}
return defaultConfig
})
// Update column configuration
const updateColumnConfig = (newConfig) => {
setColumnConfig(newConfig)
localStorage.setItem('packages-column-config', JSON.stringify(newConfig))
}
// Handle affected hosts click
const handleAffectedHostsClick = (pkg) => {
const hostIds = pkg.affectedHosts.map(host => host.hostId)
const hostNames = pkg.affectedHosts.map(host => host.friendlyName)
// Create URL with selected hosts and filter
const params = new URLSearchParams()
params.set('selected', hostIds.join(','))
params.set('filter', 'selected')
// Navigate to hosts page with selected hosts
navigate(`/hosts?${params.toString()}`)
}
// Handle URL filter parameters
useEffect(() => {
@@ -41,6 +105,196 @@ const Packages = () => {
staleTime: 30000,
})
// Fetch hosts data to get total packages count
const { data: hosts } = useQuery({
queryKey: ['hosts'],
queryFn: () => dashboardAPI.getHosts().then(res => res.data),
refetchInterval: 60000,
staleTime: 30000,
})
// Filter and sort packages
const filteredAndSortedPackages = useMemo(() => {
if (!packages) return []
// Filter packages
const filtered = packages.filter(pkg => {
const matchesSearch = pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(pkg.description && pkg.description.toLowerCase().includes(searchTerm.toLowerCase()))
const matchesCategory = categoryFilter === 'all' || pkg.category === categoryFilter
const matchesSecurity = securityFilter === 'all' ||
(securityFilter === 'security' && pkg.isSecurityUpdate) ||
(securityFilter === 'regular' && !pkg.isSecurityUpdate)
const matchesHost = hostFilter === 'all' ||
pkg.affectedHosts.some(host => host.hostId === hostFilter)
return matchesSearch && matchesCategory && matchesSecurity && matchesHost
})
// Sorting
filtered.sort((a, b) => {
let aValue, bValue
switch (sortField) {
case 'name':
aValue = a.name?.toLowerCase() || ''
bValue = b.name?.toLowerCase() || ''
break
case 'latestVersion':
aValue = a.latestVersion?.toLowerCase() || ''
bValue = b.latestVersion?.toLowerCase() || ''
break
case 'affectedHosts':
aValue = a.affectedHostsCount || 0
bValue = b.affectedHostsCount || 0
break
case 'priority':
aValue = a.isSecurityUpdate ? 0 : 1 // Security updates first
bValue = b.isSecurityUpdate ? 0 : 1
break
default:
aValue = a.name?.toLowerCase() || ''
bValue = b.name?.toLowerCase() || ''
}
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1
return 0
})
return filtered
}, [packages, searchTerm, categoryFilter, securityFilter, sortField, sortDirection])
// Get visible columns in order
const visibleColumns = columnConfig
.filter(col => col.visible)
.sort((a, b) => a.order - b.order)
// Sorting functions
const handleSort = (field) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortDirection('asc')
}
}
const getSortIcon = (field) => {
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />
return sortDirection === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
}
// Column management functions
const toggleColumnVisibility = (columnId) => {
const newConfig = columnConfig.map(col =>
col.id === columnId ? { ...col, visible: !col.visible } : col
)
updateColumnConfig(newConfig)
}
const reorderColumns = (fromIndex, toIndex) => {
const newConfig = [...columnConfig]
const [movedColumn] = newConfig.splice(fromIndex, 1)
newConfig.splice(toIndex, 0, movedColumn)
// Update order values
const updatedConfig = newConfig.map((col, index) => ({ ...col, order: index }))
updateColumnConfig(updatedConfig)
}
const resetColumns = () => {
const defaultConfig = [
{ id: 'name', label: 'Package', visible: true, order: 0 },
{ id: 'affectedHosts', label: 'Affected Hosts', visible: true, order: 1 },
{ id: 'priority', label: 'Priority', visible: true, order: 2 },
{ id: 'latestVersion', label: 'Latest Version', visible: true, order: 3 }
]
updateColumnConfig(defaultConfig)
}
// Helper function to render table cell content
const renderCellContent = (column, pkg) => {
switch (column.id) {
case 'name':
return (
<div className="flex items-center">
<Package className="h-5 w-5 text-secondary-400 mr-3" />
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{pkg.name}
</div>
{pkg.description && (
<div className="text-sm text-secondary-500 dark:text-secondary-300 max-w-md truncate">
{pkg.description}
</div>
)}
{pkg.category && (
<div className="text-xs text-secondary-400 dark:text-secondary-400">
Category: {pkg.category}
</div>
)}
</div>
</div>
)
case 'affectedHosts':
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`}
>
<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' : ''}
</div>
</button>
)
case 'priority':
return pkg.isSecurityUpdate ? (
<span className="badge-danger flex items-center gap-1">
<Shield className="h-3 w-3" />
Security Update
</span>
) : (
<span className="badge-warning">Regular Update</span>
)
case 'latestVersion':
return (
<div className="text-sm text-secondary-900 dark:text-white max-w-xs truncate" title={pkg.latestVersion || 'Unknown'}>
{pkg.latestVersion || 'Unknown'}
</div>
)
default:
return null
}
}
// Get unique categories
const categories = [...new Set(packages?.map(pkg => pkg.category).filter(Boolean))] || []
// Calculate unique affected hosts
const uniqueAffectedHosts = new Set()
packages?.forEach(pkg => {
pkg.affectedHosts.forEach(host => {
uniqueAffectedHosts.add(host.hostId)
})
})
const uniqueAffectedHostsCount = uniqueAffectedHosts.size
// Calculate total packages across all hosts (including up-to-date ones)
const totalPackagesCount = hosts?.reduce((total, host) => {
return total + (host.totalPackagesCount || 0)
}, 0) || 0
// Calculate outdated packages (packages that need updates)
const outdatedPackagesCount = packages?.length || 0
// Calculate security updates
const securityUpdatesCount = packages?.filter(pkg => pkg.isSecurityUpdate).length || 0
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -74,64 +328,38 @@ const Packages = () => {
)
}
// Filter packages based on search and filters
const filteredPackages = packages?.filter(pkg => {
const matchesSearch = pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(pkg.description && pkg.description.toLowerCase().includes(searchTerm.toLowerCase()))
const matchesCategory = categoryFilter === 'all' || pkg.category === categoryFilter
const matchesSecurity = securityFilter === 'all' ||
(securityFilter === 'security' && pkg.isSecurityUpdate) ||
(securityFilter === 'regular' && !pkg.isSecurityUpdate)
return matchesSearch && matchesCategory && matchesSecurity
}) || []
// Get unique categories
const categories = [...new Set(packages?.map(pkg => pkg.category).filter(Boolean))] || []
// Calculate unique affected hosts
const uniqueAffectedHosts = new Set()
packages?.forEach(pkg => {
pkg.affectedHosts.forEach(host => {
uniqueAffectedHosts.add(host.hostId)
})
})
const uniqueAffectedHostsCount = uniqueAffectedHosts.size
return (
<div className="space-y-6">
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
{/* Summary Stats */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
<div className="card p-4">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6 flex-shrink-0">
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Package className="h-5 w-5 text-primary-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Total Packages</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{packages?.length || 0}</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{totalPackagesCount}</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Shield className="h-5 w-5 text-danger-600 mr-2" />
<Package className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Security Updates</p>
<p className="text-sm text-secondary-500 dark:text-white">Total Outdated Packages</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{packages?.filter(pkg => pkg.isSecurityUpdate).length || 0}
{outdatedPackagesCount}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Server className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Affected Hosts</p>
<p className="text-sm text-secondary-500 dark:text-white">Hosts Pending Updates</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{uniqueAffectedHostsCount}
</p>
@@ -139,152 +367,235 @@ const Packages = () => {
</div>
</div>
<div className="card p-4">
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Filter className="h-5 w-5 text-secondary-600 mr-2" />
<Shield className="h-5 w-5 text-danger-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Categories</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{categories.length}</p>
<p className="text-sm text-secondary-500 dark:text-white">Security Updates Across All Hosts</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{securityUpdatesCount}</p>
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="card p-4">
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
<input
type="text"
placeholder="Search packages..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
/>
</div>
</div>
{/* Category Filter */}
<div className="sm:w-48">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Categories</option>
{categories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
{/* Security Filter */}
<div className="sm:w-48">
<select
value={securityFilter}
onChange={(e) => setSecurityFilter(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Updates</option>
<option value="security">Security Only</option>
<option value="regular">Regular Only</option>
</select>
</div>
</div>
</div>
{/* Packages List */}
<div className="card">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Packages Needing Updates ({filteredPackages.length})
</h3>
<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">
{/* Empty selection controls area to match hosts page spacing */}
</div>
{filteredPackages.length === 0 ? (
<div className="text-center py-8">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
{packages?.length === 0 ? 'No packages need updates' : 'No packages match your filters'}
</p>
{packages?.length === 0 && (
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
All packages are up to date across all hosts
</p>
)}
{/* Table Controls */}
<div className="mb-4 space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
<input
type="text"
placeholder="Search packages..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
/>
</div>
</div>
{/* Category Filter */}
<div className="sm:w-48">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Categories</option>
{categories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
{/* Security Filter */}
<div className="sm:w-48">
<select
value={securityFilter}
onChange={(e) => setSecurityFilter(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Updates</option>
<option value="security">Security Only</option>
<option value="regular">Regular Only</option>
</select>
</div>
{/* Host Filter */}
<div className="sm:w-48">
<select
value={hostFilter}
onChange={(e) => setHostFilter(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Hosts</option>
{hosts?.map(host => (
<option key={host.id} value={host.id}>{host.friendlyName}</option>
))}
</select>
</div>
{/* Columns Button */}
<div className="flex items-center">
<button
onClick={() => setShowColumnSettings(true)}
className="flex items-center gap-2 px-3 py-2 text-sm text-secondary-700 dark:text-secondary-300 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 transition-colors"
>
<Columns className="h-4 w-4" />
Columns
</button>
</div>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
</div>
<div className="flex-1 overflow-hidden">
{filteredAndSortedPackages.length === 0 ? (
<div className="text-center py-8">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
{packages?.length === 0 ? 'No packages need updates' : 'No packages match your filters'}
</p>
{packages?.length === 0 && (
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
All packages are up to date across all hosts
</p>
)}
</div>
) : (
<div className="h-full overflow-auto">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Package
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Latest Version
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Affected Hosts
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Priority
</th>
{visibleColumns.map((column) => (
<th key={column.id} className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
onClick={() => handleSort(column.id)}
className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
>
{column.label}
{getSortIcon(column.id)}
</button>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredPackages.map((pkg) => (
<tr key={pkg.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Package className="h-5 w-5 text-secondary-400 mr-3" />
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{pkg.name}
</div>
{pkg.description && (
<div className="text-sm text-secondary-500 dark:text-secondary-300 max-w-md truncate">
{pkg.description}
</div>
)}
{pkg.category && (
<div className="text-xs text-secondary-400 dark:text-secondary-400">
Category: {pkg.category}
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
{pkg.latestVersion || 'Unknown'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-secondary-900 dark:text-white">
{pkg.affectedHostsCount} host{pkg.affectedHostsCount !== 1 ? 's' : ''}
</div>
<div className="text-xs text-secondary-500 dark:text-secondary-300">
{pkg.affectedHosts.slice(0, 2).map(host => host.hostname).join(', ')}
{pkg.affectedHosts.length > 2 && ` +${pkg.affectedHosts.length - 2} more`}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{pkg.isSecurityUpdate ? (
<span className="badge-danger flex items-center gap-1">
<Shield className="h-3 w-3" />
Security Update
</span>
) : (
<span className="badge-warning">Regular Update</span>
)}
</td>
{filteredAndSortedPackages.map((pkg) => (
<tr key={pkg.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
{visibleColumns.map((column) => (
<td key={column.id} className="px-4 py-2 whitespace-nowrap text-center">
{renderCellContent(column, pkg)}
</td>
))}
</tr>
))}
</tbody>
</table>
</table>
</div>
)}
</div>
</div>
</div>
{/* Column Settings Modal */}
{showColumnSettings && (
<ColumnSettingsModal
columnConfig={columnConfig}
onClose={() => setShowColumnSettings(false)}
onToggleVisibility={toggleColumnVisibility}
onReorder={reorderColumns}
onReset={resetColumns}
/>
)}
</div>
)
}
// Column Settings Modal Component
const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => {
const [draggedIndex, setDraggedIndex] = useState(null)
const handleDragStart = (e, index) => {
setDraggedIndex(index)
e.dataTransfer.effectAllowed = 'move'
}
const handleDragOver = (e) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
const handleDrop = (e, dropIndex) => {
e.preventDefault()
if (draggedIndex !== null && draggedIndex !== dropIndex) {
onReorder(draggedIndex, dropIndex)
}
setDraggedIndex(null)
}
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 p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Customize Columns</h3>
<button onClick={onClose} 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>
<div className="space-y-2">
{columnConfig.map((column, index) => (
<div
key={column.id}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, index)}
className={`flex items-center justify-between p-3 border rounded-lg cursor-move ${
draggedIndex === index ? 'opacity-50' : 'hover:bg-secondary-50 dark:hover:bg-secondary-700'
} border-secondary-200 dark:border-secondary-600`}
>
<div className="flex items-center gap-3">
<GripVertical className="h-4 w-4 text-secondary-400 dark:text-secondary-500" />
<span className="text-sm font-medium text-secondary-900 dark:text-white">
{column.label}
</span>
</div>
<button
onClick={() => onToggleVisibility(column.id)}
className={`p-1 rounded ${
column.visible
? 'text-primary-600 hover:text-primary-700'
: 'text-secondary-400 hover:text-secondary-600'
}`}
>
{column.visible ? <EyeIcon className="h-4 w-4" /> : <EyeOffIcon className="h-4 w-4" />}
</button>
</div>
)}
))}
</div>
<div className="flex justify-between mt-6">
<button
onClick={onReset}
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"
>
Reset to Default
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700"
>
Done
</button>
</div>
</div>
</div>
+462 -194
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
@@ -11,7 +11,15 @@ import {
Lock,
Unlock,
Database,
Eye
Eye,
Search,
Columns,
ArrowUpDown,
ArrowUp,
ArrowDown,
X,
GripVertical,
Check
} from 'lucide-react';
import { repositoryAPI } from '../utils/api';
@@ -19,6 +27,37 @@ const Repositories = () => {
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState('all'); // all, secure, insecure
const [filterStatus, setFilterStatus] = useState('all'); // all, active, inactive
const [sortField, setSortField] = useState('name');
const [sortDirection, setSortDirection] = useState('asc');
const [showColumnSettings, setShowColumnSettings] = useState(false);
// Column configuration
const [columnConfig, setColumnConfig] = useState(() => {
const defaultConfig = [
{ id: 'name', label: 'Repository', visible: true, order: 0 },
{ id: 'url', label: 'URL', visible: true, order: 1 },
{ id: 'distribution', label: 'Distribution', visible: true, order: 2 },
{ id: 'security', label: 'Security', visible: true, order: 3 },
{ id: 'status', label: 'Status', visible: true, order: 4 },
{ id: 'hostCount', label: 'Hosts', visible: true, order: 5 },
{ id: 'actions', label: 'Actions', visible: true, order: 6 }
];
const saved = localStorage.getItem('repositories-column-config');
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
console.error('Failed to parse saved column config:', e);
}
}
return defaultConfig;
});
const updateColumnConfig = (newConfig) => {
setColumnConfig(newConfig);
localStorage.setItem('repositories-column-config', JSON.stringify(newConfig));
};
// Fetch repositories
const { data: repositories = [], isLoading, error } = useQuery({
@@ -32,22 +71,122 @@ const Repositories = () => {
queryFn: () => repositoryAPI.getStats().then(res => res.data)
});
// Filter repositories based on search and filters
const filteredRepositories = repositories.filter(repo => {
const matchesSearch = repo.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
repo.url.toLowerCase().includes(searchTerm.toLowerCase()) ||
repo.distribution.toLowerCase().includes(searchTerm.toLowerCase());
// Get visible columns in order
const visibleColumns = columnConfig
.filter(col => col.visible)
.sort((a, b) => a.order - b.order);
// Sorting functions
const handleSort = (field) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const getSortIcon = (field) => {
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />
return sortDirection === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
};
// Column management functions
const toggleColumnVisibility = (columnId) => {
const newConfig = columnConfig.map(col =>
col.id === columnId ? { ...col, visible: !col.visible } : col
)
updateColumnConfig(newConfig)
};
const reorderColumns = (fromIndex, toIndex) => {
const newConfig = [...columnConfig]
const [movedColumn] = newConfig.splice(fromIndex, 1)
newConfig.splice(toIndex, 0, movedColumn)
const matchesType = filterType === 'all' ||
(filterType === 'secure' && repo.isSecure) ||
(filterType === 'insecure' && !repo.isSecure);
// Update order values
const updatedConfig = newConfig.map((col, index) => ({ ...col, order: index }))
updateColumnConfig(updatedConfig)
};
const resetColumns = () => {
const defaultConfig = [
{ id: 'name', label: 'Repository', visible: true, order: 0 },
{ id: 'url', label: 'URL', visible: true, order: 1 },
{ id: 'distribution', label: 'Distribution', visible: true, order: 2 },
{ id: 'security', label: 'Security', visible: true, order: 3 },
{ id: 'status', label: 'Status', visible: true, order: 4 },
{ id: 'hostCount', label: 'Hosts', visible: true, order: 5 },
{ id: 'actions', label: 'Actions', visible: true, order: 6 }
]
updateColumnConfig(defaultConfig)
};
// Filter and sort repositories
const filteredAndSortedRepositories = useMemo(() => {
if (!repositories) return []
const matchesStatus = filterStatus === 'all' ||
(filterStatus === 'active' && repo.isActive) ||
(filterStatus === 'inactive' && !repo.isActive);
return matchesSearch && matchesType && matchesStatus;
});
// Filter repositories
const filtered = repositories.filter(repo => {
const matchesSearch = repo.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
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://');
const matchesType = filterType === 'all' ||
(filterType === 'secure' && isSecure) ||
(filterType === 'insecure' && !isSecure);
const matchesStatus = filterStatus === 'all' ||
(filterStatus === 'active' && repo.isActive === true) ||
(filterStatus === 'inactive' && repo.isActive === false);
console.log('Filter results:', {
matchesSearch,
matchesType,
matchesStatus,
final: matchesSearch && matchesType && matchesStatus
});
return matchesSearch && matchesType && matchesStatus;
});
// Sort repositories
const sorted = filtered.sort((a, b) => {
let aValue = a[sortField];
let bValue = b[sortField];
// Handle special cases
if (sortField === 'security') {
aValue = a.isSecure ? 'Secure' : 'Insecure';
bValue = b.isSecure ? 'Secure' : 'Insecure';
} else if (sortField === 'status') {
aValue = a.isActive ? 'Active' : 'Inactive';
bValue = b.isActive ? 'Active' : 'Inactive';
}
if (typeof aValue === 'string') {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
return sorted;
}, [repositories, searchTerm, filterType, filterStatus, sortField, sortDirection]);
if (isLoading) {
return (
@@ -71,202 +210,331 @@ const Repositories = () => {
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
Repositories
</h1>
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
Manage and monitor package repositories across your infrastructure
</p>
</div>
</div>
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
{/* Statistics Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Database className="h-8 w-8 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Total Repositories</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.totalRepositories}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Server className="h-8 w-8 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Active Repositories</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.activeRepositories}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Shield className="h-8 w-8 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Secure (HTTPS)</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.secureRepositories}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="relative">
<ShieldCheck className="h-8 w-8 text-green-600" />
<span className="absolute -top-1 -right-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs font-medium px-1.5 py-0.5 rounded-full">
{stats.securityPercentage}%
</span>
</div>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Security Score</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.securityPercentage}%</p>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6 flex-shrink-0">
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Database className="h-5 w-5 text-primary-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Total Repositories</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.totalRepositories || 0}</p>
</div>
</div>
</div>
)}
{/* Search and Filters */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow p-6">
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<input
type="text"
placeholder="Search repositories..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
/>
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Server className="h-5 w-5 text-success-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Active Repositories</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.activeRepositories || 0}</p>
</div>
</div>
{/* Security Filter */}
<div>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
>
<option value="all">All Security Types</option>
<option value="secure">HTTPS Only</option>
<option value="insecure">HTTP Only</option>
</select>
</div>
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Shield className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Secure (HTTPS)</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.secureRepositories || 0}</p>
</div>
</div>
{/* Status Filter */}
<div>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
>
<option value="all">All Statuses</option>
<option value="active">Active Only</option>
<option value="inactive">Inactive Only</option>
</select>
</div>
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<ShieldCheck className="h-5 w-5 text-danger-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Security Score</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.securityPercentage || 0}%</p>
</div>
</div>
</div>
</div>
{/* Repositories List */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
<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">
Repositories ({filteredRepositories.length})
</h2>
</div>
{filteredRepositories.length === 0 ? (
<div className="px-6 py-12 text-center">
<Database 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 repositories found</h3>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
{searchTerm || filterType !== 'all' || filterStatus !== 'all'
? 'Try adjusting your search or filters.'
: 'No repositories have been reported by your hosts yet.'}
</p>
<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">
{/* Empty selection controls area to match packages page spacing */}
</div>
) : (
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
{filteredRepositories.map((repo) => (
<div key={repo.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-1 min-w-0">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
{repo.isSecure ? (
<Lock className="h-4 w-4 text-green-600" />
) : (
<Unlock className="h-4 w-4 text-orange-600" />
)}
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
{repo.name}
</h3>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
repo.isActive
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}>
{repo.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</div>
<div className="mt-2 space-y-1">
<p className="text-sm text-secondary-600 dark:text-secondary-300">
<Globe className="inline h-4 w-4 mr-1" />
{repo.url}
</p>
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400">
<span>Distribution: <span className="font-medium">{repo.distribution}</span></span>
<span>Type: <span className="font-medium">{repo.repoType}</span></span>
<span>Components: <span className="font-medium">{repo.components}</span></span>
</div>
</div>
</div>
<div className="flex items-center gap-4">
{/* Host Count */}
<div className="text-center">
<div className="flex items-center gap-1 text-sm text-secondary-500 dark:text-secondary-400">
<Users className="h-4 w-4" />
<span>{repo.hostCount} hosts</span>
</div>
</div>
{/* View Details */}
<Link
to={`/repositories/${repo.id}`}
className="btn-outline text-sm flex items-center gap-1"
>
<Eye className="h-4 w-4" />
View
</Link>
</div>
{/* Table Controls */}
<div className="mb-4 space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
<input
type="text"
placeholder="Search repositories..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
/>
</div>
</div>
))}
{/* Security Filter */}
<div className="sm:w-48">
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Security Types</option>
<option value="secure">HTTPS Only</option>
<option value="insecure">HTTP Only</option>
</select>
</div>
{/* Status Filter */}
<div className="sm:w-48">
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Statuses</option>
<option value="active">Active Only</option>
<option value="inactive">Inactive Only</option>
</select>
</div>
{/* Columns Button */}
<div className="flex items-center">
<button
onClick={() => setShowColumnSettings(true)}
className="flex items-center gap-2 px-3 py-2 text-sm text-secondary-700 dark:text-secondary-300 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 transition-colors"
>
<Columns className="h-4 w-4" />
Columns
</button>
</div>
</div>
</div>
)}
<div className="flex-1 overflow-hidden">
{filteredAndSortedRepositories.length === 0 ? (
<div className="text-center py-8">
<Database className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
{repositories?.length === 0 ? 'No repositories found' : 'No repositories match your filters'}
</p>
{repositories?.length === 0 && (
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
No repositories have been reported by your hosts yet
</p>
)}
</div>
) : (
<div className="h-full overflow-auto">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
<tr>
{visibleColumns.map((column) => (
<th key={column.id} className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
onClick={() => handleSort(column.id)}
className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
>
{column.label}
{getSortIcon(column.id)}
</button>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredAndSortedRepositories.map((repo) => (
<tr key={repo.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
{visibleColumns.map((column) => (
<td key={column.id} className="px-4 py-2 whitespace-nowrap text-center">
{renderCellContent(column, repo)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
{/* Column Settings Modal */}
{showColumnSettings && (
<ColumnSettingsModal
columnConfig={columnConfig}
onClose={() => setShowColumnSettings(false)}
onToggleVisibility={toggleColumnVisibility}
onReorder={reorderColumns}
onReset={resetColumns}
/>
)}
</div>
);
// Render cell content based on column type
function renderCellContent(column, repo) {
switch (column.id) {
case 'name':
return (
<div className="flex items-center">
<Database className="h-5 w-5 text-secondary-400 mr-3" />
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{repo.name}
</div>
</div>
</div>
)
case 'url':
return (
<div className="text-sm text-secondary-900 dark:text-white max-w-xs truncate" title={repo.url}>
{repo.url}
</div>
)
case 'distribution':
return (
<div className="text-sm text-secondary-900 dark:text-white">
{repo.distribution}
</div>
)
case 'security':
const isSecure = repo.isSecure !== undefined ? repo.isSecure : repo.url.startsWith('https://');
return (
<div className="flex items-center justify-center">
{isSecure ? (
<div className="flex items-center gap-1 text-green-600">
<Lock className="h-4 w-4" />
<span className="text-sm">Secure</span>
</div>
) : (
<div className="flex items-center gap-1 text-orange-600">
<Unlock className="h-4 w-4" />
<span className="text-sm">Insecure</span>
</div>
)}
</div>
)
case 'status':
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
repo.isActive
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}>
{repo.isActive ? 'Active' : 'Inactive'}
</span>
)
case 'hostCount':
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>
</div>
)
case 'actions':
return (
<Link
to={`/repositories/${repo.id}`}
className="text-primary-600 hover:text-primary-900 flex items-center gap-1"
>
View
<Eye className="h-3 w-3" />
</Link>
)
default:
return null
}
}
};
// Column Settings Modal Component
const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => {
const [draggedIndex, setDraggedIndex] = useState(null)
const handleDragStart = (e, index) => {
setDraggedIndex(index)
e.dataTransfer.effectAllowed = 'move'
}
const handleDragOver = (e) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
const handleDrop = (e, dropIndex) => {
e.preventDefault()
if (draggedIndex !== null && draggedIndex !== dropIndex) {
onReorder(draggedIndex, dropIndex)
}
setDraggedIndex(null)
}
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 p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Column Settings</h3>
<button onClick={onClose} 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>
<div className="space-y-3">
{columnConfig.map((column, index) => (
<div
key={column.id}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, index)}
className="flex items-center justify-between p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg cursor-move hover:bg-secondary-100 dark:hover:bg-secondary-600 transition-colors"
>
<div className="flex items-center gap-3">
<GripVertical className="h-4 w-4 text-secondary-400" />
<span className="text-sm font-medium text-secondary-900 dark:text-white">
{column.label}
</span>
</div>
<button
onClick={() => onToggleVisibility(column.id)}
className={`w-4 h-4 rounded border-2 flex items-center justify-center ${
column.visible
? 'bg-primary-600 border-primary-600'
: 'bg-white dark:bg-secondary-800 border-secondary-300 dark:border-secondary-600'
}`}
>
{column.visible && <Check className="h-3 w-3 text-white" />}
</button>
</div>
))}
</div>
<div className="flex justify-between mt-6">
<button
onClick={onReset}
className="px-4 py-2 text-sm text-secondary-600 dark:text-secondary-400 hover:text-secondary-800 dark:hover:text-secondary-200"
>
Reset to Default
</button>
<button
onClick={onClose}
className="px-4 py-2 bg-primary-600 text-white text-sm rounded-md hover:bg-primary-700 transition-colors"
>
Done
</button>
</div>
</div>
</div>
)
};
export default Repositories;
+1 -1
View File
@@ -339,7 +339,7 @@ const RepositoryDetail = () => {
to={`/hosts/${hostRepo.host.id}`}
className="text-primary-600 hover:text-primary-700 font-medium"
>
{hostRepo.host.hostname}
{hostRepo.host.friendlyName}
</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>
+2 -1
View File
@@ -63,7 +63,8 @@ export const adminHostsAPI = {
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 }),
toggleAutoUpdate: (hostId, autoUpdate) => api.patch(`/hosts/${hostId}/auto-update`, { autoUpdate })
toggleAutoUpdate: (hostId, autoUpdate) => api.patch(`/hosts/${hostId}/auto-update`, { autoUpdate }),
updateFriendlyName: (hostId, friendlyName) => api.patch(`/hosts/${hostId}/friendly-name`, { friendlyName })
}
// Host Groups API
+130
View File
@@ -0,0 +1,130 @@
import {
Monitor,
Server,
HardDrive,
Cpu,
Zap,
Shield,
Globe,
Terminal
} from 'lucide-react';
// Import OS icons from react-icons
import {
SiUbuntu,
SiDebian,
SiCentos,
SiFedora,
SiArchlinux,
SiAlpinelinux,
SiLinux,
SiMacos
} from 'react-icons/si';
import {
DiUbuntu,
DiDebian,
DiLinux,
DiWindows
} from 'react-icons/di';
/**
* OS Icon mapping utility
* Maps operating system types to appropriate react-icons components
*/
export const getOSIcon = (osType) => {
if (!osType) return Monitor;
const os = osType.toLowerCase();
// Linux distributions with authentic react-icons
if (os.includes('ubuntu')) return SiUbuntu;
if (os.includes('debian')) return SiDebian;
if (os.includes('centos') || os.includes('rhel') || os.includes('red hat')) return SiCentos;
if (os.includes('fedora')) return SiFedora;
if (os.includes('arch')) return SiArchlinux;
if (os.includes('alpine')) return SiAlpinelinux;
if (os.includes('suse') || os.includes('opensuse')) return SiLinux; // SUSE uses generic Linux icon
// Generic Linux
if (os.includes('linux')) return SiLinux;
// Windows
if (os.includes('windows')) return DiWindows;
// macOS
if (os.includes('mac') || os.includes('darwin')) return SiMacos;
// FreeBSD
if (os.includes('freebsd')) return Server;
// Default fallback
return Monitor;
};
/**
* OS Color mapping utility
* Maps operating system types to appropriate colors (react-icons have built-in brand colors)
*/
export const getOSColor = (osType) => {
if (!osType) return 'text-gray-500';
// react-icons already have the proper brand colors built-in
// This function is kept for compatibility but returns neutral colors
return 'text-gray-600';
};
/**
* OS Display name utility
* Provides clean, formatted OS names for display
*/
export const getOSDisplayName = (osType) => {
if (!osType) return 'Unknown';
const os = osType.toLowerCase();
// Linux distributions
if (os.includes('ubuntu')) return 'Ubuntu';
if (os.includes('debian')) return 'Debian';
if (os.includes('centos')) return 'CentOS';
if (os.includes('rhel') || os.includes('red hat')) return 'Red Hat Enterprise Linux';
if (os.includes('fedora')) return 'Fedora';
if (os.includes('arch')) return 'Arch Linux';
if (os.includes('suse')) return 'SUSE Linux';
if (os.includes('opensuse')) return 'openSUSE';
if (os.includes('alpine')) return 'Alpine Linux';
// Generic Linux
if (os.includes('linux')) return 'Linux';
// Windows
if (os.includes('windows')) return 'Windows';
// macOS
if (os.includes('mac') || os.includes('darwin')) return 'macOS';
// FreeBSD
if (os.includes('freebsd')) return 'FreeBSD';
// Return original if no match
return osType;
};
/**
* OS Icon component with proper styling
*/
export const OSIcon = ({ osType, className = "h-4 w-4", showText = false }) => {
const IconComponent = getOSIcon(osType);
const displayName = getOSDisplayName(osType);
if (showText) {
return (
<div className="flex items-center gap-2">
<IconComponent className={className} title={displayName} />
<span className="text-sm">{displayName}</span>
</div>
);
}
return <IconComponent className={className} title={displayName} />;
};
+10
View File
@@ -63,6 +63,7 @@
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.20.1"
},
"devDependencies": {
@@ -6201,6 +6202,15 @@
"react": "^18.3.1"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",