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

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

View File

@@ -0,0 +1 @@

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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,2 @@
-- RenameIndex
ALTER INDEX "hosts_hostname_key" RENAME TO "hosts_friendly_name_key";

View File

@@ -0,0 +1,2 @@
-- Rename hostname column to friendly_name in hosts table
ALTER TABLE "hosts" RENAME COLUMN "hostname" TO "friendly_name";

View File

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

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

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

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,

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

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;

View File

@@ -100,6 +100,7 @@ router.get('/', async (req, res) => {
host: {
select: {
id: true,
friendlyName: true,
hostname: true,
osType: true
}

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) {

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

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;

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +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": {

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;

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;

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'

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 = () => {

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

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>

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>

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;

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>

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

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
package-lock.json generated
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",