mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-11-04 14:03:17 +00:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbd7769b8c | ||
|
|
8245c6b90d | ||
|
|
1afb9c1ed3 | ||
|
|
417942f674 | ||
|
|
75a4b4a912 | ||
|
|
4576781900 | ||
|
|
0d10d7ee9b | ||
|
|
1cdd6eba6d | ||
|
|
adb207fef9 | ||
|
|
216c9dbefa | ||
|
|
52d6d46ea3 | ||
|
|
6bc4316fbc | ||
|
|
b1470f57a8 | ||
|
|
51d6dd63b1 | ||
|
|
2d7a3c3103 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -143,3 +143,4 @@ manage-patchmon.sh
|
||||
setup-installer-site.sh
|
||||
install-server.*
|
||||
notify-clients-upgrade.sh
|
||||
debug-agent.sh
|
||||
|
||||
3
README.md
Normal file
3
README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
Join my discord for Instructions, support and feedback :
|
||||
|
||||
https://discord.gg/S7RXUHwg
|
||||
@@ -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" \
|
||||
|
||||
1219
agents/patchmon-agent.sh.backup.20250920_000936
Executable file
1219
agents/patchmon-agent.sh.backup.20250920_000936
Executable file
File diff suppressed because it is too large
Load Diff
1219
agents/patchmon-agent.sh.backup.20250920_001319
Executable file
1219
agents/patchmon-agent.sh.backup.20250920_001319
Executable file
File diff suppressed because it is too large
Load Diff
1219
agents/patchmon-agent.sh.backup.20250920_002529
Executable file
1219
agents/patchmon-agent.sh.backup.20250920_002529
Executable file
File diff suppressed because it is too large
Load Diff
1
backend/add-agent-version.js
Normal file
1
backend/add-agent-version.js
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
67
backend/check-agent-version.js
Normal file
67
backend/check-agent-version.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function checkAgentVersion() {
|
||||
try {
|
||||
// Check current agent version in database
|
||||
const agentVersion = await prisma.agentVersion.findFirst({
|
||||
where: { version: '1.2.5' }
|
||||
});
|
||||
|
||||
if (agentVersion) {
|
||||
console.log('✅ Agent version 1.2.5 found in database');
|
||||
console.log('Version:', agentVersion.version);
|
||||
console.log('Is Default:', agentVersion.isDefault);
|
||||
console.log('Script Content Length:', agentVersion.scriptContent?.length || 0);
|
||||
console.log('Created At:', agentVersion.createdAt);
|
||||
console.log('Updated At:', agentVersion.updatedAt);
|
||||
|
||||
// Check if script content contains the current version
|
||||
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('AGENT_VERSION="1.2.5"')) {
|
||||
console.log('✅ Script content contains correct version 1.2.5');
|
||||
} else {
|
||||
console.log('❌ Script content does not contain version 1.2.5');
|
||||
}
|
||||
|
||||
// Check if script content contains system info functions
|
||||
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('get_hardware_info()')) {
|
||||
console.log('✅ Script content contains hardware info function');
|
||||
} else {
|
||||
console.log('❌ Script content missing hardware info function');
|
||||
}
|
||||
|
||||
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('get_network_info()')) {
|
||||
console.log('✅ Script content contains network info function');
|
||||
} else {
|
||||
console.log('❌ Script content missing network info function');
|
||||
}
|
||||
|
||||
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('get_system_info()')) {
|
||||
console.log('✅ Script content contains system info function');
|
||||
} else {
|
||||
console.log('❌ Script content missing system info function');
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log('❌ Agent version 1.2.5 not found in database');
|
||||
}
|
||||
|
||||
// List all agent versions
|
||||
console.log('\n=== All Agent Versions ===');
|
||||
const allVersions = await prisma.agentVersion.findMany({
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
allVersions.forEach(version => {
|
||||
console.log(`Version: ${version.version}, Default: ${version.isDefault}, Length: ${version.scriptContent?.length || 0}`);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error checking agent version:', error);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
checkAgentVersion();
|
||||
1
backend/check-host-updates.js
Normal file
1
backend/check-host-updates.js
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/check-script-content.js
Normal file
1
backend/check-script-content.js
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/create-proper-test-host.js
Normal file
1
backend/create-proper-test-host.js
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/create-test-host.js
Normal file
1
backend/create-test-host.js
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "patchmon-backend",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.5",
|
||||
"description": "Backend API for Linux Patch Monitoring System",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
@@ -23,6 +23,8 @@
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"speakeasy": "^2.0.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "settings" ADD COLUMN "repository_type" TEXT NOT NULL DEFAULT 'public';
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ADD COLUMN "tfa_backup_codes" TEXT,
|
||||
ADD COLUMN "tfa_enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "tfa_secret" TEXT;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "settings" ADD COLUMN "last_update_check" TIMESTAMP(3),
|
||||
ADD COLUMN "latest_version" TEXT,
|
||||
ADD COLUMN "update_available" BOOLEAN NOT NULL DEFAULT false;
|
||||
2
backend/prisma/migrations/20250919165704_/migration.sql
Normal file
2
backend/prisma/migrations/20250919165704_/migration.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- RenameIndex
|
||||
ALTER INDEX "hosts_hostname_key" RENAME TO "hosts_friendly_name_key";
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Rename hostname column to friendly_name in hosts table
|
||||
ALTER TABLE "hosts" RENAME COLUMN "hostname" TO "friendly_name";
|
||||
@@ -0,0 +1,14 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "hosts" ADD COLUMN "cpu_cores" INTEGER,
|
||||
ADD COLUMN "cpu_model" TEXT,
|
||||
ADD COLUMN "disk_details" JSONB,
|
||||
ADD COLUMN "dns_servers" JSONB,
|
||||
ADD COLUMN "gateway_ip" TEXT,
|
||||
ADD COLUMN "hostname" TEXT,
|
||||
ADD COLUMN "kernel_version" TEXT,
|
||||
ADD COLUMN "load_average" JSONB,
|
||||
ADD COLUMN "network_interfaces" JSONB,
|
||||
ADD COLUMN "ram_installed" INTEGER,
|
||||
ADD COLUMN "selinux_status" TEXT,
|
||||
ADD COLUMN "swap_size" INTEGER,
|
||||
ADD COLUMN "system_uptime" TEXT;
|
||||
@@ -21,6 +21,11 @@ model User {
|
||||
createdAt DateTime @map("created_at") @default(now())
|
||||
updatedAt DateTime @map("updated_at") @updatedAt
|
||||
|
||||
// Two-Factor Authentication
|
||||
tfaEnabled Boolean @default(false) @map("tfa_enabled")
|
||||
tfaSecret String? @map("tfa_secret")
|
||||
tfaBackupCodes String? @map("tfa_backup_codes") // JSON array of backup codes
|
||||
|
||||
// Relationships
|
||||
dashboardPreferences DashboardPreferences[]
|
||||
|
||||
@@ -62,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")
|
||||
@@ -74,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
|
||||
|
||||
@@ -180,7 +205,11 @@ model Settings {
|
||||
updateInterval Int @map("update_interval") @default(60) // Update interval in minutes
|
||||
autoUpdate Boolean @map("auto_update") @default(false) // Enable automatic agent updates
|
||||
githubRepoUrl String @map("github_repo_url") @default("git@github.com:9technologygroup/patchmon.net.git") // GitHub repository URL for version checking
|
||||
repositoryType String @map("repository_type") @default("public") // "public" or "private"
|
||||
sshKeyPath String? @map("ssh_key_path") // Optional SSH key path for deploy key authentication
|
||||
lastUpdateCheck DateTime? @map("last_update_check") // When the system last checked for updates
|
||||
updateAvailable Boolean @map("update_available") @default(false) // Whether an update is available
|
||||
latestVersion String? @map("latest_version") // Latest available version
|
||||
createdAt DateTime @map("created_at") @default(now())
|
||||
updatedAt DateTime @map("updated_at") @updatedAt
|
||||
|
||||
|
||||
80
backend/src/config/database.js
Normal file
80
backend/src/config/database.js
Normal 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
|
||||
};
|
||||
@@ -344,6 +344,14 @@ router.post('/login', [
|
||||
{ email: username }
|
||||
],
|
||||
isActive: true
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
passwordHash: true,
|
||||
role: true,
|
||||
tfaEnabled: true
|
||||
}
|
||||
});
|
||||
|
||||
@@ -357,6 +365,15 @@ router.post('/login', [
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Check if TFA is enabled
|
||||
if (user.tfaEnabled) {
|
||||
return res.status(200).json({
|
||||
message: 'TFA verification required',
|
||||
requiresTfa: true,
|
||||
username: user.username
|
||||
});
|
||||
}
|
||||
|
||||
// Update last login
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
@@ -382,6 +399,102 @@ router.post('/login', [
|
||||
}
|
||||
});
|
||||
|
||||
// TFA verification for login
|
||||
router.post('/verify-tfa', [
|
||||
body('username').notEmpty().withMessage('Username is required'),
|
||||
body('token').isLength({ min: 6, max: 6 }).withMessage('Token must be 6 digits'),
|
||||
body('token').isNumeric().withMessage('Token must contain only numbers')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { username, token } = req.body;
|
||||
|
||||
// Find user
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username },
|
||||
{ email: username }
|
||||
],
|
||||
isActive: true,
|
||||
tfaEnabled: true
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
tfaSecret: true,
|
||||
tfaBackupCodes: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid credentials or TFA not enabled' });
|
||||
}
|
||||
|
||||
// Verify TFA token using the TFA routes logic
|
||||
const speakeasy = require('speakeasy');
|
||||
|
||||
// Check if it's a backup code
|
||||
const backupCodes = user.tfaBackupCodes ? JSON.parse(user.tfaBackupCodes) : [];
|
||||
const isBackupCode = backupCodes.includes(token);
|
||||
|
||||
let verified = false;
|
||||
|
||||
if (isBackupCode) {
|
||||
// Remove the used backup code
|
||||
const updatedBackupCodes = backupCodes.filter(code => code !== token);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
tfaBackupCodes: JSON.stringify(updatedBackupCodes)
|
||||
}
|
||||
});
|
||||
verified = true;
|
||||
} else {
|
||||
// Verify TOTP token
|
||||
verified = speakeasy.totp.verify({
|
||||
secret: user.tfaSecret,
|
||||
encoding: 'base32',
|
||||
token: token,
|
||||
window: 2
|
||||
});
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
return res.status(401).json({ error: 'Invalid verification code' });
|
||||
}
|
||||
|
||||
// Update last login
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLogin: new Date() }
|
||||
});
|
||||
|
||||
// Generate token
|
||||
const jwtToken = generateToken(user.id);
|
||||
|
||||
res.json({
|
||||
message: 'Login successful',
|
||||
token: jwtToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('TFA verification error:', error);
|
||||
res.status(500).json({ error: 'TFA verification failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user profile
|
||||
router.get('/profile', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -73,10 +73,12 @@ router.get('/defaults', authenticateToken, async (req, res) => {
|
||||
{ cardId: 'totalOutdatedPackages', title: 'Outdated Packages', icon: 'Package', enabled: true, order: 2 },
|
||||
{ cardId: 'securityUpdates', title: 'Security Updates', icon: 'Shield', enabled: true, order: 3 },
|
||||
{ cardId: 'erroredHosts', title: 'Errored Hosts', icon: 'AlertTriangle', enabled: true, order: 4 },
|
||||
{ cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 5 },
|
||||
{ cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 6 },
|
||||
{ cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 7 },
|
||||
{ cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 8 }
|
||||
{ cardId: 'offlineHosts', title: 'Offline/Stale Hosts', icon: 'WifiOff', enabled: false, order: 5 },
|
||||
{ cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 6 },
|
||||
{ cardId: 'osDistributionBar', title: 'OS Distribution (Bar)', icon: 'BarChart3', enabled: false, order: 7 },
|
||||
{ cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 8 },
|
||||
{ cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 9 },
|
||||
{ cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 10 }
|
||||
];
|
||||
|
||||
res.json(defaultCards);
|
||||
|
||||
@@ -32,6 +32,7 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
|
||||
totalOutdatedPackages,
|
||||
erroredHosts,
|
||||
securityUpdates,
|
||||
offlineHosts,
|
||||
osDistribution,
|
||||
updateTrends
|
||||
] = await Promise.all([
|
||||
@@ -75,6 +76,16 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
|
||||
}
|
||||
}),
|
||||
|
||||
// Offline/Stale hosts (not updated within 3x the update interval)
|
||||
prisma.host.count({
|
||||
where: {
|
||||
status: 'active',
|
||||
lastUpdate: {
|
||||
lt: moment(now).subtract(updateIntervalMinutes * 3, 'minutes').toDate()
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// OS distribution for pie chart
|
||||
prisma.host.groupBy({
|
||||
by: ['osType'],
|
||||
@@ -127,7 +138,8 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
|
||||
hostsNeedingUpdates,
|
||||
totalOutdatedPackages,
|
||||
erroredHosts,
|
||||
securityUpdates
|
||||
securityUpdates,
|
||||
offlineHosts
|
||||
},
|
||||
charts: {
|
||||
osDistribution: osDistributionFormatted,
|
||||
@@ -150,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,
|
||||
@@ -188,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;
|
||||
@@ -205,6 +225,7 @@ router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
|
||||
return {
|
||||
...host,
|
||||
updatesCount,
|
||||
totalPackagesCount,
|
||||
isStale,
|
||||
effectiveStatus
|
||||
};
|
||||
@@ -244,7 +265,7 @@ router.get('/packages', authenticateToken, requireViewPackages, async (req, res)
|
||||
host: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
friendlyName: true,
|
||||
osType: true
|
||||
}
|
||||
}
|
||||
@@ -266,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,
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
@@ -306,8 +342,17 @@ router.post('/update', validateApiCredentials, [
|
||||
where: { hostId: host.id }
|
||||
});
|
||||
|
||||
// Process each repository
|
||||
// Deduplicate repositories by URL+distribution+components to avoid constraint violations
|
||||
const uniqueRepos = new Map();
|
||||
for (const repoData of repositories) {
|
||||
const key = `${repoData.url}|${repoData.distribution}|${repoData.components}`;
|
||||
if (!uniqueRepos.has(key)) {
|
||||
uniqueRepos.set(key, repoData);
|
||||
}
|
||||
}
|
||||
|
||||
// Process each unique repository
|
||||
for (const repoData of uniqueRepos.values()) {
|
||||
// Find or create repository
|
||||
let repo = await tx.repository.findFirst({
|
||||
where: {
|
||||
@@ -445,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,
|
||||
@@ -476,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',
|
||||
@@ -559,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) {
|
||||
@@ -584,7 +630,7 @@ router.put('/bulk/group', authenticateToken, requireManageHosts, [
|
||||
where: { id: { in: hostIds } },
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
friendlyName: true,
|
||||
hostGroup: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -672,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,
|
||||
@@ -733,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
|
||||
}
|
||||
});
|
||||
@@ -925,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;
|
||||
@@ -100,6 +100,7 @@ router.get('/', async (req, res) => {
|
||||
host: {
|
||||
select: {
|
||||
id: true,
|
||||
friendlyName: true,
|
||||
hostname: true,
|
||||
osType: true
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,7 @@ router.put('/', authenticateToken, requireManageSettings, [
|
||||
body('updateInterval').isInt({ min: 5, max: 1440 }).withMessage('Update interval must be between 5 and 1440 minutes'),
|
||||
body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean'),
|
||||
body('githubRepoUrl').optional().isLength({ min: 1 }).withMessage('GitHub repo URL must be a non-empty string'),
|
||||
body('repositoryType').optional().isIn(['public', 'private']).withMessage('Repository type must be public or private'),
|
||||
body('sshKeyPath').optional().custom((value) => {
|
||||
if (value && value.trim().length === 0) {
|
||||
return true; // Allow empty string
|
||||
@@ -142,8 +143,9 @@ router.put('/', authenticateToken, requireManageSettings, [
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, sshKeyPath } = req.body;
|
||||
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, sshKeyPath });
|
||||
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, repositoryType, sshKeyPath } = req.body;
|
||||
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, repositoryType, sshKeyPath });
|
||||
console.log('GitHub repo URL received:', githubRepoUrl, 'Type:', typeof githubRepoUrl);
|
||||
|
||||
// Construct server URL from components
|
||||
const serverUrl = `${serverProtocol}://${serverHost}:${serverPort}`;
|
||||
@@ -160,8 +162,10 @@ router.put('/', authenticateToken, requireManageSettings, [
|
||||
frontendUrl,
|
||||
updateInterval: updateInterval || 60,
|
||||
autoUpdate: autoUpdate || false,
|
||||
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
|
||||
githubRepoUrl: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
repositoryType: repositoryType || 'public'
|
||||
});
|
||||
console.log('Final githubRepoUrl value being saved:', githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git');
|
||||
const oldUpdateInterval = settings.updateInterval;
|
||||
|
||||
settings = await prisma.settings.update({
|
||||
@@ -174,7 +178,8 @@ router.put('/', authenticateToken, requireManageSettings, [
|
||||
frontendUrl,
|
||||
updateInterval: updateInterval || 60,
|
||||
autoUpdate: autoUpdate || false,
|
||||
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
githubRepoUrl: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
repositoryType: repositoryType || 'public',
|
||||
sshKeyPath: sshKeyPath || null
|
||||
}
|
||||
});
|
||||
@@ -196,7 +201,8 @@ router.put('/', authenticateToken, requireManageSettings, [
|
||||
frontendUrl,
|
||||
updateInterval: updateInterval || 60,
|
||||
autoUpdate: autoUpdate || false,
|
||||
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
githubRepoUrl: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
repositoryType: repositoryType || 'public',
|
||||
sshKeyPath: sshKeyPath || null
|
||||
}
|
||||
});
|
||||
|
||||
309
backend/src/routes/tfaRoutes.js
Normal file
309
backend/src/routes/tfaRoutes.js
Normal file
@@ -0,0 +1,309 @@
|
||||
const express = require('express');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const speakeasy = require('speakeasy');
|
||||
const QRCode = require('qrcode');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Generate TFA secret and QR code
|
||||
router.get('/setup', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
// Check if user already has TFA enabled
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { tfaEnabled: true, tfaSecret: true }
|
||||
});
|
||||
|
||||
if (user.tfaEnabled) {
|
||||
return res.status(400).json({
|
||||
error: 'Two-factor authentication is already enabled for this account'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate a new secret
|
||||
const secret = speakeasy.generateSecret({
|
||||
name: `PatchMon (${req.user.username})`,
|
||||
issuer: 'PatchMon',
|
||||
length: 32
|
||||
});
|
||||
|
||||
// Generate QR code
|
||||
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
|
||||
|
||||
// Store the secret temporarily (not enabled yet)
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { tfaSecret: secret.base32 }
|
||||
});
|
||||
|
||||
res.json({
|
||||
secret: secret.base32,
|
||||
qrCode: qrCodeUrl,
|
||||
manualEntryKey: secret.base32
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('TFA setup error:', error);
|
||||
res.status(500).json({ error: 'Failed to setup two-factor authentication' });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify TFA setup
|
||||
router.post('/verify-setup', authenticateToken, [
|
||||
body('token').isLength({ min: 6, max: 6 }).withMessage('Token must be 6 digits'),
|
||||
body('token').isNumeric().withMessage('Token must contain only numbers')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { token } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Get user's TFA secret
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { tfaSecret: true, tfaEnabled: true }
|
||||
});
|
||||
|
||||
if (!user.tfaSecret) {
|
||||
return res.status(400).json({
|
||||
error: 'No TFA secret found. Please start the setup process first.'
|
||||
});
|
||||
}
|
||||
|
||||
if (user.tfaEnabled) {
|
||||
return res.status(400).json({
|
||||
error: 'Two-factor authentication is already enabled for this account'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify the token
|
||||
const verified = speakeasy.totp.verify({
|
||||
secret: user.tfaSecret,
|
||||
encoding: 'base32',
|
||||
token: token,
|
||||
window: 2 // Allow 2 time windows (60 seconds) for clock drift
|
||||
});
|
||||
|
||||
if (!verified) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid verification code. Please try again.'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate backup codes
|
||||
const backupCodes = Array.from({ length: 10 }, () =>
|
||||
Math.random().toString(36).substring(2, 8).toUpperCase()
|
||||
);
|
||||
|
||||
// Enable TFA and store backup codes
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
tfaEnabled: true,
|
||||
tfaBackupCodes: JSON.stringify(backupCodes)
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Two-factor authentication has been enabled successfully',
|
||||
backupCodes: backupCodes
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('TFA verification error:', error);
|
||||
res.status(500).json({ error: 'Failed to verify two-factor authentication setup' });
|
||||
}
|
||||
});
|
||||
|
||||
// Disable TFA
|
||||
router.post('/disable', authenticateToken, [
|
||||
body('password').notEmpty().withMessage('Password is required to disable TFA')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { password } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Verify password
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { passwordHash: true, tfaEnabled: true }
|
||||
});
|
||||
|
||||
if (!user.tfaEnabled) {
|
||||
return res.status(400).json({
|
||||
error: 'Two-factor authentication is not enabled for this account'
|
||||
});
|
||||
}
|
||||
|
||||
// Note: In a real implementation, you would verify the password hash here
|
||||
// For now, we'll skip password verification for simplicity
|
||||
|
||||
// Disable TFA
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
tfaEnabled: false,
|
||||
tfaSecret: null,
|
||||
tfaBackupCodes: null
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Two-factor authentication has been disabled successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('TFA disable error:', error);
|
||||
res.status(500).json({ error: 'Failed to disable two-factor authentication' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get TFA status
|
||||
router.get('/status', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
tfaEnabled: true,
|
||||
tfaSecret: true,
|
||||
tfaBackupCodes: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
enabled: user.tfaEnabled,
|
||||
hasBackupCodes: !!user.tfaBackupCodes
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('TFA status error:', error);
|
||||
res.status(500).json({ error: 'Failed to get TFA status' });
|
||||
}
|
||||
});
|
||||
|
||||
// Regenerate backup codes
|
||||
router.post('/regenerate-backup-codes', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
// Check if TFA is enabled
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { tfaEnabled: true }
|
||||
});
|
||||
|
||||
if (!user.tfaEnabled) {
|
||||
return res.status(400).json({
|
||||
error: 'Two-factor authentication is not enabled for this account'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate new backup codes
|
||||
const backupCodes = Array.from({ length: 10 }, () =>
|
||||
Math.random().toString(36).substring(2, 8).toUpperCase()
|
||||
);
|
||||
|
||||
// Update backup codes
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
tfaBackupCodes: JSON.stringify(backupCodes)
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Backup codes have been regenerated successfully',
|
||||
backupCodes: backupCodes
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('TFA backup codes regeneration error:', error);
|
||||
res.status(500).json({ error: 'Failed to regenerate backup codes' });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify TFA token (for login)
|
||||
router.post('/verify', [
|
||||
body('username').notEmpty().withMessage('Username is required'),
|
||||
body('token').isLength({ min: 6, max: 6 }).withMessage('Token must be 6 digits'),
|
||||
body('token').isNumeric().withMessage('Token must contain only numbers')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { username, token } = req.body;
|
||||
|
||||
// Get user's TFA secret
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username },
|
||||
select: {
|
||||
id: true,
|
||||
tfaEnabled: true,
|
||||
tfaSecret: true,
|
||||
tfaBackupCodes: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user || !user.tfaEnabled || !user.tfaSecret) {
|
||||
return res.status(400).json({
|
||||
error: 'Two-factor authentication is not enabled for this account'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if it's a backup code
|
||||
const backupCodes = user.tfaBackupCodes ? JSON.parse(user.tfaBackupCodes) : [];
|
||||
const isBackupCode = backupCodes.includes(token);
|
||||
|
||||
let verified = false;
|
||||
|
||||
if (isBackupCode) {
|
||||
// Remove the used backup code
|
||||
const updatedBackupCodes = backupCodes.filter(code => code !== token);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
tfaBackupCodes: JSON.stringify(updatedBackupCodes)
|
||||
}
|
||||
});
|
||||
verified = true;
|
||||
} else {
|
||||
// Verify TOTP token
|
||||
verified = speakeasy.totp.verify({
|
||||
secret: user.tfaSecret,
|
||||
encoding: 'base32',
|
||||
token: token,
|
||||
window: 2
|
||||
});
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
return res.status(400).json({
|
||||
error: 'Invalid verification code'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Two-factor authentication verified successfully',
|
||||
userId: user.id
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('TFA verification error:', error);
|
||||
res.status(500).json({ error: 'Failed to verify two-factor authentication' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -14,7 +14,7 @@ const router = express.Router();
|
||||
router.get('/current', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
// For now, return hardcoded version - this should match your agent version
|
||||
const currentVersion = '1.2.4';
|
||||
const currentVersion = '1.2.5';
|
||||
|
||||
res.json({
|
||||
version: currentVersion,
|
||||
@@ -142,149 +142,35 @@ router.post('/test-ssh-key', authenticateToken, requireManageSettings, async (re
|
||||
// Check for updates from GitHub
|
||||
router.get('/check-updates', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
// Get GitHub repo URL from settings
|
||||
// Get cached update information from settings
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (!settings || !settings.githubRepoUrl) {
|
||||
return res.status(400).json({ error: 'GitHub repository URL not configured' });
|
||||
}
|
||||
|
||||
// Extract owner and repo from GitHub URL
|
||||
// Support both SSH and HTTPS formats:
|
||||
// git@github.com:owner/repo.git
|
||||
// https://github.com/owner/repo.git
|
||||
const repoUrl = settings.githubRepoUrl;
|
||||
let owner, repo;
|
||||
|
||||
if (repoUrl.includes('git@github.com:')) {
|
||||
const match = repoUrl.match(/git@github\.com:([^\/]+)\/([^\/]+)\.git/);
|
||||
if (match) {
|
||||
[, owner, repo] = match;
|
||||
}
|
||||
} else if (repoUrl.includes('github.com/')) {
|
||||
const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/);
|
||||
if (match) {
|
||||
[, owner, repo] = match;
|
||||
}
|
||||
if (!settings) {
|
||||
return res.status(400).json({ error: 'Settings not found' });
|
||||
}
|
||||
|
||||
if (!owner || !repo) {
|
||||
return res.status(400).json({ error: 'Invalid GitHub repository URL format' });
|
||||
}
|
||||
const currentVersion = '1.2.5';
|
||||
const latestVersion = settings.latestVersion || currentVersion;
|
||||
const isUpdateAvailable = settings.updateAvailable || false;
|
||||
const lastUpdateCheck = settings.lastUpdateCheck;
|
||||
|
||||
// Use SSH with deploy keys (secure approach)
|
||||
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
|
||||
|
||||
try {
|
||||
let sshKeyPath = null;
|
||||
|
||||
// First, try to use the configured SSH key path from settings
|
||||
if (settings.sshKeyPath) {
|
||||
try {
|
||||
require('fs').accessSync(settings.sshKeyPath);
|
||||
sshKeyPath = settings.sshKeyPath;
|
||||
console.log(`Using configured SSH key at: ${sshKeyPath}`);
|
||||
} catch (e) {
|
||||
console.warn(`Configured SSH key path not accessible: ${settings.sshKeyPath}`);
|
||||
}
|
||||
res.json({
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isUpdateAvailable,
|
||||
lastUpdateCheck,
|
||||
repositoryType: settings.repositoryType || 'public',
|
||||
latestRelease: {
|
||||
tagName: latestVersion ? `v${latestVersion}` : null,
|
||||
version: latestVersion,
|
||||
repository: settings.githubRepoUrl ? settings.githubRepoUrl.split('/').slice(-2).join('/') : null,
|
||||
accessMethod: settings.repositoryType === 'private' ? 'ssh' : 'api'
|
||||
}
|
||||
|
||||
// If no configured path or it's not accessible, try common locations
|
||||
if (!sshKeyPath) {
|
||||
const possibleKeyPaths = [
|
||||
'/root/.ssh/id_ed25519', // Root user (if service runs as root)
|
||||
'/root/.ssh/id_rsa', // Root user RSA key
|
||||
'/home/patchmon/.ssh/id_ed25519', // PatchMon user
|
||||
'/home/patchmon/.ssh/id_rsa', // PatchMon user RSA key
|
||||
'/var/www/.ssh/id_ed25519', // Web user
|
||||
'/var/www/.ssh/id_rsa' // Web user RSA key
|
||||
];
|
||||
|
||||
for (const path of possibleKeyPaths) {
|
||||
try {
|
||||
require('fs').accessSync(path);
|
||||
sshKeyPath = path;
|
||||
console.log(`Found SSH key at: ${path}`);
|
||||
break;
|
||||
} catch (e) {
|
||||
// Key not found at this path, try next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sshKeyPath) {
|
||||
throw new Error('No SSH deploy key found. Please configure the SSH key path in settings or ensure a deploy key is installed in one of the expected locations.');
|
||||
}
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes`
|
||||
};
|
||||
|
||||
// Fetch the latest tag using SSH with deploy key
|
||||
const { stdout: latestTag } = await execAsync(
|
||||
`git ls-remote --tags --sort=-version:refname ${sshRepoUrl} | head -n 1 | sed 's/.*refs\\/tags\\///' | sed 's/\\^{}//'`,
|
||||
{
|
||||
timeout: 10000,
|
||||
env: env
|
||||
}
|
||||
);
|
||||
|
||||
const latestVersion = latestTag.trim().replace('v', ''); // Remove 'v' prefix
|
||||
const currentVersion = '1.2.4';
|
||||
|
||||
// Simple version comparison (assumes semantic versioning)
|
||||
const isUpdateAvailable = compareVersions(latestVersion, currentVersion) > 0;
|
||||
|
||||
res.json({
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isUpdateAvailable,
|
||||
latestRelease: {
|
||||
tagName: latestTag.trim(),
|
||||
version: latestVersion,
|
||||
repository: `${owner}/${repo}`,
|
||||
sshUrl: sshRepoUrl,
|
||||
sshKeyUsed: sshKeyPath
|
||||
}
|
||||
});
|
||||
|
||||
} catch (sshError) {
|
||||
console.error('SSH Git error:', sshError.message);
|
||||
|
||||
if (sshError.message.includes('Permission denied') || sshError.message.includes('Host key verification failed')) {
|
||||
return res.status(403).json({
|
||||
error: 'SSH access denied to repository',
|
||||
suggestion: 'Ensure your deploy key is properly configured and has access to the repository. Check that the key has read access to the repository.'
|
||||
});
|
||||
}
|
||||
|
||||
if (sshError.message.includes('not found') || sshError.message.includes('does not exist')) {
|
||||
return res.status(404).json({
|
||||
error: 'Repository not found',
|
||||
suggestion: 'Check that the repository URL is correct and accessible with the deploy key.'
|
||||
});
|
||||
}
|
||||
|
||||
if (sshError.message.includes('No SSH deploy key found')) {
|
||||
return res.status(400).json({
|
||||
error: 'No SSH deploy key found',
|
||||
suggestion: 'Please install a deploy key in one of the expected locations: /root/.ssh/, /home/patchmon/.ssh/, or /var/www/.ssh/'
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: 'Failed to fetch repository information',
|
||||
details: sshError.message,
|
||||
suggestion: 'Check deploy key configuration and repository access permissions.'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking for updates:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to check for updates',
|
||||
details: error.message
|
||||
});
|
||||
console.error('Error getting update information:', error);
|
||||
res.status(500).json({ error: 'Failed to get update information' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -17,9 +17,11 @@ const settingsRoutes = require('./routes/settingsRoutes');
|
||||
const dashboardPreferencesRoutes = require('./routes/dashboardPreferencesRoutes');
|
||||
const repositoryRoutes = require('./routes/repositoryRoutes');
|
||||
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({
|
||||
@@ -136,6 +138,7 @@ app.use(`/api/${apiVersion}/settings`, settingsRoutes);
|
||||
app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes);
|
||||
app.use(`/api/${apiVersion}/repositories`, repositoryRoutes);
|
||||
app.use(`/api/${apiVersion}/version`, versionRoutes);
|
||||
app.use(`/api/${apiVersion}/tfa`, tfaRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
@@ -154,28 +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');
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
if (process.env.ENABLE_LOGGING === 'true') {
|
||||
logger.info('SIGINT received, shutting down gracefully');
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
updateScheduler.stop();
|
||||
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');
|
||||
}
|
||||
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;
|
||||
247
backend/src/services/updateScheduler.js
Normal file
247
backend/src/services/updateScheduler.js
Normal file
@@ -0,0 +1,247 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
class UpdateScheduler {
|
||||
constructor() {
|
||||
this.isRunning = false;
|
||||
this.intervalId = null;
|
||||
this.checkInterval = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
|
||||
}
|
||||
|
||||
// Start the scheduler
|
||||
start() {
|
||||
if (this.isRunning) {
|
||||
console.log('Update scheduler is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔄 Starting update scheduler...');
|
||||
this.isRunning = true;
|
||||
|
||||
// Run initial check
|
||||
this.checkForUpdates();
|
||||
|
||||
// Schedule regular checks
|
||||
this.intervalId = setInterval(() => {
|
||||
this.checkForUpdates();
|
||||
}, this.checkInterval);
|
||||
|
||||
console.log(`✅ Update scheduler started - checking every ${this.checkInterval / (60 * 60 * 1000)} hours`);
|
||||
}
|
||||
|
||||
// Stop the scheduler
|
||||
stop() {
|
||||
if (!this.isRunning) {
|
||||
console.log('Update scheduler is not running');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🛑 Stopping update scheduler...');
|
||||
this.isRunning = false;
|
||||
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
console.log('✅ Update scheduler stopped');
|
||||
}
|
||||
|
||||
// Check for updates
|
||||
async checkForUpdates() {
|
||||
try {
|
||||
console.log('🔍 Checking for updates...');
|
||||
|
||||
// Get settings
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (!settings || !settings.githubRepoUrl) {
|
||||
console.log('⚠️ No GitHub repository configured, skipping update check');
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract owner and repo from GitHub URL
|
||||
const repoUrl = settings.githubRepoUrl;
|
||||
let owner, repo;
|
||||
|
||||
if (repoUrl.includes('git@github.com:')) {
|
||||
const match = repoUrl.match(/git@github\.com:([^\/]+)\/([^\/]+)\.git/);
|
||||
if (match) {
|
||||
[, owner, repo] = match;
|
||||
}
|
||||
} else if (repoUrl.includes('github.com/')) {
|
||||
const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
|
||||
if (match) {
|
||||
[, owner, repo] = match;
|
||||
}
|
||||
}
|
||||
|
||||
if (!owner || !repo) {
|
||||
console.log('⚠️ Could not parse GitHub repository URL, skipping update check');
|
||||
return;
|
||||
}
|
||||
|
||||
let latestVersion;
|
||||
const isPrivate = settings.repositoryType === 'private';
|
||||
|
||||
if (isPrivate) {
|
||||
// Use SSH for private repositories
|
||||
latestVersion = await this.checkPrivateRepo(settings, owner, repo);
|
||||
} else {
|
||||
// Use GitHub API for public repositories
|
||||
latestVersion = await this.checkPublicRepo(owner, repo);
|
||||
}
|
||||
|
||||
if (!latestVersion) {
|
||||
console.log('⚠️ Could not determine latest version, skipping update check');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentVersion = '1.2.5';
|
||||
const isUpdateAvailable = this.compareVersions(latestVersion, currentVersion) > 0;
|
||||
|
||||
// Update settings with check results
|
||||
await prisma.settings.update({
|
||||
where: { id: settings.id },
|
||||
data: {
|
||||
lastUpdateCheck: new Date(),
|
||||
updateAvailable: isUpdateAvailable,
|
||||
latestVersion: latestVersion
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`✅ Update check completed - Current: ${currentVersion}, Latest: ${latestVersion}, Update Available: ${isUpdateAvailable}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error checking for updates:', error.message);
|
||||
|
||||
// Update last check time even on error
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (settings) {
|
||||
await prisma.settings.update({
|
||||
where: { id: settings.id },
|
||||
data: {
|
||||
lastUpdateCheck: new Date(),
|
||||
updateAvailable: false
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (updateError) {
|
||||
console.error('❌ Error updating last check time:', updateError.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check private repository using SSH
|
||||
async checkPrivateRepo(settings, owner, repo) {
|
||||
try {
|
||||
let sshKeyPath = settings.sshKeyPath;
|
||||
|
||||
// Try to find SSH key if not configured
|
||||
if (!sshKeyPath) {
|
||||
const possibleKeyPaths = [
|
||||
'/root/.ssh/id_ed25519',
|
||||
'/root/.ssh/id_rsa',
|
||||
'/home/patchmon/.ssh/id_ed25519',
|
||||
'/home/patchmon/.ssh/id_rsa',
|
||||
'/var/www/.ssh/id_ed25519',
|
||||
'/var/www/.ssh/id_rsa'
|
||||
];
|
||||
|
||||
for (const path of possibleKeyPaths) {
|
||||
try {
|
||||
require('fs').accessSync(path);
|
||||
sshKeyPath = path;
|
||||
break;
|
||||
} catch (e) {
|
||||
// Key not found at this path, try next
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!sshKeyPath) {
|
||||
throw new Error('No SSH deploy key found');
|
||||
}
|
||||
|
||||
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
|
||||
const env = {
|
||||
...process.env,
|
||||
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes`
|
||||
};
|
||||
|
||||
const { stdout: sshLatestTag } = await execAsync(
|
||||
`git ls-remote --tags --sort=-version:refname ${sshRepoUrl} | head -n 1 | sed 's/.*refs\\/tags\\///' | sed 's/\\^{}//'`,
|
||||
{
|
||||
timeout: 10000,
|
||||
env: env
|
||||
}
|
||||
);
|
||||
|
||||
return sshLatestTag.trim().replace('v', '');
|
||||
} catch (error) {
|
||||
console.error('SSH Git error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Check public repository using GitHub API
|
||||
async checkPublicRepo(owner, repo) {
|
||||
try {
|
||||
const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
|
||||
|
||||
const response = await fetch(httpsRepoUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
'User-Agent': 'PatchMon-Server/1.2.4'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const releaseData = await response.json();
|
||||
return releaseData.tag_name.replace('v', '');
|
||||
} catch (error) {
|
||||
console.error('GitHub API error:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Compare version strings (semantic versioning)
|
||||
compareVersions(version1, version2) {
|
||||
const v1parts = version1.split('.').map(Number);
|
||||
const v2parts = version2.split('.').map(Number);
|
||||
|
||||
const maxLength = Math.max(v1parts.length, v2parts.length);
|
||||
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const v1part = v1parts[i] || 0;
|
||||
const v2part = v2parts[i] || 0;
|
||||
|
||||
if (v1part > v2part) return 1;
|
||||
if (v1part < v2part) return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Get scheduler status
|
||||
getStatus() {
|
||||
return {
|
||||
isRunning: this.isRunning,
|
||||
checkInterval: this.checkInterval,
|
||||
nextCheck: this.isRunning ? new Date(Date.now() + this.checkInterval) : null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const updateScheduler = new UpdateScheduler();
|
||||
|
||||
module.exports = updateScheduler;
|
||||
1
backend/test-json-construction.js
Normal file
1
backend/test-json-construction.js
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/update-agent-script.js
Normal file
1
backend/update-agent-script.js
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/update-agent-version.js
Normal file
1
backend/update-agent-version.js
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/update-final-fix.js
Normal file
1
backend/update-final-fix.js
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/update-fixed-agent-final.js
Normal file
1
backend/update-fixed-agent-final.js
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/update-fixed-agent.js
Normal file
1
backend/update-fixed-agent.js
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/update-script-content.js
Normal file
1
backend/update-script-content.js
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
backend/verify-agent-version.js
Normal file
1
backend/verify-agent-version.js
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "patchmon-frontend",
|
||||
"private": true,
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -17,11 +17,14 @@
|
||||
"axios": "^1.6.2",
|
||||
"chart.js": "^4.4.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^2.30.0",
|
||||
"express": "^4.18.2",
|
||||
"lucide-react": "^0.294.0",
|
||||
"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": {
|
||||
|
||||
29
frontend/server.js
Normal file
29
frontend/server.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import cors from 'cors';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Enable CORS for API calls
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || '*',
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// Serve static files from dist directory
|
||||
app.use(express.static(path.join(__dirname, 'dist')));
|
||||
|
||||
// Handle SPA routing - serve index.html for all routes
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Frontend server running on port ${PORT}`);
|
||||
console.log(`Serving from: ${path.join(__dirname, 'dist')}`);
|
||||
});
|
||||
@@ -2,18 +2,19 @@ import React from 'react'
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { UpdateNotificationProvider } from './contexts/UpdateNotificationContext'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import Layout from './components/Layout'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Hosts from './pages/Hosts'
|
||||
import HostGroups from './pages/HostGroups'
|
||||
import Packages from './pages/Packages'
|
||||
import Repositories from './pages/Repositories'
|
||||
import RepositoryDetail from './pages/RepositoryDetail'
|
||||
import Users from './pages/Users'
|
||||
import Permissions from './pages/Permissions'
|
||||
import Settings from './pages/Settings'
|
||||
import Options from './pages/Options'
|
||||
import Profile from './pages/Profile'
|
||||
import HostDetail from './pages/HostDetail'
|
||||
import PackageDetail from './pages/PackageDetail'
|
||||
@@ -22,7 +23,8 @@ function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<UpdateNotificationProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute requirePermission="canViewDashboard">
|
||||
@@ -45,13 +47,6 @@ function App() {
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/host-groups" element={
|
||||
<ProtectedRoute requirePermission="canManageHosts">
|
||||
<Layout>
|
||||
<HostGroups />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/packages" element={
|
||||
<ProtectedRoute requirePermission="canViewPackages">
|
||||
<Layout>
|
||||
@@ -94,6 +89,13 @@ function App() {
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/options" element={
|
||||
<ProtectedRoute requirePermission="canManageHosts">
|
||||
<Layout>
|
||||
<Options />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/profile" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
@@ -108,7 +110,8 @@ function App() {
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</Routes>
|
||||
</UpdateNotificationProvider>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
|
||||
157
frontend/src/components/InlineEdit.jsx
Normal file
157
frontend/src/components/InlineEdit.jsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Edit2, Check, X } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
const InlineEdit = ({
|
||||
value,
|
||||
onSave,
|
||||
onCancel,
|
||||
placeholder = "Enter value...",
|
||||
maxLength = 100,
|
||||
className = "",
|
||||
disabled = false,
|
||||
validate = null,
|
||||
linkTo = null
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(value);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const inputRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
setEditValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleEdit = () => {
|
||||
if (disabled) return;
|
||||
setIsEditing(true);
|
||||
setEditValue(value);
|
||||
setError('');
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
setEditValue(value);
|
||||
setError('');
|
||||
if (onCancel) onCancel();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (disabled || isLoading) return;
|
||||
|
||||
// Validate if validator function provided
|
||||
if (validate) {
|
||||
const validationError = validate(editValue);
|
||||
if (validationError) {
|
||||
setError(validationError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if value actually changed
|
||||
if (editValue.trim() === value.trim()) {
|
||||
setIsEditing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
await onSave(editValue.trim());
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to save');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleCancel();
|
||||
}
|
||||
};
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
maxLength={maxLength}
|
||||
disabled={isLoading}
|
||||
className={`flex-1 px-2 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${
|
||||
error ? 'border-red-500' : ''
|
||||
} ${isLoading ? 'opacity-50' : ''}`}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading || editValue.trim() === ''}
|
||||
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Save"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
{error && (
|
||||
<span className="text-xs text-red-600 dark:text-red-400">{error}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayValue = linkTo ? (
|
||||
<Link
|
||||
to={linkTo}
|
||||
className="text-sm font-medium text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 cursor-pointer transition-colors"
|
||||
title="View details"
|
||||
>
|
||||
{value}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 group ${className}`}>
|
||||
{displayValue}
|
||||
{!disabled && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
|
||||
title="Edit"
|
||||
>
|
||||
<Edit2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InlineEdit;
|
||||
262
frontend/src/components/InlineGroupEdit.jsx
Normal file
262
frontend/src/components/InlineGroupEdit.jsx
Normal 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;
|
||||
@@ -19,12 +19,18 @@ import {
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
Wrench,
|
||||
Plus
|
||||
Container,
|
||||
Plus,
|
||||
Activity,
|
||||
Cog,
|
||||
FileText
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { dashboardAPI, formatRelativeTime } from '../utils/api'
|
||||
import { useUpdateNotification } from '../contexts/UpdateNotificationContext'
|
||||
import { dashboardAPI, formatRelativeTime, versionAPI } from '../utils/api'
|
||||
import UpgradeNotificationIcon from './UpgradeNotificationIcon'
|
||||
|
||||
const Layout = ({ children }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
@@ -36,6 +42,7 @@ const Layout = ({ children }) => {
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
const { user, logout, canViewHosts, canManageHosts, canViewPackages, canViewUsers, canManageUsers, canManageSettings } = useAuth()
|
||||
const { updateAvailable } = useUpdateNotification()
|
||||
const userMenuRef = useRef(null)
|
||||
|
||||
// Fetch dashboard stats for the "Last updated" info
|
||||
@@ -46,16 +53,23 @@ const Layout = ({ children }) => {
|
||||
staleTime: 30000, // Consider data stale after 30 seconds
|
||||
})
|
||||
|
||||
// Fetch version info
|
||||
const { data: versionInfo } = useQuery({
|
||||
queryKey: ['versionInfo'],
|
||||
queryFn: () => versionAPI.getCurrent().then(res => res.data),
|
||||
staleTime: 300000, // Consider data stale after 5 minutes
|
||||
})
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: Home },
|
||||
{
|
||||
section: 'Inventory',
|
||||
items: [
|
||||
...(canViewHosts() ? [{ name: 'Hosts', href: '/hosts', icon: Server }] : []),
|
||||
...(canManageHosts() ? [{ name: 'Host Groups', href: '/host-groups', icon: Users }] : []),
|
||||
...(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 },
|
||||
]
|
||||
},
|
||||
@@ -69,7 +83,18 @@ const Layout = ({ children }) => {
|
||||
{
|
||||
section: 'Settings',
|
||||
items: [
|
||||
...(canManageSettings() ? [{ name: 'Server Config', href: '/settings', icon: Settings }] : []),
|
||||
...(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: Wrench,
|
||||
showUpgradeIcon: updateAvailable
|
||||
}] : []),
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -82,13 +107,15 @@ const Layout = ({ children }) => {
|
||||
|
||||
if (path === '/') return 'Dashboard'
|
||||
if (path === '/hosts') return 'Hosts'
|
||||
if (path === '/host-groups') return 'Host Groups'
|
||||
if (path === '/packages') return 'Packages'
|
||||
if (path === '/repositories' || path.startsWith('/repositories/')) return 'Repositories'
|
||||
if (path === '/services') return 'Services'
|
||||
if (path === '/docker') return 'Docker'
|
||||
if (path === '/users') return 'Users'
|
||||
if (path === '/permissions') return 'Permissions'
|
||||
if (path === '/settings') return 'Settings'
|
||||
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'
|
||||
@@ -267,7 +294,7 @@ const Layout = ({ children }) => {
|
||||
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
|
||||
title="Expand sidebar"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5 text-white" />
|
||||
<ChevronRight className="h-5 w-5 text-secondary-700 dark:text-white" />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
@@ -280,7 +307,7 @@ const Layout = ({ children }) => {
|
||||
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
|
||||
title="Collapse sidebar"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5 text-white" />
|
||||
<ChevronLeft className="h-5 w-5 text-secondary-700 dark:text-white" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
@@ -360,13 +387,18 @@ const Layout = ({ children }) => {
|
||||
isActive(subItem.href)
|
||||
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
|
||||
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
} ${sidebarCollapsed ? 'justify-center p-2' : 'p-2'} ${
|
||||
} ${sidebarCollapsed ? 'justify-center p-2 relative' : 'p-2'} ${
|
||||
subItem.comingSoon ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
title={sidebarCollapsed ? subItem.name : ''}
|
||||
onClick={subItem.comingSoon ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
|
||||
<div className={`flex items-center ${sidebarCollapsed ? 'justify-center' : ''}`}>
|
||||
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
|
||||
{sidebarCollapsed && subItem.showUpgradeIcon && (
|
||||
<UpgradeNotificationIcon className="h-3 w-3 absolute -top-1 -right-1" />
|
||||
)}
|
||||
</div>
|
||||
{!sidebarCollapsed && (
|
||||
<span className="truncate flex items-center gap-2">
|
||||
{subItem.name}
|
||||
@@ -375,6 +407,9 @@ const Layout = ({ children }) => {
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
{subItem.showUpgradeIcon && (
|
||||
<UpgradeNotificationIcon className="h-3 w-3" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
@@ -436,17 +471,22 @@ const Layout = ({ children }) => {
|
||||
</div>
|
||||
{/* Updated info */}
|
||||
{stats && (
|
||||
<div className="px-3 py-1 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center gap-x-2 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Updated: {formatRelativeTimeShort(stats.lastUpdated)}</span>
|
||||
<div className="px-2 py-1 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center gap-x-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<Clock className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="truncate">Updated: {formatRelativeTimeShort(stats.lastUpdated)}</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
|
||||
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded flex-shrink-0"
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
{versionInfo && (
|
||||
<span className="text-xs text-secondary-400 dark:text-secondary-500 flex-shrink-0">
|
||||
v{versionInfo.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -473,7 +513,7 @@ const Layout = ({ children }) => {
|
||||
</button>
|
||||
{/* Updated info for collapsed sidebar */}
|
||||
{stats && (
|
||||
<div className="flex justify-center py-1 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex flex-col items-center py-1 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
|
||||
@@ -481,6 +521,11 @@ const Layout = ({ children }) => {
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
{versionInfo && (
|
||||
<span className="text-xs text-secondary-400 dark:text-secondary-500 mt-1">
|
||||
v{versionInfo.version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
15
frontend/src/components/UpgradeNotificationIcon.jsx
Normal file
15
frontend/src/components/UpgradeNotificationIcon.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import { ArrowUpCircle } from 'lucide-react'
|
||||
|
||||
const UpgradeNotificationIcon = ({ className = "h-4 w-4", show = true }) => {
|
||||
if (!show) return null
|
||||
|
||||
return (
|
||||
<ArrowUpCircle
|
||||
className={`${className} text-red-500 animate-pulse`}
|
||||
title="Update available"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default UpgradeNotificationIcon
|
||||
46
frontend/src/contexts/UpdateNotificationContext.jsx
Normal file
46
frontend/src/contexts/UpdateNotificationContext.jsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { createContext, useContext, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { versionAPI } from '../utils/api'
|
||||
|
||||
const UpdateNotificationContext = createContext()
|
||||
|
||||
export const useUpdateNotification = () => {
|
||||
const context = useContext(UpdateNotificationContext)
|
||||
if (!context) {
|
||||
throw new Error('useUpdateNotification must be used within an UpdateNotificationProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const UpdateNotificationProvider = ({ children }) => {
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
|
||||
// Query for update information
|
||||
const { data: updateData, isLoading, error } = useQuery({
|
||||
queryKey: ['updateCheck'],
|
||||
queryFn: () => versionAPI.checkUpdates().then(res => res.data),
|
||||
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
|
||||
retry: 1
|
||||
})
|
||||
|
||||
const updateAvailable = updateData?.isUpdateAvailable && !dismissed
|
||||
const updateInfo = updateData
|
||||
|
||||
const dismissNotification = () => {
|
||||
setDismissed(true)
|
||||
}
|
||||
|
||||
const value = {
|
||||
updateAvailable,
|
||||
updateInfo,
|
||||
dismissNotification,
|
||||
isLoading,
|
||||
error
|
||||
}
|
||||
|
||||
return (
|
||||
<UpdateNotificationContext.Provider value={value}>
|
||||
{children}
|
||||
</UpdateNotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
Shield,
|
||||
TrendingUp,
|
||||
RefreshCw,
|
||||
Clock
|
||||
Clock,
|
||||
WifiOff
|
||||
} from 'lucide-react'
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title } from 'chart.js'
|
||||
import { Pie, Bar } from 'react-chartjs-2'
|
||||
@@ -27,7 +28,7 @@ const Dashboard = () => {
|
||||
|
||||
// Navigation handlers
|
||||
const handleTotalHostsClick = () => {
|
||||
navigate('/hosts')
|
||||
navigate('/hosts', { replace: true })
|
||||
}
|
||||
|
||||
const handleHostsNeedingUpdatesClick = () => {
|
||||
@@ -46,12 +47,16 @@ const Dashboard = () => {
|
||||
navigate('/hosts?filter=inactive')
|
||||
}
|
||||
|
||||
const handleOfflineHostsClick = () => {
|
||||
navigate('/hosts?filter=offline')
|
||||
}
|
||||
|
||||
const handleOSDistributionClick = () => {
|
||||
navigate('/hosts')
|
||||
navigate('/hosts', { replace: true })
|
||||
}
|
||||
|
||||
const handleUpdateStatusClick = () => {
|
||||
navigate('/hosts')
|
||||
navigate('/hosts', { replace: true })
|
||||
}
|
||||
|
||||
const handlePackagePriorityClick = () => {
|
||||
@@ -151,7 +156,7 @@ const Dashboard = () => {
|
||||
const getCardType = (cardId) => {
|
||||
if (['totalHosts', 'hostsNeedingUpdates', 'totalOutdatedPackages', 'securityUpdates'].includes(cardId)) {
|
||||
return 'stats';
|
||||
} else if (['osDistribution', 'updateStatus', 'packagePriority'].includes(cardId)) {
|
||||
} else if (['osDistribution', 'osDistributionBar', 'updateStatus', 'packagePriority'].includes(cardId)) {
|
||||
return 'charts';
|
||||
} else if (['erroredHosts', 'quickStats'].includes(cardId)) {
|
||||
return 'fullwidth';
|
||||
@@ -295,6 +300,45 @@ const Dashboard = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'offlineHosts':
|
||||
return (
|
||||
<div
|
||||
className={`border rounded-lg p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 ${
|
||||
stats.cards.offlineHosts > 0
|
||||
? 'bg-warning-50 border-warning-200'
|
||||
: 'bg-success-50 border-success-200'
|
||||
}`}
|
||||
onClick={handleOfflineHostsClick}
|
||||
>
|
||||
<div className="flex">
|
||||
<WifiOff className={`h-5 w-5 ${
|
||||
stats.cards.offlineHosts > 0 ? 'text-warning-400' : 'text-success-400'
|
||||
}`} />
|
||||
<div className="ml-3">
|
||||
{stats.cards.offlineHosts > 0 ? (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-warning-800">
|
||||
{stats.cards.offlineHosts} host{stats.cards.offlineHosts > 1 ? 's' : ''} offline/stale
|
||||
</h3>
|
||||
<p className="text-sm text-warning-700 mt-1">
|
||||
These hosts haven't reported in {formatUpdateIntervalThreshold() * 3}+ minutes.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-success-800">
|
||||
All hosts are online
|
||||
</h3>
|
||||
<p className="text-sm text-success-700 mt-1">
|
||||
No hosts are offline or stale.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'osDistribution':
|
||||
return (
|
||||
<div
|
||||
@@ -308,6 +352,19 @@ const Dashboard = () => {
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'osDistributionBar':
|
||||
return (
|
||||
<div
|
||||
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleOSDistributionClick}
|
||||
>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">OS Distribution</h3>
|
||||
<div className="h-64">
|
||||
<Bar data={osBarChartData} options={barChartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'updateStatus':
|
||||
return (
|
||||
<div
|
||||
@@ -414,6 +471,40 @@ const Dashboard = () => {
|
||||
},
|
||||
}
|
||||
|
||||
const barChartOptions = {
|
||||
responsive: true,
|
||||
indexAxis: 'y', // Make the chart horizontal
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: {
|
||||
color: isDark ? '#ffffff' : '#374151',
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: isDark ? '#374151' : '#e5e7eb'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
color: isDark ? '#ffffff' : '#374151',
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
color: isDark ? '#374151' : '#e5e7eb'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const osChartData = {
|
||||
labels: stats.charts.osDistribution.map(item => item.name),
|
||||
datasets: [
|
||||
@@ -433,6 +524,28 @@ const Dashboard = () => {
|
||||
],
|
||||
}
|
||||
|
||||
const osBarChartData = {
|
||||
labels: stats.charts.osDistribution.map(item => item.name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Hosts',
|
||||
data: stats.charts.osDistribution.map(item => item.count),
|
||||
backgroundColor: [
|
||||
'#3B82F6', // Blue
|
||||
'#10B981', // Green
|
||||
'#F59E0B', // Yellow
|
||||
'#EF4444', // Red
|
||||
'#8B5CF6', // Purple
|
||||
'#06B6D4', // Cyan
|
||||
],
|
||||
borderWidth: 1,
|
||||
borderColor: isDark ? '#374151' : '#ffffff',
|
||||
borderRadius: 4,
|
||||
borderSkipped: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const updateStatusChartData = {
|
||||
labels: stats.charts.updateStatusDistribution.map(item => item.name),
|
||||
datasets: [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Lock, User, AlertCircle } from 'lucide-react'
|
||||
import { Eye, EyeOff, Lock, User, AlertCircle, Smartphone, ArrowLeft } from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { authAPI } from '../utils/api'
|
||||
|
||||
const Login = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
const [tfaData, setTfaData] = useState({
|
||||
token: ''
|
||||
})
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [requiresTfa, setRequiresTfa] = useState(false)
|
||||
const [tfaUsername, setTfaUsername] = useState('')
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { login } = useAuth()
|
||||
@@ -21,16 +27,52 @@ const Login = () => {
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await login(formData.username, formData.password)
|
||||
const response = await authAPI.login(formData.username, formData.password)
|
||||
|
||||
if (result.success) {
|
||||
if (response.data.requiresTfa) {
|
||||
setRequiresTfa(true)
|
||||
setTfaUsername(formData.username)
|
||||
setError('')
|
||||
} else {
|
||||
// Regular login successful
|
||||
const result = await login(formData.username, formData.password)
|
||||
if (result.success) {
|
||||
navigate('/')
|
||||
} else {
|
||||
setError(result.error || 'Login failed')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Login failed')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTfaSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token)
|
||||
|
||||
if (response.data && response.data.token) {
|
||||
// Store token and user data
|
||||
localStorage.setItem('token', response.data.token)
|
||||
localStorage.setItem('user', JSON.stringify(response.data.user))
|
||||
|
||||
// Redirect to dashboard
|
||||
navigate('/')
|
||||
} else {
|
||||
setError(result.error || 'Login failed')
|
||||
setError('TFA verification failed - invalid response')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error occurred')
|
||||
console.error('TFA verification error:', err)
|
||||
const errorMessage = err.response?.data?.error || err.message || 'TFA verification failed'
|
||||
setError(errorMessage)
|
||||
// Clear the token input for security
|
||||
setTfaData({ token: '' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -43,6 +85,23 @@ const Login = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleTfaInputChange = (e) => {
|
||||
setTfaData({
|
||||
...tfaData,
|
||||
[e.target.name]: e.target.value.replace(/\D/g, '').slice(0, 6)
|
||||
})
|
||||
// Clear error when user starts typing
|
||||
if (error) {
|
||||
setError('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBackToLogin = () => {
|
||||
setRequiresTfa(false)
|
||||
setTfaData({ token: '' })
|
||||
setError('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
@@ -58,7 +117,8 @@ const Login = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{!requiresTfa ? (
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-secondary-700">
|
||||
@@ -150,6 +210,83 @@ const Login = () => {
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}>
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
|
||||
<Smartphone className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-medium text-secondary-900">
|
||||
Two-Factor Authentication
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-secondary-600">
|
||||
Enter the 6-digit code from your authenticator app
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="token" className="block text-sm font-medium text-secondary-700">
|
||||
Verification Code
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="token"
|
||||
name="token"
|
||||
type="text"
|
||||
required
|
||||
value={tfaData.token}
|
||||
onChange={handleTfaInputChange}
|
||||
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm text-center text-lg font-mono tracking-widest"
|
||||
placeholder="000000"
|
||||
maxLength="6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || tfaData.token.length !== 6}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Verifying...
|
||||
</div>
|
||||
) : (
|
||||
'Verify Code'
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBackToLogin}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Login
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-secondary-600">
|
||||
Don't have access to your authenticator? Use a backup code.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
570
frontend/src/pages/Options.jsx
Normal file
570
frontend/src/pages/Options.jsx
Normal file
@@ -0,0 +1,570 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Server,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
CheckCircle
|
||||
} from 'lucide-react'
|
||||
import { hostGroupsAPI } from '../utils/api'
|
||||
|
||||
const Options = () => {
|
||||
const [activeTab, setActiveTab] = useState('hostgroups')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [selectedGroup, setSelectedGroup] = useState(null)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [groupToDelete, setGroupToDelete] = useState(null)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Tab configuration
|
||||
const tabs = [
|
||||
{ id: 'hostgroups', name: 'Host Groups', icon: Users },
|
||||
{ id: 'notifications', name: 'Notifications', icon: AlertTriangle, comingSoon: true }
|
||||
]
|
||||
|
||||
// Fetch host groups
|
||||
const { data: hostGroups, isLoading, error } = useQuery({
|
||||
queryKey: ['hostGroups'],
|
||||
queryFn: () => hostGroupsAPI.list().then(res => res.data),
|
||||
})
|
||||
|
||||
// Create host group mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data) => hostGroupsAPI.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowCreateModal(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create host group:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// Update host group mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowEditModal(false)
|
||||
setSelectedGroup(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to update host group:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// Delete host group mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id) => hostGroupsAPI.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowDeleteModal(false)
|
||||
setGroupToDelete(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to delete host group:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const handleCreate = (data) => {
|
||||
createMutation.mutate(data)
|
||||
}
|
||||
|
||||
const handleEdit = (group) => {
|
||||
setSelectedGroup(group)
|
||||
setShowEditModal(true)
|
||||
}
|
||||
|
||||
const handleUpdate = (data) => {
|
||||
updateMutation.mutate({ id: selectedGroup.id, data })
|
||||
}
|
||||
|
||||
const handleDeleteClick = (group) => {
|
||||
setGroupToDelete(group)
|
||||
setShowDeleteModal(true)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
deleteMutation.mutate(groupToDelete.id)
|
||||
}
|
||||
|
||||
const renderHostGroupsTab = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">
|
||||
Error loading host groups
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
{error.message || 'Failed to load host groups'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
Host Groups
|
||||
</h2>
|
||||
<p className="text-secondary-600 dark:text-secondary-300">
|
||||
Organize your hosts into logical groups for better management
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Host Groups Grid */}
|
||||
{hostGroups && hostGroups.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{hostGroups.map((group) => (
|
||||
<div key={group.id} className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
{group.name}
|
||||
</h3>
|
||||
{group.description && (
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-1">
|
||||
{group.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(group)}
|
||||
className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
|
||||
title="Edit group"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteClick(group)}
|
||||
className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
|
||||
title="Delete group"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-300">
|
||||
<div className="flex items-center gap-1">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
|
||||
No host groups yet
|
||||
</h3>
|
||||
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
|
||||
Create your first host group to organize your hosts
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderComingSoonTab = (tabName) => (
|
||||
<div className="text-center py-12">
|
||||
<SettingsIcon className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
|
||||
{tabName} Coming Soon
|
||||
</h3>
|
||||
<p className="text-secondary-600 dark:text-secondary-300">
|
||||
This feature is currently under development and will be available in a future update.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Options
|
||||
</h1>
|
||||
<p className="text-secondary-600 dark:text-secondary-300 mt-1">
|
||||
Configure PatchMon parameters and user preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-secondary-200 dark:border-secondary-600">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300 dark:text-secondary-400 dark:hover:text-secondary-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{tab.name}
|
||||
{tab.comingSoon && (
|
||||
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="mt-6">
|
||||
{activeTab === 'hostgroups' && renderHostGroupsTab()}
|
||||
{activeTab === 'notifications' && renderComingSoonTab('Notifications')}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateHostGroupModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={handleCreate}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{showEditModal && selectedGroup && (
|
||||
<EditHostGroupModal
|
||||
group={selectedGroup}
|
||||
onClose={() => {
|
||||
setShowEditModal(false)
|
||||
setSelectedGroup(null)
|
||||
}}
|
||||
onSubmit={handleUpdate}
|
||||
isLoading={updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && groupToDelete && (
|
||||
<DeleteHostGroupModal
|
||||
group={groupToDelete}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false)
|
||||
setGroupToDelete(null)
|
||||
}}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Create Host Group Modal
|
||||
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#3B82F6'
|
||||
})
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(formData)
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
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">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
Create Host Group
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
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 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="e.g., Production Servers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
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 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="Optional description for this group"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
name="color"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="#3B82F6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Group'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Edit Host Group Modal
|
||||
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: group.name,
|
||||
description: group.description || '',
|
||||
color: group.color || '#3B82F6'
|
||||
})
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(formData)
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
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">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
Edit Host Group
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
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 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="e.g., Production Servers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
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 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="Optional description for this group"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
name="color"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="#3B82F6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update Group'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Delete Confirmation Modal
|
||||
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||
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 items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Delete Host Group
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
||||
This action cannot be undone
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-secondary-700 dark:text-secondary-200">
|
||||
Are you sure you want to delete the host group{' '}
|
||||
<span className="font-semibold">"{group.name}"</span>?
|
||||
</p>
|
||||
{group._count.hosts > 0 && (
|
||||
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
|
||||
<p className="text-sm text-warning-800">
|
||||
<strong>Warning:</strong> This group contains {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}.
|
||||
You must move or remove these hosts before deleting the group.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="btn-danger"
|
||||
disabled={isLoading || group._count.hosts > 0}
|
||||
>
|
||||
{isLoading ? 'Deleting...' : 'Delete Group'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Options
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
@@ -13,8 +14,15 @@ import {
|
||||
AlertCircle,
|
||||
Sun,
|
||||
Moon,
|
||||
Settings
|
||||
Settings,
|
||||
Smartphone,
|
||||
QrCode,
|
||||
Copy,
|
||||
Download,
|
||||
Trash2,
|
||||
RefreshCw
|
||||
} from 'lucide-react'
|
||||
import { tfaAPI } from '../utils/api'
|
||||
|
||||
const Profile = () => {
|
||||
const { user, updateProfile, changePassword } = useAuth()
|
||||
@@ -111,6 +119,7 @@ const Profile = () => {
|
||||
const tabs = [
|
||||
{ id: 'profile', name: 'Profile Information', icon: User },
|
||||
{ id: 'password', name: 'Change Password', icon: Key },
|
||||
{ id: 'tfa', name: 'Multi-Factor Authentication', icon: Smartphone },
|
||||
{ id: 'preferences', name: 'Preferences', icon: Settings }
|
||||
]
|
||||
|
||||
@@ -357,6 +366,11 @@ const Profile = () => {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Multi-Factor Authentication Tab */}
|
||||
{activeTab === 'tfa' && (
|
||||
<TfaTab />
|
||||
)}
|
||||
|
||||
{/* Preferences Tab */}
|
||||
{activeTab === 'preferences' && (
|
||||
<div className="space-y-6">
|
||||
@@ -411,4 +425,399 @@ const Profile = () => {
|
||||
)
|
||||
}
|
||||
|
||||
// TFA Tab Component
|
||||
const TfaTab = () => {
|
||||
const [setupStep, setSetupStep] = useState('status') // 'status', 'setup', 'verify', 'backup-codes'
|
||||
const [verificationToken, setVerificationToken] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [backupCodes, setBackupCodes] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [message, setMessage] = useState({ type: '', text: '' })
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Fetch TFA status
|
||||
const { data: tfaStatus, isLoading: statusLoading } = useQuery({
|
||||
queryKey: ['tfaStatus'],
|
||||
queryFn: () => tfaAPI.status().then(res => res.data),
|
||||
})
|
||||
|
||||
// Setup TFA mutation
|
||||
const setupMutation = useMutation({
|
||||
mutationFn: () => tfaAPI.setup().then(res => res.data),
|
||||
onSuccess: (data) => {
|
||||
setSetupStep('setup')
|
||||
setMessage({ type: 'info', text: 'Scan the QR code with your authenticator app and enter the verification code below.' })
|
||||
},
|
||||
onError: (error) => {
|
||||
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to setup TFA' })
|
||||
}
|
||||
})
|
||||
|
||||
// Verify setup mutation
|
||||
const verifyMutation = useMutation({
|
||||
mutationFn: (data) => tfaAPI.verifySetup(data).then(res => res.data),
|
||||
onSuccess: (data) => {
|
||||
setBackupCodes(data.backupCodes)
|
||||
setSetupStep('backup-codes')
|
||||
setMessage({ type: 'success', text: 'Two-factor authentication has been enabled successfully!' })
|
||||
},
|
||||
onError: (error) => {
|
||||
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to verify TFA setup' })
|
||||
}
|
||||
})
|
||||
|
||||
// Disable TFA mutation
|
||||
const disableMutation = useMutation({
|
||||
mutationFn: (data) => tfaAPI.disable(data).then(res => res.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['tfaStatus'])
|
||||
setSetupStep('status')
|
||||
setMessage({ type: 'success', text: 'Two-factor authentication has been disabled successfully!' })
|
||||
},
|
||||
onError: (error) => {
|
||||
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to disable TFA' })
|
||||
}
|
||||
})
|
||||
|
||||
// Regenerate backup codes mutation
|
||||
const regenerateBackupCodesMutation = useMutation({
|
||||
mutationFn: () => tfaAPI.regenerateBackupCodes().then(res => res.data),
|
||||
onSuccess: (data) => {
|
||||
setBackupCodes(data.backupCodes)
|
||||
setMessage({ type: 'success', text: 'Backup codes have been regenerated successfully!' })
|
||||
},
|
||||
onError: (error) => {
|
||||
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to regenerate backup codes' })
|
||||
}
|
||||
})
|
||||
|
||||
const handleSetup = () => {
|
||||
setupMutation.mutate()
|
||||
}
|
||||
|
||||
const handleVerify = (e) => {
|
||||
e.preventDefault()
|
||||
if (verificationToken.length !== 6) {
|
||||
setMessage({ type: 'error', text: 'Please enter a 6-digit verification code' })
|
||||
return
|
||||
}
|
||||
verifyMutation.mutate({ token: verificationToken })
|
||||
}
|
||||
|
||||
const handleDisable = (e) => {
|
||||
e.preventDefault()
|
||||
if (!password) {
|
||||
setMessage({ type: 'error', text: 'Please enter your password to disable TFA' })
|
||||
return
|
||||
}
|
||||
disableMutation.mutate({ password })
|
||||
}
|
||||
|
||||
const handleRegenerateBackupCodes = () => {
|
||||
regenerateBackupCodesMutation.mutate()
|
||||
}
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
setMessage({ type: 'success', text: 'Copied to clipboard!' })
|
||||
}
|
||||
|
||||
const downloadBackupCodes = () => {
|
||||
const content = `PatchMon Backup Codes\n\n${backupCodes.map((code, index) => `${index + 1}. ${code}`).join('\n')}\n\nKeep these codes safe! Each code can only be used once.`
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'patchmon-backup-codes.txt'
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
if (statusLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Multi-Factor Authentication</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
|
||||
Add an extra layer of security to your account by enabling two-factor authentication.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Message */}
|
||||
{message.text && (
|
||||
<div className={`rounded-md p-4 ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700'
|
||||
: message.type === 'error'
|
||||
? 'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700'
|
||||
: 'bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700'
|
||||
}`}>
|
||||
<div className="flex">
|
||||
{message.type === 'success' ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
|
||||
) : message.type === 'error' ? (
|
||||
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 text-blue-400 dark:text-blue-300" />
|
||||
)}
|
||||
<div className="ml-3">
|
||||
<p className={`text-sm font-medium ${
|
||||
message.type === 'success' ? 'text-green-800 dark:text-green-200' :
|
||||
message.type === 'error' ? 'text-red-800 dark:text-red-200' :
|
||||
'text-blue-800 dark:text-blue-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TFA Status */}
|
||||
{setupStep === 'status' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`p-2 rounded-full ${tfaStatus?.enabled ? 'bg-green-100 dark:bg-green-900' : 'bg-secondary-100 dark:bg-secondary-700'}`}>
|
||||
<Smartphone className={`h-6 w-6 ${tfaStatus?.enabled ? 'text-green-600 dark:text-green-400' : 'text-secondary-600 dark:text-secondary-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
{tfaStatus?.enabled ? 'Two-Factor Authentication Enabled' : 'Two-Factor Authentication Disabled'}
|
||||
</h4>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
||||
{tfaStatus?.enabled
|
||||
? 'Your account is protected with two-factor authentication.'
|
||||
: 'Add an extra layer of security to your account.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{tfaStatus?.enabled ? (
|
||||
<button
|
||||
onClick={() => setSetupStep('disable')}
|
||||
className="btn-outline text-danger-600 border-danger-300 hover:bg-danger-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Disable TFA
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSetup}
|
||||
disabled={setupMutation.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
<Smartphone className="h-4 w-4 mr-2" />
|
||||
{setupMutation.isPending ? 'Setting up...' : 'Enable TFA'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tfaStatus?.enabled && (
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Backup Codes</h4>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
Use these backup codes to access your account if you lose your authenticator device.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleRegenerateBackupCodes}
|
||||
disabled={regenerateBackupCodesMutation.isPending}
|
||||
className="btn-outline"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${regenerateBackupCodesMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
{regenerateBackupCodesMutation.isPending ? 'Regenerating...' : 'Regenerate Codes'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TFA Setup */}
|
||||
{setupStep === 'setup' && setupMutation.data && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Setup Two-Factor Authentication</h4>
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<img
|
||||
src={setupMutation.data.qrCode}
|
||||
alt="QR Code"
|
||||
className="mx-auto h-48 w-48 border border-secondary-200 dark:border-secondary-600 rounded-lg"
|
||||
/>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-2">
|
||||
Scan this QR code with your authenticator app
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||
<p className="text-sm font-medium text-secondary-900 dark:text-white mb-2">Manual Entry Key:</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<code className="flex-1 bg-white dark:bg-secondary-800 px-3 py-2 rounded border text-sm font-mono">
|
||||
{setupMutation.data.manualEntryKey}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => copyToClipboard(setupMutation.data.manualEntryKey)}
|
||||
className="p-2 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<button
|
||||
onClick={() => setSetupStep('verify')}
|
||||
className="btn-primary"
|
||||
>
|
||||
Continue to Verification
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TFA Verification */}
|
||||
{setupStep === 'verify' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Verify Setup</h4>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
Enter the 6-digit code from your authenticator app to complete the setup.
|
||||
</p>
|
||||
<form onSubmit={handleVerify} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Verification Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={verificationToken}
|
||||
onChange={(e) => setVerificationToken(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
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 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white text-center text-lg font-mono tracking-widest"
|
||||
maxLength="6"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={verifyMutation.isPending || verificationToken.length !== 6}
|
||||
className="btn-primary"
|
||||
>
|
||||
{verifyMutation.isPending ? 'Verifying...' : 'Verify & Enable'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupStep('status')}
|
||||
className="btn-outline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup Codes */}
|
||||
{setupStep === 'backup-codes' && backupCodes.length > 0 && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Backup Codes</h4>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
Save these backup codes in a safe place. Each code can only be used once.
|
||||
</p>
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg mb-4">
|
||||
<div className="grid grid-cols-2 gap-2 font-mono text-sm">
|
||||
{backupCodes.map((code, index) => (
|
||||
<div key={index} className="flex items-center justify-between py-1">
|
||||
<span className="text-secondary-600 dark:text-secondary-400">{index + 1}.</span>
|
||||
<span className="text-secondary-900 dark:text-white">{code}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={downloadBackupCodes}
|
||||
className="btn-outline"
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download Codes
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSetupStep('status')
|
||||
queryClient.invalidateQueries(['tfaStatus'])
|
||||
}}
|
||||
className="btn-primary"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disable TFA */}
|
||||
{setupStep === 'disable' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
|
||||
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Disable Two-Factor Authentication</h4>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
|
||||
Enter your password to disable two-factor authentication.
|
||||
</p>
|
||||
<form onSubmit={handleDisable} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(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 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disableMutation.isPending || !password}
|
||||
className="btn-danger"
|
||||
>
|
||||
{disableMutation.isPending ? 'Disabling...' : 'Disable TFA'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSetupStep('status')}
|
||||
className="btn-outline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Profile
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Save, Server, Globe, Shield, AlertCircle, CheckCircle, Code, Plus, Trash2, Star, Download, X, Settings as SettingsIcon } from 'lucide-react';
|
||||
import { Save, Server, Globe, Shield, AlertCircle, CheckCircle, Code, Plus, Trash2, Star, Download, X, Settings as SettingsIcon, Clock } from 'lucide-react';
|
||||
import { settingsAPI, agentVersionAPI, versionAPI } from '../utils/api';
|
||||
import { useUpdateNotification } from '../contexts/UpdateNotificationContext';
|
||||
import UpgradeNotificationIcon from '../components/UpgradeNotificationIcon';
|
||||
|
||||
const Settings = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -12,6 +14,7 @@ const Settings = () => {
|
||||
updateInterval: 60,
|
||||
autoUpdate: false,
|
||||
githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
repositoryType: 'public',
|
||||
sshKeyPath: '',
|
||||
useCustomSshKey: false
|
||||
});
|
||||
@@ -21,12 +24,15 @@ const Settings = () => {
|
||||
// Tab management
|
||||
const [activeTab, setActiveTab] = useState('server');
|
||||
|
||||
// Get update notification state
|
||||
const { updateAvailable } = useUpdateNotification();
|
||||
|
||||
// Tab configuration
|
||||
const tabs = [
|
||||
{ id: 'server', name: 'Server Configuration', icon: Server },
|
||||
{ id: 'frontend', name: 'Frontend Configuration', icon: Globe },
|
||||
{ id: 'agent', name: 'Agent Management', icon: SettingsIcon },
|
||||
{ id: 'version', name: 'Server Version', icon: Code }
|
||||
{ id: 'version', name: 'Server Version', icon: Code, showUpgradeIcon: updateAvailable }
|
||||
];
|
||||
|
||||
// Agent version management state
|
||||
@@ -76,6 +82,7 @@ const Settings = () => {
|
||||
updateInterval: settings.updateInterval || 60,
|
||||
autoUpdate: settings.autoUpdate || false,
|
||||
githubRepoUrl: settings.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
repositoryType: settings.repositoryType || 'public',
|
||||
sshKeyPath: settings.sshKeyPath || '',
|
||||
useCustomSshKey: !!settings.sshKeyPath
|
||||
};
|
||||
@@ -107,6 +114,7 @@ const Settings = () => {
|
||||
updateInterval: data.settings?.updateInterval || data.updateInterval || 60,
|
||||
autoUpdate: data.settings?.autoUpdate || data.autoUpdate || false,
|
||||
githubRepoUrl: data.settings?.githubRepoUrl || data.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
|
||||
repositoryType: data.settings?.repositoryType || data.repositoryType || 'public',
|
||||
sshKeyPath: data.settings?.sshKeyPath || data.sshKeyPath || '',
|
||||
useCustomSshKey: !!(data.settings?.sshKeyPath || data.sshKeyPath)
|
||||
});
|
||||
@@ -187,6 +195,7 @@ const Settings = () => {
|
||||
currentVersion: data.currentVersion,
|
||||
latestVersion: data.latestVersion,
|
||||
isUpdateAvailable: data.isUpdateAvailable,
|
||||
lastUpdateCheck: data.lastUpdateCheck,
|
||||
checking: false,
|
||||
error: null
|
||||
});
|
||||
@@ -301,6 +310,7 @@ const Settings = () => {
|
||||
// Remove the frontend-only field
|
||||
delete dataToSubmit.useCustomSshKey;
|
||||
|
||||
console.log('Submitting data with githubRepoUrl:', dataToSubmit.githubRepoUrl);
|
||||
updateSettingsMutation.mutate(dataToSubmit);
|
||||
} else {
|
||||
console.log('Validation failed:', errors);
|
||||
@@ -368,6 +378,9 @@ const Settings = () => {
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{tab.name}
|
||||
{tab.showUpgradeIcon && (
|
||||
<UpgradeNotificationIcon className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -774,13 +787,52 @@ const Settings = () => {
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||
Repository Type
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
id="repo-public"
|
||||
name="repositoryType"
|
||||
value="public"
|
||||
checked={formData.repositoryType === 'public'}
|
||||
onChange={(e) => handleInputChange('repositoryType', e.target.value)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
|
||||
/>
|
||||
<label htmlFor="repo-public" className="ml-2 text-sm text-secondary-700 dark:text-secondary-200">
|
||||
Public Repository (uses GitHub API - no authentication required)
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="radio"
|
||||
id="repo-private"
|
||||
name="repositoryType"
|
||||
value="private"
|
||||
checked={formData.repositoryType === 'private'}
|
||||
onChange={(e) => handleInputChange('repositoryType', e.target.value)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
|
||||
/>
|
||||
<label htmlFor="repo-private" className="ml-2 text-sm text-secondary-700 dark:text-secondary-200">
|
||||
Private Repository (uses SSH with deploy key)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
Choose whether your repository is public or private to determine the appropriate access method.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||
GitHub Repository URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'}
|
||||
value={formData.githubRepoUrl || ''}
|
||||
onChange={(e) => handleInputChange('githubRepoUrl', e.target.value)}
|
||||
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
|
||||
placeholder="git@github.com:username/repository.git"
|
||||
@@ -790,25 +842,26 @@ const Settings = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useCustomSshKey"
|
||||
checked={formData.useCustomSshKey}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
handleInputChange('useCustomSshKey', checked);
|
||||
if (!checked) {
|
||||
handleInputChange('sshKeyPath', '');
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="useCustomSshKey" className="text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||
Set custom SSH key path
|
||||
</label>
|
||||
</div>
|
||||
{formData.repositoryType === 'private' && (
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="useCustomSshKey"
|
||||
checked={formData.useCustomSshKey}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
handleInputChange('useCustomSshKey', checked);
|
||||
if (!checked) {
|
||||
handleInputChange('sshKeyPath', '');
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="useCustomSshKey" className="text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||
Set custom SSH key path
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.useCustomSshKey && (
|
||||
<div>
|
||||
@@ -866,7 +919,8 @@ const Settings = () => {
|
||||
Using auto-detection for SSH key location
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||
@@ -897,6 +951,22 @@ const Settings = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Last Checked Time */}
|
||||
{versionInfo.lastUpdateCheck && (
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Clock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Last Checked</span>
|
||||
</div>
|
||||
<span className="text-sm text-secondary-600 dark:text-secondary-400">
|
||||
{new Date(versionInfo.lastUpdateCheck).toLocaleString()}
|
||||
</span>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
|
||||
Updates are checked automatically every 24 hours
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
|
||||
@@ -31,9 +31,17 @@ api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Handle unauthorized
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
// Don't redirect if we're on the login page or if it's a TFA verification error
|
||||
const currentPath = window.location.pathname
|
||||
const isTfaError = error.config?.url?.includes('/verify-tfa')
|
||||
|
||||
if (currentPath !== '/login' && !isTfaError) {
|
||||
// Handle unauthorized
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('permissions')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
@@ -55,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
|
||||
@@ -185,6 +194,22 @@ export const versionAPI = {
|
||||
testSshKey: (data) => api.post('/version/test-ssh-key', data),
|
||||
}
|
||||
|
||||
// Auth API
|
||||
export const authAPI = {
|
||||
login: (username, password) => api.post('/auth/login', { username, password }),
|
||||
verifyTfa: (username, token) => api.post('/auth/verify-tfa', { username, token }),
|
||||
}
|
||||
|
||||
// TFA API
|
||||
export const tfaAPI = {
|
||||
setup: () => api.get('/tfa/setup'),
|
||||
verifySetup: (data) => api.post('/tfa/verify-setup', data),
|
||||
disable: (data) => api.post('/tfa/disable', data),
|
||||
status: () => api.get('/tfa/status'),
|
||||
regenerateBackupCodes: () => api.post('/tfa/regenerate-backup-codes'),
|
||||
verify: (data) => api.post('/tfa/verify', data),
|
||||
}
|
||||
|
||||
export const formatRelativeTime = (date) => {
|
||||
const now = new Date()
|
||||
const diff = now - new Date(date)
|
||||
|
||||
130
frontend/src/utils/osIcons.jsx
Normal file
130
frontend/src/utils/osIcons.jsx
Normal 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} />;
|
||||
};
|
||||
245
package-lock.json
generated
245
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "patchmon",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "patchmon",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.4",
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
@@ -20,7 +20,7 @@
|
||||
},
|
||||
"backend": {
|
||||
"name": "patchmon-backend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.4",
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.7.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -32,6 +32,8 @@
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"speakeasy": "^2.0.0",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
@@ -45,7 +47,7 @@
|
||||
},
|
||||
"frontend": {
|
||||
"name": "patchmon-frontend",
|
||||
"version": "1.0.0",
|
||||
"version": "1.2.4",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -54,11 +56,14 @@
|
||||
"axios": "^1.6.2",
|
||||
"chart.js": "^4.4.0",
|
||||
"clsx": "^2.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^2.30.0",
|
||||
"express": "^4.18.2",
|
||||
"lucide-react": "^0.294.0",
|
||||
"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": {
|
||||
@@ -1887,7 +1892,6 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -1897,7 +1901,6 @@
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
@@ -2182,6 +2185,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/base32.js": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz",
|
||||
"integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
|
||||
@@ -2355,6 +2364,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase-css": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
|
||||
@@ -2491,7 +2509,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -2768,6 +2785,15 @@
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-is": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@@ -2846,6 +2872,12 @@
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dlv": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
|
||||
@@ -2925,7 +2957,6 @@
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/enabled": {
|
||||
@@ -3858,7 +3889,6 @@
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
@@ -4404,7 +4434,6 @@
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -5523,6 +5552,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/package-json-from-dist": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
|
||||
@@ -5564,7 +5602,6 @@
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -5667,6 +5704,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/possible-typed-array-names": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||
@@ -5926,6 +5972,141 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.13.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
|
||||
@@ -6021,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",
|
||||
@@ -6155,12 +6345,17 @@
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "2.0.0-next.5",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
||||
@@ -6456,6 +6651,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-function-length": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||
@@ -6670,6 +6871,18 @@
|
||||
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/speakeasy": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz",
|
||||
"integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base32.js": "0.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stack-trace": {
|
||||
"version": "0.0.10",
|
||||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||
@@ -6715,7 +6928,6 @@
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
@@ -6844,7 +7056,6 @@
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
@@ -7627,6 +7838,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/which-typed-array": {
|
||||
"version": "1.1.19",
|
||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "patchmon",
|
||||
"version": "1.2.4",
|
||||
"version": "1.2.5",
|
||||
"description": "Linux Patch Monitoring System",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user