Compare commits

...

27 Commits

Author SHA1 Message Date
Muhammad Ibrahim
bbd7769b8c Fix hardcoded version numbers in backend - update to 1.2.5 2025-09-20 13:48:25 +01:00
Muhammad Ibrahim
8245c6b90d Remove manage-patchmon.sh from git tracking
- manage-patchmon.sh is in .gitignore and should not be tracked
- Both management scripts should remain local only
- Scripts are generated during deployment process
2025-09-20 13:28:07 +01:00
Muhammad Ibrahim
1afb9c1ed3 Remove manage-patchmon-dev.sh from git tracking
- manage-patchmon-dev.sh should remain local only
- Only manage-patchmon.sh should be tracked in git
- Dev script is for local development use
2025-09-20 13:27:28 +01:00
Muhammad Ibrahim
417942f674 Add automatic branch switching for updates
- Added branch detection and switching logic to both scripts
- manage-patchmon-dev.sh: automatically switches instances to dev branch
- manage-patchmon.sh: automatically switches instances to main branch
- Handles local changes by stashing before branch switch
- Creates local branch from origin if it doesn't exist locally
- Fixes issue where main branch instances couldn't be updated with dev script
2025-09-20 13:26:51 +01:00
Muhammad Ibrahim
75a4b4a912 Add jq installation to essential tools
- Added jq to essential tools installation in both manage-patchmon.sh and manage-patchmon-dev.sh
- jq is useful for JSON processing and API interactions
- Updated installation check to include jq command availability
- Enhanced status message to show all installed tools
2025-09-20 13:20:17 +01:00
Muhammad Ibrahim
4576781900 Improve agent version logic in manage-patchmon.sh
- Added priority-based version detection (agent script > codebase > package.json > database)
- Always update agent script content during updates, even if version exists
- Improved logging and fallback to 1.2.5
- Consistent behavior with manage-patchmon-dev.sh
2025-09-20 13:07:02 +01:00
Muhammad Ibrahim
0d10d7ee9b Bump version to 1.2.5
- Updated root package.json to 1.2.5
- Updated backend package.json to 1.2.5
- Updated frontend package.json to 1.2.5
- Agent script already has AGENT_VERSION=1.2.5
2025-09-20 13:02:24 +01:00
Muhammad Ibrahim
1cdd6eba6d Added better view on host details and improved filtering 2025-09-20 12:40:31 +01:00
Muhammad Ibrahim
adb207fef9 improved table views and added more host information 2025-09-20 10:56:59 +01:00
Muhammad Ibrahim
216c9dbefa improved duplicate repo handling 2025-09-19 16:07:29 +01:00
Muhammad Ibrahim
52d6d46ea3 Merge branch 'main' of github.com:9technologygroup/patchmon.net 2025-09-19 16:01:45 +01:00
Muhammad Ibrahim
6bc4316fbc fixed duplicated repo url issue 2025-09-19 15:59:45 +01:00
9 Technology Group LTD
b1470f57a8 Create README.md 2025-09-19 11:36:45 +01:00
Muhammad Ibrahim
51d6dd63b1 added server.js for frontend when not using nginx in deployment 2025-09-18 22:59:50 +01:00
Muhammad Ibrahim
2d7a3c3103 Added mfa and css enhancements 2025-09-18 20:14:54 +01:00
Muhammad Ibrahim
5bdd0b5830 upgraded version 2025-09-18 02:09:42 +01:00
Muhammad Ibrahim
98cadb1ff1 added the right version of patchmon 2025-09-18 01:50:15 +01:00
Muhammad Ibrahim
42a6b7e19c added ability to save the custom ssh id path 2025-09-18 01:31:07 +01:00
Muhammad Ibrahim
e35f96d30f added deploy key custom ssh path 2025-09-18 01:19:43 +01:00
Muhammad Ibrahim
08f82bc795 added ability to specify deploy key path 2025-09-18 01:02:51 +01:00
Muhammad Ibrahim
c497c1db2a chore: Remove manage-patchmon.sh from Git tracking
- Remove manage-patchmon.sh from Git tracking as it's in .gitignore
- File should only exist locally and be hosted separately
- This prevents accidental commits of the management script
2025-09-17 23:44:48 +01:00
Muhammad Ibrahim
5b7e7216e8 fix: Improve Prisma migration execution with multiple fallback methods
- Add multiple fallback methods to run Prisma migrations
- Try npx first, then direct binary paths with chmod +x
- Add fallback to install Prisma if not found
- Apply same fixes to both update_instance and small management script
- Resolves 'Permission denied' error when running prisma migrate deploy

This ensures migrations work even when npx has permission issues
or when Prisma binaries need executable permissions.
2025-09-17 23:43:23 +01:00
Muhammad Ibrahim
fe5fb92e48 fix: Add git safe.directory configuration for update commands
- Add 'git config --global --add safe.directory' before git pull in update_instance
- Add same fix to small management script update command
- Resolves 'dubious ownership' error when updating repositories
  that were cloned as root but accessed by different users

This fixes the git ownership security warning that prevents
git pull from working during updates.
2025-09-17 23:41:54 +01:00
Muhammad Ibrahim
c8d54facb9 fix: Use .env file for database credentials in update commands
- Update update_instance function to read database credentials from .env file
- Fix database backup to use credentials from .env instead of hardcoded values
- Update migration commands to load environment variables from .env
- Fix small management script update command to use .env credentials

This resolves the 'password authentication failed' error when running
update commands on existing instances where database credentials
are stored in the .env file rather than hardcoded.
2025-09-17 23:39:00 +01:00
Muhammad Ibrahim
f97b300158 fix: Add database migrations to update command
- Add 'npx prisma migrate deploy' to the update command in manage.sh
- Ensures database schema is updated when running update command
- Fixes issue where new features (like version checking) wouldn't work
  after updating without running migrations

The update_instance function already had migrations, but the
manage.sh update command was missing this crucial step.
2025-09-17 22:21:35 +01:00
Muhammad Ibrahim
17ffa48158 fix: Fix SSH command escaping for version checking
- Fix sed command escaping in git ls-remote command
- Add explicit SSH key path and GIT_SSH_COMMAND environment
- Add debug logging for troubleshooting SSH issues
- Ensure proper SSH authentication for private repositories

This resolves the 'Failed to fetch repository information' error
when checking for updates from private GitHub repositories.
2025-09-17 22:19:12 +01:00
Muhammad Ibrahim
16821d6b5e feat: Implement SSH-based version checking for private repositories
- Replace HTTPS GitHub API calls with SSH git commands
- Use existing deploy key for private repository access
- Add proper error handling for SSH authentication issues
- Support fetching latest tags via git ls-remote
- Maintain compatibility with private repositories using deploy keys

This allows the version checking system to work with private repositories
that have SSH deploy keys configured, using the same authentication
as the local git operations.
2025-09-17 22:16:52 +01:00
64 changed files with 9479 additions and 898 deletions

1
.gitignore vendored
View File

@@ -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
View File

@@ -0,0 +1,3 @@
Join my discord for Instructions, support and feedback :
https://discord.gg/S7RXUHwg

View File

@@ -1,12 +1,12 @@
#!/bin/bash
# PatchMon Agent Script
# PatchMon Agent Script v1.2.5
# This script sends package update information to the PatchMon server using API credentials
# Configuration
PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}"
API_VERSION="v1"
AGENT_VERSION="1.2.3"
AGENT_VERSION="1.2.5"
CONFIG_FILE="/etc/patchmon/agent.conf"
CREDENTIALS_FILE="/etc/patchmon/credentials"
LOG_FILE="/var/log/patchmon-agent.log"
@@ -656,6 +656,114 @@ get_yum_packages() {
done <<< "$installed"
}
# Get hardware information
get_hardware_info() {
local cpu_model=""
local cpu_cores=0
local ram_installed=0
local swap_size=0
local disk_details="[]"
# CPU Information
if command -v lscpu >/dev/null 2>&1; then
cpu_model=$(lscpu | grep "Model name" | cut -d':' -f2 | xargs)
cpu_cores=$(lscpu | grep "^CPU(s):" | cut -d':' -f2 | xargs)
elif [[ -f /proc/cpuinfo ]]; then
cpu_model=$(grep "model name" /proc/cpuinfo | head -1 | cut -d':' -f2 | xargs)
cpu_cores=$(grep -c "^processor" /proc/cpuinfo)
fi
# Memory Information
if command -v free >/dev/null 2>&1; then
ram_installed=$(free -g | grep "^Mem:" | awk '{print $2}')
swap_size=$(free -g | grep "^Swap:" | awk '{print $2}')
elif [[ -f /proc/meminfo ]]; then
ram_installed=$(grep "MemTotal" /proc/meminfo | awk '{print int($2/1024/1024)}')
swap_size=$(grep "SwapTotal" /proc/meminfo | awk '{print int($2/1024/1024)}')
fi
# Disk Information
if command -v lsblk >/dev/null 2>&1; then
disk_details=$(lsblk -J -o NAME,SIZE,TYPE,MOUNTPOINT | jq -c '[.blockdevices[] | select(.type == "disk") | {name: .name, size: .size, mountpoint: .mountpoint}]')
elif command -v df >/dev/null 2>&1; then
disk_details=$(df -h | grep -E "^/dev/" | awk '{print "{\"name\":\""$1"\",\"size\":\""$2"\",\"mountpoint\":\""$6"\"}"}' | jq -s .)
fi
echo "{\"cpuModel\":\"$cpu_model\",\"cpuCores\":$cpu_cores,\"ramInstalled\":$ram_installed,\"swapSize\":$swap_size,\"diskDetails\":$disk_details}"
}
# Get network information
get_network_info() {
local gateway_ip=""
local dns_servers="[]"
local network_interfaces="[]"
# Gateway IP
if command -v ip >/dev/null 2>&1; then
gateway_ip=$(ip route | grep default | head -1 | awk '{print $3}')
elif command -v route >/dev/null 2>&1; then
gateway_ip=$(route -n | grep '^0.0.0.0' | head -1 | awk '{print $2}')
fi
# DNS Servers
if [[ -f /etc/resolv.conf ]]; then
dns_servers=$(grep "nameserver" /etc/resolv.conf | awk '{print $2}' | jq -R . | jq -s .)
fi
# Network Interfaces
if command -v ip >/dev/null 2>&1; then
network_interfaces=$(ip -j addr show | jq -c '[.[] | {name: .ifname, type: .link_type, addresses: [.addr_info[]? | {address: .local, family: .family}]}]')
elif command -v ifconfig >/dev/null 2>&1; then
network_interfaces=$(ifconfig -a | grep -E "^[a-zA-Z]" | awk '{print $1}' | jq -R . | jq -s .)
fi
echo "{\"gatewayIp\":\"$gateway_ip\",\"dnsServers\":$dns_servers,\"networkInterfaces\":$network_interfaces}"
}
# Get system information
get_system_info() {
local kernel_version=""
local selinux_status=""
local system_uptime=""
local load_average="[]"
# Kernel Version
if [[ -f /proc/version ]]; then
kernel_version=$(cat /proc/version | awk '{print $3}')
elif command -v uname >/dev/null 2>&1; then
kernel_version=$(uname -r)
fi
# SELinux Status
if command -v getenforce >/dev/null 2>&1; then
selinux_status=$(getenforce 2>/dev/null | tr '[:upper:]' '[:lower:]')
elif [[ -f /etc/selinux/config ]]; then
selinux_status=$(grep "^SELINUX=" /etc/selinux/config | cut -d'=' -f2 | tr '[:upper:]' '[:lower:]')
else
selinux_status="disabled"
fi
# System Uptime
if [[ -f /proc/uptime ]]; then
local uptime_seconds=$(cat /proc/uptime | awk '{print int($1)}')
local days=$((uptime_seconds / 86400))
local hours=$(((uptime_seconds % 86400) / 3600))
local minutes=$(((uptime_seconds % 3600) / 60))
system_uptime="${days}d ${hours}h ${minutes}m"
elif command -v uptime >/dev/null 2>&1; then
system_uptime=$(uptime | awk -F'up ' '{print $2}' | awk -F', load' '{print $1}')
fi
# Load Average
if [[ -f /proc/loadavg ]]; then
load_average=$(cat /proc/loadavg | awk '{print "["$1","$2","$3"]"}')
elif command -v uptime >/dev/null 2>&1; then
load_average=$(uptime | awk -F'load average: ' '{print "["$2"]"}' | tr -d ' ')
fi
echo "{\"kernelVersion\":\"$kernel_version\",\"selinuxStatus\":\"$selinux_status\",\"systemUptime\":\"$system_uptime\",\"loadAverage\":$load_average}"
}
# Send package update to server
send_update() {
load_credentials
@@ -666,14 +774,27 @@ send_update() {
info "Collecting repository information..."
local repositories_json=$(get_repository_info)
info "Collecting hardware information..."
local hardware_json=$(get_hardware_info)
info "Collecting network information..."
local network_json=$(get_network_info)
info "Collecting system information..."
local system_json=$(get_system_info)
info "Sending update to PatchMon server..."
local payload=$(cat <<EOF
# Merge all JSON objects into one
local merged_json=$(echo "$hardware_json $network_json $system_json" | jq -s '.[0] * .[1] * .[2]')
# Create the base payload and merge with system info
local base_payload=$(cat <<EOF
{
"packages": $packages_json,
"repositories": $repositories_json,
"osType": "$OS_TYPE",
"osVersion": "$OS_VERSION",
"hostname": "$HOSTNAME",
"ip": "$IP_ADDRESS",
"architecture": "$ARCHITECTURE",
"agentVersion": "$AGENT_VERSION"
@@ -681,6 +802,10 @@ send_update() {
EOF
)
# Merge the base payload with the system information
local payload=$(echo "$base_payload $merged_json" | jq -s '.[0] * .[1]')
local response=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "X-API-ID: $API_ID" \

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,67 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function checkAgentVersion() {
try {
// Check current agent version in database
const agentVersion = await prisma.agentVersion.findFirst({
where: { version: '1.2.5' }
});
if (agentVersion) {
console.log('✅ Agent version 1.2.5 found in database');
console.log('Version:', agentVersion.version);
console.log('Is Default:', agentVersion.isDefault);
console.log('Script Content Length:', agentVersion.scriptContent?.length || 0);
console.log('Created At:', agentVersion.createdAt);
console.log('Updated At:', agentVersion.updatedAt);
// Check if script content contains the current version
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('AGENT_VERSION="1.2.5"')) {
console.log('✅ Script content contains correct version 1.2.5');
} else {
console.log('❌ Script content does not contain version 1.2.5');
}
// Check if script content contains system info functions
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('get_hardware_info()')) {
console.log('✅ Script content contains hardware info function');
} else {
console.log('❌ Script content missing hardware info function');
}
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('get_network_info()')) {
console.log('✅ Script content contains network info function');
} else {
console.log('❌ Script content missing network info function');
}
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('get_system_info()')) {
console.log('✅ Script content contains system info function');
} else {
console.log('❌ Script content missing system info function');
}
} else {
console.log('❌ Agent version 1.2.5 not found in database');
}
// List all agent versions
console.log('\n=== All Agent Versions ===');
const allVersions = await prisma.agentVersion.findMany({
orderBy: { createdAt: 'desc' }
});
allVersions.forEach(version => {
console.log(`Version: ${version.version}, Default: ${version.isDefault}, Length: ${version.scriptContent?.length || 0}`);
});
} catch (error) {
console.error('❌ Error checking agent version:', error);
} finally {
await prisma.$disconnect();
}
}
checkAgentVersion();

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon-backend",
"version": "1.0.0",
"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"
},

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "settings" ADD COLUMN "ssh_key_path" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "settings" ADD COLUMN "repository_type" TEXT NOT NULL DEFAULT 'public';

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
-- AlterTable
ALTER TABLE "hosts" ADD COLUMN "cpu_cores" INTEGER,
ADD COLUMN "cpu_model" TEXT,
ADD COLUMN "disk_details" JSONB,
ADD COLUMN "dns_servers" JSONB,
ADD COLUMN "gateway_ip" TEXT,
ADD COLUMN "hostname" TEXT,
ADD COLUMN "kernel_version" TEXT,
ADD COLUMN "load_average" JSONB,
ADD COLUMN "network_interfaces" JSONB,
ADD COLUMN "ram_installed" INTEGER,
ADD COLUMN "selinux_status" TEXT,
ADD COLUMN "swap_size" INTEGER,
ADD COLUMN "system_uptime" TEXT;

View File

@@ -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,6 +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

View File

@@ -0,0 +1,80 @@
/**
* Database configuration for multiple instances
* Optimizes connection pooling to prevent "too many connections" errors
*/
const { PrismaClient } = require('@prisma/client');
// Parse DATABASE_URL and add connection pooling parameters
function getOptimizedDatabaseUrl() {
const originalUrl = process.env.DATABASE_URL;
if (!originalUrl) {
throw new Error('DATABASE_URL environment variable is required');
}
// Parse the URL
const url = new URL(originalUrl);
// Add connection pooling parameters for multiple instances
url.searchParams.set('connection_limit', '5'); // Reduced from default 10
url.searchParams.set('pool_timeout', '10'); // 10 seconds
url.searchParams.set('connect_timeout', '10'); // 10 seconds
url.searchParams.set('idle_timeout', '300'); // 5 minutes
url.searchParams.set('max_lifetime', '1800'); // 30 minutes
return url.toString();
}
// Create optimized Prisma client
function createPrismaClient() {
const optimizedUrl = getOptimizedDatabaseUrl();
return new PrismaClient({
datasources: {
db: {
url: optimizedUrl
}
},
log: process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['warn', 'error'],
errorFormat: 'pretty'
});
}
// Connection health check
async function checkDatabaseConnection(prisma) {
try {
await prisma.$queryRaw`SELECT 1`;
return true;
} catch (error) {
console.error('Database connection failed:', error.message);
return false;
}
}
// Graceful disconnect with retry
async function disconnectPrisma(prisma, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
await prisma.$disconnect();
console.log('Database disconnected successfully');
return;
} catch (error) {
console.error(`Disconnect attempt ${i + 1} failed:`, error.message);
if (i === maxRetries - 1) {
console.error('Failed to disconnect from database after all retries');
} else {
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
}
}
}
}
module.exports = {
createPrismaClient,
checkDatabaseConnection,
disconnectPrisma,
getOptimizedDatabaseUrl
};

View File

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

View File

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

View File

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

View File

@@ -41,6 +41,7 @@ router.get('/:id', authenticateToken, async (req, res) => {
hosts: {
select: {
id: true,
friendlyName: true,
hostname: true,
ip: true,
osType: true,
@@ -201,7 +202,7 @@ router.get('/:id/hosts', authenticateToken, async (req, res) => {
where: { hostGroupId: id },
select: {
id: true,
hostname: true,
friendlyName: true,
ip: true,
osType: true,
osVersion: true,
@@ -211,7 +212,7 @@ router.get('/:id/hosts', authenticateToken, async (req, res) => {
createdAt: true
},
orderBy: {
hostname: 'asc'
friendlyName: 'asc'
}
});

View File

@@ -133,7 +133,7 @@ const validateApiCredentials = async (req, res, next) => {
// Admin endpoint to create a new host manually (replaces auto-registration)
router.post('/create', authenticateToken, requireManageHosts, [
body('hostname').isLength({ min: 1 }).withMessage('Hostname is required'),
body('friendlyName').isLength({ min: 1 }).withMessage('Friendly name is required'),
body('hostGroupId').optional()
], async (req, res) => {
try {
@@ -142,14 +142,14 @@ router.post('/create', authenticateToken, requireManageHosts, [
return res.status(400).json({ errors: errors.array() });
}
const { hostname, hostGroupId } = req.body;
const { friendlyName, hostGroupId } = req.body;
// Generate unique API credentials for this host
const { apiId, apiKey } = generateApiCredentials();
// Check if host already exists
const existingHost = await prisma.host.findUnique({
where: { hostname }
where: { friendlyName }
});
if (existingHost) {
@@ -170,7 +170,7 @@ router.post('/create', authenticateToken, requireManageHosts, [
// Create new host with API credentials - system info will be populated when agent connects
const host = await prisma.host.create({
data: {
hostname,
friendlyName,
osType: 'unknown', // Will be updated when agent connects
osVersion: 'unknown', // Will be updated when agent connects
ip: null, // Will be updated when agent connects
@@ -194,7 +194,7 @@ router.post('/create', authenticateToken, requireManageHosts, [
res.status(201).json({
message: 'Host created successfully',
hostId: host.id,
hostname: host.hostname,
friendlyName: host.friendlyName,
apiId: host.apiId,
apiKey: host.apiKey,
hostGroup: host.hostGroup,
@@ -223,7 +223,22 @@ router.post('/update', validateApiCredentials, [
body('packages.*.availableVersion').optional().isLength({ min: 1 }),
body('packages.*.needsUpdate').isBoolean().withMessage('needsUpdate must be boolean'),
body('packages.*.isSecurityUpdate').optional().isBoolean().withMessage('isSecurityUpdate must be boolean'),
body('agentVersion').optional().isLength({ min: 1 }).withMessage('Agent version must be a non-empty string')
body('agentVersion').optional().isLength({ min: 1 }).withMessage('Agent version must be a non-empty string'),
// Hardware Information
body('cpuModel').optional().isString().withMessage('CPU model must be a string'),
body('cpuCores').optional().isInt({ min: 1 }).withMessage('CPU cores must be a positive integer'),
body('ramInstalled').optional().isInt({ min: 1 }).withMessage('RAM installed must be a positive integer'),
body('swapSize').optional().isInt({ min: 0 }).withMessage('Swap size must be a non-negative integer'),
body('diskDetails').optional().isArray().withMessage('Disk details must be an array'),
// Network Information
body('gatewayIp').optional().isIP().withMessage('Gateway IP must be a valid IP address'),
body('dnsServers').optional().isArray().withMessage('DNS servers must be an array'),
body('networkInterfaces').optional().isArray().withMessage('Network interfaces must be an array'),
// System Information
body('kernelVersion').optional().isString().withMessage('Kernel version must be a string'),
body('selinuxStatus').optional().isIn(['enabled', 'disabled', 'permissive']).withMessage('SELinux status must be enabled, disabled, or permissive'),
body('systemUptime').optional().isString().withMessage('System uptime must be a string'),
body('loadAverage').optional().isArray().withMessage('Load average must be an array')
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -234,14 +249,35 @@ router.post('/update', validateApiCredentials, [
const { packages, repositories } = req.body;
const host = req.hostRecord;
// Update host last update timestamp and OS info if provided
// Update host last update timestamp and system info if provided
const updateData = { lastUpdate: new Date() };
// Basic system info
if (req.body.osType) updateData.osType = req.body.osType;
if (req.body.osVersion) updateData.osVersion = req.body.osVersion;
if (req.body.hostname) updateData.hostname = req.body.hostname;
if (req.body.ip) updateData.ip = req.body.ip;
if (req.body.architecture) updateData.architecture = req.body.architecture;
if (req.body.agentVersion) updateData.agentVersion = req.body.agentVersion;
// Hardware Information
if (req.body.cpuModel) updateData.cpuModel = req.body.cpuModel;
if (req.body.cpuCores) updateData.cpuCores = req.body.cpuCores;
if (req.body.ramInstalled) updateData.ramInstalled = req.body.ramInstalled;
if (req.body.swapSize !== undefined) updateData.swapSize = req.body.swapSize;
if (req.body.diskDetails) updateData.diskDetails = req.body.diskDetails;
// Network Information
if (req.body.gatewayIp) updateData.gatewayIp = req.body.gatewayIp;
if (req.body.dnsServers) updateData.dnsServers = req.body.dnsServers;
if (req.body.networkInterfaces) updateData.networkInterfaces = req.body.networkInterfaces;
// System Information
if (req.body.kernelVersion) updateData.kernelVersion = req.body.kernelVersion;
if (req.body.selinuxStatus) updateData.selinuxStatus = req.body.selinuxStatus;
if (req.body.systemUptime) updateData.systemUptime = req.body.systemUptime;
if (req.body.loadAverage) updateData.loadAverage = req.body.loadAverage;
// If this is the first update (status is 'pending'), change to 'active'
if (host.status === 'pending') {
updateData.status = 'active';
@@ -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;

View File

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

View File

@@ -17,7 +17,7 @@ router.get('/', authenticateToken, requireViewHosts, async (req, res) => {
host: {
select: {
id: true,
hostname: true,
friendlyName: true,
status: true
}
}
@@ -43,7 +43,7 @@ router.get('/', authenticateToken, requireViewHosts, async (req, res) => {
activeHostCount: repo.hostRepositories.filter(hr => hr.host.status === 'active').length,
hosts: repo.hostRepositories.map(hr => ({
id: hr.host.id,
hostname: hr.host.hostname,
friendlyName: hr.host.friendlyName,
status: hr.host.status,
isEnabled: hr.isEnabled,
lastChecked: hr.lastChecked
@@ -69,7 +69,7 @@ router.get('/host/:hostId', authenticateToken, requireViewHosts, async (req, res
host: {
select: {
id: true,
hostname: true
friendlyName: true
}
}
},
@@ -100,6 +100,7 @@ router.get('/:repositoryId', authenticateToken, requireViewHosts, async (req, re
host: {
select: {
id: true,
friendlyName: true,
hostname: true,
ip: true,
osType: true,
@@ -111,7 +112,7 @@ router.get('/:repositoryId', authenticateToken, requireViewHosts, async (req, re
},
orderBy: {
host: {
hostname: 'asc'
friendlyName: 'asc'
}
}
}
@@ -197,14 +198,14 @@ router.patch('/host/:hostId/repository/:repositoryId', authenticateToken, requir
repository: true,
host: {
select: {
hostname: true
friendlyName: true
}
}
}
});
res.json({
message: `Repository ${isEnabled ? 'enabled' : 'disabled'} for host ${hostRepository.host.hostname}`,
message: `Repository ${isEnabled ? 'enabled' : 'disabled'} for host ${hostRepository.host.friendlyName}`,
hostRepository
});
} catch (error) {

View File

@@ -20,7 +20,7 @@ async function triggerCrontabUpdates() {
},
select: {
id: true,
hostname: true,
friendlyName: true,
apiId: true,
apiKey: true
}
@@ -32,7 +32,7 @@ async function triggerCrontabUpdates() {
// This is done by sending a ping with a special flag
for (const host of hosts) {
try {
console.log(`Triggering crontab update for host: ${host.hostname}`);
console.log(`Triggering crontab update for host: ${host.friendlyName}`);
// We'll use the existing ping endpoint but add a special parameter
// The agent will detect this and run update-crontab command
@@ -64,20 +64,20 @@ async function triggerCrontabUpdates() {
const req = client.request(options, (res) => {
if (res.statusCode === 200) {
console.log(`Successfully triggered crontab update for ${host.hostname}`);
console.log(`Successfully triggered crontab update for ${host.friendlyName}`);
} else {
console.error(`Failed to trigger crontab update for ${host.hostname}: ${res.statusCode}`);
console.error(`Failed to trigger crontab update for ${host.friendlyName}: ${res.statusCode}`);
}
});
req.on('error', (error) => {
console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
console.error(`Error triggering crontab update for ${host.friendlyName}:`, error.message);
});
req.write(postData);
req.end();
} catch (error) {
console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
console.error(`Error triggering crontab update for ${host.friendlyName}:`, error.message);
}
}
@@ -123,7 +123,17 @@ router.put('/', authenticateToken, requireManageSettings, [
body('frontendUrl').isLength({ min: 1 }).withMessage('Frontend URL is required'),
body('updateInterval').isInt({ min: 5, max: 1440 }).withMessage('Update interval must be between 5 and 1440 minutes'),
body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean'),
body('githubRepoUrl').optional().isLength({ min: 1 }).withMessage('GitHub repo URL must be a non-empty string')
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
}
if (value && value.trim().length < 1) {
throw new Error('SSH key path must be a non-empty string');
}
return true;
})
], async (req, res) => {
try {
console.log('Settings update request body:', req.body);
@@ -133,8 +143,9 @@ router.put('/', authenticateToken, requireManageSettings, [
return res.status(400).json({ errors: errors.array() });
}
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl } = req.body;
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl });
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}`;
@@ -151,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({
@@ -165,7 +178,9 @@ 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
}
});
console.log('Settings updated successfully:', settings);
@@ -186,7 +201,9 @@ 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
}
});
}

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

View File

@@ -0,0 +1,195 @@
const express = require('express');
const { authenticateToken } = require('../middleware/auth');
const { requireManageSettings } = require('../middleware/permissions');
const { PrismaClient } = require('@prisma/client');
const { exec } = require('child_process');
const { promisify } = require('util');
const prisma = new PrismaClient();
const execAsync = promisify(exec);
const router = express.Router();
// Get current version info
router.get('/current', authenticateToken, async (req, res) => {
try {
// For now, return hardcoded version - this should match your agent version
const currentVersion = '1.2.5';
res.json({
version: currentVersion,
buildDate: new Date().toISOString(),
environment: process.env.NODE_ENV || 'development'
});
} catch (error) {
console.error('Error getting current version:', error);
res.status(500).json({ error: 'Failed to get current version' });
}
});
// Test SSH key permissions and GitHub access
router.post('/test-ssh-key', authenticateToken, requireManageSettings, async (req, res) => {
try {
const { sshKeyPath, githubRepoUrl } = req.body;
if (!sshKeyPath || !githubRepoUrl) {
return res.status(400).json({
error: 'SSH key path and GitHub repo URL are required'
});
}
// Parse repository info
let owner, repo;
if (githubRepoUrl.includes('git@github.com:')) {
const match = githubRepoUrl.match(/git@github\.com:([^\/]+)\/([^\/]+)\.git/);
if (match) {
[, owner, repo] = match;
}
} else if (githubRepoUrl.includes('github.com/')) {
const match = githubRepoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/);
if (match) {
[, owner, repo] = match;
}
}
if (!owner || !repo) {
return res.status(400).json({
error: 'Invalid GitHub repository URL format'
});
}
// Check if SSH key file exists and is readable
try {
require('fs').accessSync(sshKeyPath);
} catch (e) {
return res.status(400).json({
error: 'SSH key file not found or not accessible',
details: `Cannot access: ${sshKeyPath}`,
suggestion: 'Check the file path and ensure the application has read permissions'
});
}
// Test SSH connection to GitHub
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
const env = {
...process.env,
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -o ConnectTimeout=10`
};
try {
// Test with a simple git command
const { stdout } = await execAsync(
`git ls-remote --heads ${sshRepoUrl} | head -n 1`,
{
timeout: 15000,
env: env
}
);
if (stdout.trim()) {
return res.json({
success: true,
message: 'SSH key is working correctly',
details: {
sshKeyPath,
repository: `${owner}/${repo}`,
testResult: 'Successfully connected to GitHub'
}
});
} else {
return res.status(400).json({
error: 'SSH connection succeeded but no data returned',
suggestion: 'Check repository access permissions'
});
}
} catch (sshError) {
console.error('SSH test error:', sshError.message);
if (sshError.message.includes('Permission denied')) {
return res.status(403).json({
error: 'SSH key permission denied',
details: 'The SSH key exists but GitHub rejected the connection',
suggestion: 'Verify the SSH key is added to the repository as a deploy key with read access'
});
} else if (sshError.message.includes('Host key verification failed')) {
return res.status(403).json({
error: 'Host key verification failed',
suggestion: 'This is normal for first-time connections. The key will be added to known_hosts automatically.'
});
} else if (sshError.message.includes('Connection timed out')) {
return res.status(408).json({
error: 'Connection timed out',
suggestion: 'Check your internet connection and GitHub status'
});
} else {
return res.status(500).json({
error: 'SSH connection failed',
details: sshError.message,
suggestion: 'Check the SSH key format and repository URL'
});
}
}
} catch (error) {
console.error('SSH key test error:', error);
res.status(500).json({
error: 'Failed to test SSH key',
details: error.message
});
}
});
// Check for updates from GitHub
router.get('/check-updates', authenticateToken, requireManageSettings, async (req, res) => {
try {
// Get cached update information from settings
const settings = await prisma.settings.findFirst();
if (!settings) {
return res.status(400).json({ error: 'Settings not found' });
}
const currentVersion = '1.2.5';
const latestVersion = settings.latestVersion || currentVersion;
const isUpdateAvailable = settings.updateAvailable || false;
const lastUpdateCheck = settings.lastUpdateCheck;
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'
}
});
} catch (error) {
console.error('Error getting update information:', error);
res.status(500).json({ error: 'Failed to get update information' });
}
});
// Simple version comparison function
function 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;
}
module.exports = router;

View File

@@ -3,7 +3,7 @@ const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const { PrismaClient } = require('@prisma/client');
const { createPrismaClient, checkDatabaseConnection, disconnectPrisma } = require('./config/database');
const winston = require('winston');
// Import routes
@@ -16,9 +16,12 @@ const permissionsRoutes = require('./routes/permissionsRoutes');
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({
@@ -134,6 +137,8 @@ app.use(`/api/${apiVersion}/permissions`, permissionsRoutes);
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) => {
@@ -152,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;

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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -1,7 +1,7 @@
{
"name": "patchmon-frontend",
"private": true,
"version": "1.0.0",
"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
View 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')}`);
});

View File

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

View File

@@ -0,0 +1,157 @@
import React, { useState, useRef, useEffect } from 'react';
import { Edit2, Check, X } from 'lucide-react';
import { Link } from 'react-router-dom';
const InlineEdit = ({
value,
onSave,
onCancel,
placeholder = "Enter value...",
maxLength = 100,
className = "",
disabled = false,
validate = null,
linkTo = null
}) => {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(value);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const inputRef = useRef(null);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
useEffect(() => {
setEditValue(value);
}, [value]);
const handleEdit = () => {
if (disabled) return;
setIsEditing(true);
setEditValue(value);
setError('');
};
const handleCancel = () => {
setIsEditing(false);
setEditValue(value);
setError('');
if (onCancel) onCancel();
};
const handleSave = async () => {
if (disabled || isLoading) return;
// Validate if validator function provided
if (validate) {
const validationError = validate(editValue);
if (validationError) {
setError(validationError);
return;
}
}
// Check if value actually changed
if (editValue.trim() === value.trim()) {
setIsEditing(false);
return;
}
setIsLoading(true);
setError('');
try {
await onSave(editValue.trim());
setIsEditing(false);
} catch (err) {
setError(err.message || 'Failed to save');
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
};
if (isEditing) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<input
ref={inputRef}
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
maxLength={maxLength}
disabled={isLoading}
className={`flex-1 px-2 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent ${
error ? 'border-red-500' : ''
} ${isLoading ? 'opacity-50' : ''}`}
/>
<button
onClick={handleSave}
disabled={isLoading || editValue.trim() === ''}
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Save"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={handleCancel}
disabled={isLoading}
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
{error && (
<span className="text-xs text-red-600 dark:text-red-400">{error}</span>
)}
</div>
);
}
const displayValue = linkTo ? (
<Link
to={linkTo}
className="text-sm font-medium text-secondary-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 cursor-pointer transition-colors"
title="View details"
>
{value}
</Link>
) : (
<span className="text-sm font-medium text-secondary-900 dark:text-white">
{value}
</span>
);
return (
<div className={`flex items-center gap-2 group ${className}`}>
{displayValue}
{!disabled && (
<button
onClick={handleEdit}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
title="Edit"
>
<Edit2 className="h-3 w-3" />
</button>
)}
</div>
);
};
export default InlineEdit;

View File

@@ -0,0 +1,262 @@
import React, { useState, useRef, useEffect } from 'react';
import { Edit2, Check, X, ChevronDown } from 'lucide-react';
const InlineGroupEdit = ({
value,
onSave,
onCancel,
options = [],
className = "",
disabled = false,
placeholder = "Select group..."
}) => {
const [isEditing, setIsEditing] = useState(false);
const [selectedValue, setSelectedValue] = useState(value);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0, width: 0 });
const dropdownRef = useRef(null);
const buttonRef = useRef(null);
useEffect(() => {
if (isEditing && dropdownRef.current) {
dropdownRef.current.focus();
}
}, [isEditing]);
useEffect(() => {
setSelectedValue(value);
// Force re-render when value changes
if (!isEditing) {
setIsOpen(false);
}
}, [value, isEditing]);
// Calculate dropdown position
const calculateDropdownPosition = () => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX,
width: rect.width
});
}
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
if (isOpen) {
calculateDropdownPosition();
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('resize', calculateDropdownPosition);
window.addEventListener('scroll', calculateDropdownPosition);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('resize', calculateDropdownPosition);
window.removeEventListener('scroll', calculateDropdownPosition);
};
}
}, [isOpen]);
const handleEdit = () => {
if (disabled) return;
setIsEditing(true);
setSelectedValue(value);
setError('');
// Automatically open dropdown when editing starts
setTimeout(() => {
setIsOpen(true);
}, 0);
};
const handleCancel = () => {
setIsEditing(false);
setSelectedValue(value);
setError('');
setIsOpen(false);
if (onCancel) onCancel();
};
const handleSave = async () => {
if (disabled || isLoading) return;
console.log('handleSave called:', { selectedValue, originalValue: value, changed: selectedValue !== value });
// Check if value actually changed
if (selectedValue === value) {
console.log('No change detected, closing edit mode');
setIsEditing(false);
setIsOpen(false);
return;
}
setIsLoading(true);
setError('');
try {
console.log('Calling onSave with:', selectedValue);
await onSave(selectedValue);
console.log('Save successful');
// Update the local value to match the saved value
setSelectedValue(selectedValue);
setIsEditing(false);
setIsOpen(false);
} catch (err) {
console.error('Save failed:', err);
setError(err.message || 'Failed to save');
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
e.preventDefault();
handleCancel();
}
};
const getDisplayValue = () => {
console.log('getDisplayValue called with:', { value, options });
if (!value) {
console.log('No value, returning Ungrouped');
return 'Ungrouped';
}
const option = options.find(opt => opt.id === value);
console.log('Found option:', option);
return option ? option.name : 'Unknown Group';
};
const getDisplayColor = () => {
if (!value) return 'bg-secondary-100 text-secondary-800';
const option = options.find(opt => opt.id === value);
return option ? `text-white` : 'bg-secondary-100 text-secondary-800';
};
if (isEditing) {
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<button
ref={buttonRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
disabled={isLoading}
className={`w-full px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent flex items-center justify-between ${
error ? 'border-red-500' : ''
} ${isLoading ? 'opacity-50' : ''}`}
>
<span className="truncate">
{selectedValue ? options.find(opt => opt.id === selectedValue)?.name || 'Unknown Group' : 'Ungrouped'}
</span>
<ChevronDown className="h-4 w-4 flex-shrink-0" />
</button>
{isOpen && (
<div
className="fixed z-50 bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-lg max-h-60 overflow-auto"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
minWidth: '200px'
}}
>
<div className="py-1">
<button
type="button"
onClick={() => {
setSelectedValue(null);
setIsOpen(false);
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
selectedValue === null ? 'bg-primary-50 dark:bg-primary-900/20' : ''
}`}
>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
Ungrouped
</span>
</button>
{options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
setSelectedValue(option.id);
setIsOpen(false);
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
selectedValue === option.id ? 'bg-primary-50 dark:bg-primary-900/20' : ''
}`}
>
<span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: option.color }}
>
{option.name}
</span>
</button>
))}
</div>
</div>
)}
</div>
<button
onClick={handleSave}
disabled={isLoading}
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Save"
>
<Check className="h-4 w-4" />
</button>
<button
onClick={handleCancel}
disabled={isLoading}
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
</div>
{error && (
<span className="text-xs text-red-600 dark:text-red-400 mt-1 block">{error}</span>
)}
</div>
);
}
return (
<div className={`flex items-center gap-2 group ${className}`}>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getDisplayColor()}`}
style={value ? { backgroundColor: options.find(opt => opt.id === value)?.color } : {}}
>
{getDisplayValue()}
</span>
{!disabled && (
<button
onClick={handleEdit}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
title="Edit group"
>
<Edit2 className="h-3 w-3" />
</button>
)}
</div>
);
};
export default InlineGroupEdit;

View File

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

View 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

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

View File

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

View File

@@ -31,11 +31,14 @@ import {
EyeOff as EyeOffIcon
} from 'lucide-react'
import { dashboardAPI, adminHostsAPI, settingsAPI, hostGroupsAPI, formatRelativeTime } from '../utils/api'
import { OSIcon } from '../utils/osIcons.jsx'
import InlineEdit from '../components/InlineEdit'
import InlineGroupEdit from '../components/InlineGroupEdit'
// Add Host Modal Component
const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
const [formData, setFormData] = useState({
hostname: '',
friendlyName: '',
hostGroupId: ''
})
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -59,7 +62,7 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
const response = await adminHostsAPI.create(formData)
console.log('Host created successfully:', response.data)
onSuccess(response.data)
setFormData({ hostname: '', hostGroupId: '' })
setFormData({ friendlyName: '', hostGroupId: '' })
onClose()
} catch (err) {
console.error('Full error object:', err)
@@ -98,12 +101,12 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">Hostname *</label>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">Friendly Name *</label>
<input
type="text"
required
value={formData.hostname}
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
value={formData.friendlyName}
onChange={(e) => setFormData({ ...formData, friendlyName: e.target.value })}
className="block w-full px-3 py-2.5 text-base border-2 border-secondary-300 dark:border-secondary-600 rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white transition-all duration-200"
placeholder="server.example.com"
/>
@@ -249,7 +252,7 @@ echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo
fullSetup: `#!/bin/bash
# Complete PatchMon Agent Setup Script
# Run this on the target host: ${host?.hostname}
# Run this on the target host: ${host?.friendlyName}
echo "🔄 Setting up PatchMon agent..."
@@ -292,7 +295,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900">Host Setup - {host.hostname}</h3>
<h3 className="text-lg font-medium text-secondary-900">Host Setup - {host.friendlyName}</h3>
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600">
<X className="h-5 w-5" />
</button>
@@ -348,7 +351,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<h4 className="text-sm font-medium text-green-800 mb-2">🚀 One-Line Installation</h4>
<p className="text-sm text-green-700">
Copy and paste this single command on <strong>{host.hostname}</strong> to install and configure the PatchMon agent automatically.
Copy and paste this single command on <strong>{host.friendlyName}</strong> to install and configure the PatchMon agent automatically.
</p>
</div>
@@ -375,7 +378,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<ul className="text-sm text-blue-700 space-y-1">
<li> Downloads the PatchMon installation script</li>
<li> Installs the agent to <code>/usr/local/bin/patchmon-agent.sh</code></li>
<li> Configures API credentials for <strong>{host.hostname}</strong></li>
<li> Configures API credentials for <strong>{host.friendlyName}</strong></li>
<li> Tests the connection to PatchMon server</li>
<li> Sends initial package data</li>
<li> Sets up hourly automatic updates via crontab</li>
@@ -441,7 +444,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="bg-amber-50 border border-amber-200 rounded-md p-4">
<h4 className="text-sm font-medium text-amber-800 mb-2"> Security Note</h4>
<p className="text-sm text-amber-700">
Keep these credentials secure. They provide access to update package information for <strong>{host.hostname}</strong> only.
Keep these credentials secure. They provide access to update package information for <strong>{host.friendlyName}</strong> only.
</p>
</div>
</div>
@@ -452,7 +455,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<h4 className="text-sm font-medium text-blue-800 mb-2">📋 Step-by-Step Setup</h4>
<p className="text-sm text-blue-700">
Follow these commands on <strong>{host.hostname}</strong> to install and configure the PatchMon agent.
Follow these commands on <strong>{host.friendlyName}</strong> to install and configure the PatchMon agent.
</p>
</div>
@@ -549,7 +552,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<h4 className="text-sm font-medium text-green-800 mb-2">🚀 Automated Setup</h4>
<p className="text-sm text-green-700">
Copy this complete setup script to <strong>{host.hostname}</strong> and run it to automatically install and configure everything.
Copy this complete setup script to <strong>{host.friendlyName}</strong> and run it to automatically install and configure everything.
</p>
</div>
@@ -570,7 +573,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="mt-3 text-sm text-secondary-600">
<p><strong>Usage:</strong></p>
<p>1. Copy the script above</p>
<p>2. Save it to a file on {host.hostname} (e.g., <code>setup-patchmon.sh</code>)</p>
<p>2. Save it to a file on {host.friendlyName} (e.g., <code>setup-patchmon.sh</code>)</p>
<p>3. Run: <code>chmod +x setup-patchmon.sh && sudo ./setup-patchmon.sh</code></p>
</div>
</div>
@@ -642,13 +645,24 @@ const Hosts = () => {
newSearchParams.delete('action')
navigate(`/hosts${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ''}`, { replace: true })
}
// Handle selected hosts from packages page
const selected = searchParams.get('selected')
if (selected) {
const hostIds = selected.split(',').filter(Boolean)
setSelectedHosts(hostIds)
// Remove the selected parameter from URL without triggering a page reload
const newSearchParams = new URLSearchParams(searchParams)
newSearchParams.delete('selected')
navigate(`/hosts${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ''}`, { replace: true })
}
}, [searchParams, navigate])
// Column configuration
const [columnConfig, setColumnConfig] = useState(() => {
const defaultConfig = [
{ id: 'select', label: 'Select', visible: true, order: 0 },
{ id: 'host', label: 'Host', visible: true, order: 1 },
{ id: 'host', label: 'Friendly Name', visible: true, order: 1 },
{ id: 'ip', label: 'IP Address', visible: false, order: 2 },
{ id: 'group', label: 'Group', visible: true, order: 3 },
{ id: 'os', label: 'OS', visible: true, order: 4 },
@@ -732,7 +746,28 @@ const Hosts = () => {
const bulkUpdateGroupMutation = useMutation({
mutationFn: ({ hostIds, hostGroupId }) => adminHostsAPI.bulkUpdateGroup(hostIds, hostGroupId),
onSuccess: () => {
onSuccess: (data) => {
console.log('bulkUpdateGroupMutation success:', data);
// Update the cache with the new host data
if (data && data.hosts) {
queryClient.setQueryData(['hosts'], (oldData) => {
if (!oldData) return oldData;
return oldData.map(host => {
const updatedHost = data.hosts.find(h => h.id === host.id);
if (updatedHost) {
// Ensure hostGroupId is set correctly
return {
...updatedHost,
hostGroupId: updatedHost.hostGroup?.id || null
};
}
return host;
});
});
}
// Also invalidate to ensure consistency
queryClient.invalidateQueries(['hosts'])
setSelectedHosts([])
setShowBulkAssignModal(false)
@@ -747,6 +782,55 @@ const Hosts = () => {
}
})
const updateFriendlyNameMutation = useMutation({
mutationFn: ({ hostId, friendlyName }) => adminHostsAPI.updateFriendlyName(hostId, friendlyName).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries(['hosts'])
}
})
const updateHostGroupMutation = useMutation({
mutationFn: ({ hostId, hostGroupId }) => {
console.log('updateHostGroupMutation called with:', { hostId, hostGroupId });
return adminHostsAPI.updateGroup(hostId, hostGroupId).then(res => {
console.log('updateGroup API response:', res);
return res.data;
});
},
onSuccess: (data) => {
console.log('updateHostGroupMutation success:', data);
console.log('Updated host data:', data.host);
console.log('Host group in response:', data.host.hostGroup);
// Update the cache with the new host data
queryClient.setQueryData(['hosts'], (oldData) => {
console.log('Old cache data before update:', oldData);
if (!oldData) return oldData;
const updatedData = oldData.map(host => {
if (host.id === data.host.id) {
console.log('Updating host in cache:', host.id, 'with new data:', data.host);
// Ensure hostGroupId is set correctly
const updatedHost = {
...data.host,
hostGroupId: data.host.hostGroup?.id || null
};
console.log('Updated host with hostGroupId:', updatedHost);
return updatedHost;
}
return host;
});
console.log('New cache data after update:', updatedData);
return updatedData;
});
// Also invalidate to ensure consistency
queryClient.invalidateQueries(['hosts'])
},
onError: (error) => {
console.error('updateHostGroupMutation error:', error);
}
})
// Helper functions for bulk selection
const handleSelectHost = (hostId) => {
setSelectedHosts(prev =>
@@ -775,7 +859,7 @@ const Hosts = () => {
let filtered = hosts.filter(host => {
// Search filter
const matchesSearch = searchTerm === '' ||
host.hostname.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.friendlyName.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.ip?.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.osType?.toLowerCase().includes(searchTerm.toLowerCase())
@@ -808,9 +892,13 @@ const Hosts = () => {
let aValue, bValue
switch (sortField) {
case 'friendlyName':
aValue = a.friendlyName.toLowerCase()
bValue = b.friendlyName.toLowerCase()
break
case 'hostname':
aValue = a.hostname.toLowerCase()
bValue = b.hostname.toLowerCase()
aValue = a.hostname?.toLowerCase() || 'zzz_no_hostname'
bValue = b.hostname?.toLowerCase() || 'zzz_no_hostname'
break
case 'ip':
aValue = a.ip?.toLowerCase() || 'zzz_no_ip'
@@ -929,15 +1017,16 @@ const Hosts = () => {
const resetColumns = () => {
const defaultConfig = [
{ id: 'select', label: 'Select', visible: true, order: 0 },
{ id: 'host', label: 'Host', visible: true, order: 1 },
{ id: 'ip', label: 'IP Address', visible: false, order: 2 },
{ id: 'group', label: 'Group', visible: true, order: 3 },
{ id: 'os', label: 'OS', visible: true, order: 4 },
{ id: 'osVersion', label: 'OS Version', visible: false, order: 5 },
{ id: 'status', label: 'Status', visible: true, order: 6 },
{ id: 'updates', label: 'Updates', visible: true, order: 7 },
{ id: 'lastUpdate', label: 'Last Update', visible: true, order: 8 },
{ id: 'actions', label: 'Actions', visible: true, order: 9 }
{ id: 'host', label: 'Friendly Name', visible: true, order: 1 },
{ id: 'hostname', label: 'System Hostname', visible: true, order: 2 },
{ id: 'ip', label: 'IP Address', visible: false, order: 3 },
{ id: 'group', label: 'Group', visible: true, order: 4 },
{ id: 'os', label: 'OS', visible: true, order: 5 },
{ id: 'osVersion', label: 'OS Version', visible: false, order: 6 },
{ id: 'status', label: 'Status', visible: true, order: 7 },
{ id: 'updates', label: 'Updates', visible: true, order: 8 },
{ id: 'lastUpdate', label: 'Last Update', visible: true, order: 9 },
{ id: 'actions', label: 'Actions', visible: true, order: 10 }
]
updateColumnConfig(defaultConfig)
}
@@ -965,12 +1054,26 @@ const Hosts = () => {
)
case 'host':
return (
<Link
to={`/hosts/${host.id}`}
className="text-sm font-medium text-primary-600 hover:text-primary-900 hover:underline"
>
{host.hostname}
</Link>
<InlineEdit
value={host.friendlyName}
onSave={(newName) => updateFriendlyNameMutation.mutate({ hostId: host.id, friendlyName: newName })}
placeholder="Enter friendly name..."
maxLength={100}
linkTo={`/hosts/${host.id}`}
validate={(value) => {
if (!value.trim()) return 'Friendly name is required';
if (value.trim().length < 1) return 'Friendly name must be at least 1 character';
if (value.trim().length > 100) return 'Friendly name must be less than 100 characters';
return null;
}}
className="w-full"
/>
)
case 'hostname':
return (
<div className="text-sm text-secondary-900 dark:text-white font-mono">
{host.hostname || 'N/A'}
</div>
)
case 'ip':
return (
@@ -979,22 +1082,27 @@ const Hosts = () => {
</div>
)
case 'group':
return host.hostGroup ? (
<span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: host.hostGroup.color }}
>
{host.hostGroup.name}
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
Ungrouped
</span>
console.log('Rendering group for host:', {
hostId: host.id,
hostGroupId: host.hostGroupId,
hostGroup: host.hostGroup,
availableGroups: hostGroups
});
return (
<InlineGroupEdit
key={`${host.id}-${host.hostGroup?.id || 'ungrouped'}-${host.hostGroup?.name || 'ungrouped'}`}
value={host.hostGroup?.id}
onSave={(newGroupId) => updateHostGroupMutation.mutate({ hostId: host.id, hostGroupId: newGroupId })}
options={hostGroups || []}
placeholder="Select group..."
className="w-full"
/>
)
case 'os':
return (
<div className="text-sm text-secondary-900 dark:text-white">
{host.osType}
<div className="flex items-center gap-2 text-sm text-secondary-900 dark:text-white">
<OSIcon osType={host.osType} className="h-4 w-4" />
<span>{host.osType}</span>
</div>
)
case 'osVersion':
@@ -1068,6 +1176,8 @@ const Hosts = () => {
setGroupBy('none')
setHideStale(false)
setShowFilters(false)
// Clear URL parameters to ensure no filters are applied
navigate('/hosts', { replace: true })
}
const handleUpToDateClick = () => {
@@ -1401,6 +1511,14 @@ const Hosts = () => {
)}
</button>
) : column.id === 'host' ? (
<button
onClick={() => handleSort('friendlyName')}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon('friendlyName')}
</button>
) : column.id === 'hostname' ? (
<button
onClick={() => handleSort('hostname')}
className="flex items-center gap-2 hover:text-secondary-700"
@@ -1561,7 +1679,7 @@ const BulkAssignModal = ({ selectedHosts, hosts, onClose, onAssign, isLoading })
const selectedHostNames = hosts
.filter(host => selectedHosts.includes(host.id))
.map(host => host.hostname)
.map(host => host.friendlyName)
const handleSubmit = (e) => {
e.preventDefault()
@@ -1585,9 +1703,9 @@ const BulkAssignModal = ({ selectedHosts, hosts, onClose, onAssign, isLoading })
Assigning {selectedHosts.length} host{selectedHosts.length !== 1 ? 's' : ''}:
</p>
<div className="max-h-32 overflow-y-auto bg-secondary-50 rounded-md p-3">
{selectedHostNames.map((hostname, index) => (
{selectedHostNames.map((friendlyName, index) => (
<div key={index} className="text-sm text-secondary-700">
{hostname}
{friendlyName}
</div>
))}
</div>

View File

@@ -1,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>
)

View 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

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import React, { useState, useEffect, useMemo } from 'react'
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import {
Package,
@@ -9,7 +9,17 @@ import {
Search,
AlertTriangle,
Filter,
ExternalLink
ExternalLink,
ArrowUpDown,
ArrowUp,
ArrowDown,
ChevronDown,
Settings,
Columns,
GripVertical,
X,
Eye as EyeIcon,
EyeOff as EyeOffIcon
} from 'lucide-react'
import { dashboardAPI } from '../utils/api'
@@ -17,7 +27,61 @@ const Packages = () => {
const [searchTerm, setSearchTerm] = useState('')
const [categoryFilter, setCategoryFilter] = useState('all')
const [securityFilter, setSecurityFilter] = useState('all')
const [hostFilter, setHostFilter] = useState('all')
const [sortField, setSortField] = useState('name')
const [sortDirection, setSortDirection] = useState('asc')
const [showColumnSettings, setShowColumnSettings] = useState(false)
const [searchParams] = useSearchParams()
const navigate = useNavigate()
// Handle host filter from URL parameter
useEffect(() => {
const hostParam = searchParams.get('host')
if (hostParam) {
setHostFilter(hostParam)
}
}, [searchParams])
// Column configuration
const [columnConfig, setColumnConfig] = useState(() => {
const defaultConfig = [
{ id: 'name', label: 'Package', visible: true, order: 0 },
{ id: 'affectedHosts', label: 'Affected Hosts', visible: true, order: 1 },
{ id: 'priority', label: 'Priority', visible: true, order: 2 },
{ id: 'latestVersion', label: 'Latest Version', visible: true, order: 3 }
]
const saved = localStorage.getItem('packages-column-config')
if (saved) {
const savedConfig = JSON.parse(saved)
// Merge with defaults to handle new columns
return defaultConfig.map(defaultCol => {
const savedCol = savedConfig.find(col => col.id === defaultCol.id)
return savedCol ? { ...defaultCol, ...savedCol } : defaultCol
})
}
return defaultConfig
})
// Update column configuration
const updateColumnConfig = (newConfig) => {
setColumnConfig(newConfig)
localStorage.setItem('packages-column-config', JSON.stringify(newConfig))
}
// Handle affected hosts click
const handleAffectedHostsClick = (pkg) => {
const hostIds = pkg.affectedHosts.map(host => host.hostId)
const hostNames = pkg.affectedHosts.map(host => host.friendlyName)
// Create URL with selected hosts and filter
const params = new URLSearchParams()
params.set('selected', hostIds.join(','))
params.set('filter', 'selected')
// Navigate to hosts page with selected hosts
navigate(`/hosts?${params.toString()}`)
}
// Handle URL filter parameters
useEffect(() => {
@@ -41,6 +105,196 @@ const Packages = () => {
staleTime: 30000,
})
// Fetch hosts data to get total packages count
const { data: hosts } = useQuery({
queryKey: ['hosts'],
queryFn: () => dashboardAPI.getHosts().then(res => res.data),
refetchInterval: 60000,
staleTime: 30000,
})
// Filter and sort packages
const filteredAndSortedPackages = useMemo(() => {
if (!packages) return []
// Filter packages
const filtered = packages.filter(pkg => {
const matchesSearch = pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(pkg.description && pkg.description.toLowerCase().includes(searchTerm.toLowerCase()))
const matchesCategory = categoryFilter === 'all' || pkg.category === categoryFilter
const matchesSecurity = securityFilter === 'all' ||
(securityFilter === 'security' && pkg.isSecurityUpdate) ||
(securityFilter === 'regular' && !pkg.isSecurityUpdate)
const matchesHost = hostFilter === 'all' ||
pkg.affectedHosts.some(host => host.hostId === hostFilter)
return matchesSearch && matchesCategory && matchesSecurity && matchesHost
})
// Sorting
filtered.sort((a, b) => {
let aValue, bValue
switch (sortField) {
case 'name':
aValue = a.name?.toLowerCase() || ''
bValue = b.name?.toLowerCase() || ''
break
case 'latestVersion':
aValue = a.latestVersion?.toLowerCase() || ''
bValue = b.latestVersion?.toLowerCase() || ''
break
case 'affectedHosts':
aValue = a.affectedHostsCount || 0
bValue = b.affectedHostsCount || 0
break
case 'priority':
aValue = a.isSecurityUpdate ? 0 : 1 // Security updates first
bValue = b.isSecurityUpdate ? 0 : 1
break
default:
aValue = a.name?.toLowerCase() || ''
bValue = b.name?.toLowerCase() || ''
}
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1
return 0
})
return filtered
}, [packages, searchTerm, categoryFilter, securityFilter, sortField, sortDirection])
// Get visible columns in order
const visibleColumns = columnConfig
.filter(col => col.visible)
.sort((a, b) => a.order - b.order)
// Sorting functions
const handleSort = (field) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc')
} else {
setSortField(field)
setSortDirection('asc')
}
}
const getSortIcon = (field) => {
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />
return sortDirection === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
}
// Column management functions
const toggleColumnVisibility = (columnId) => {
const newConfig = columnConfig.map(col =>
col.id === columnId ? { ...col, visible: !col.visible } : col
)
updateColumnConfig(newConfig)
}
const reorderColumns = (fromIndex, toIndex) => {
const newConfig = [...columnConfig]
const [movedColumn] = newConfig.splice(fromIndex, 1)
newConfig.splice(toIndex, 0, movedColumn)
// Update order values
const updatedConfig = newConfig.map((col, index) => ({ ...col, order: index }))
updateColumnConfig(updatedConfig)
}
const resetColumns = () => {
const defaultConfig = [
{ id: 'name', label: 'Package', visible: true, order: 0 },
{ id: 'affectedHosts', label: 'Affected Hosts', visible: true, order: 1 },
{ id: 'priority', label: 'Priority', visible: true, order: 2 },
{ id: 'latestVersion', label: 'Latest Version', visible: true, order: 3 }
]
updateColumnConfig(defaultConfig)
}
// Helper function to render table cell content
const renderCellContent = (column, pkg) => {
switch (column.id) {
case 'name':
return (
<div className="flex items-center">
<Package className="h-5 w-5 text-secondary-400 mr-3" />
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{pkg.name}
</div>
{pkg.description && (
<div className="text-sm text-secondary-500 dark:text-secondary-300 max-w-md truncate">
{pkg.description}
</div>
)}
{pkg.category && (
<div className="text-xs text-secondary-400 dark:text-secondary-400">
Category: {pkg.category}
</div>
)}
</div>
</div>
)
case 'affectedHosts':
return (
<button
onClick={() => handleAffectedHostsClick(pkg)}
className="text-left hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded p-1 -m-1 transition-colors group"
title={`Click to view all ${pkg.affectedHostsCount} affected hosts`}
>
<div className="text-sm text-secondary-900 dark:text-white group-hover:text-primary-600 dark:group-hover:text-primary-400">
{pkg.affectedHostsCount} host{pkg.affectedHostsCount !== 1 ? 's' : ''}
</div>
</button>
)
case 'priority':
return pkg.isSecurityUpdate ? (
<span className="badge-danger flex items-center gap-1">
<Shield className="h-3 w-3" />
Security Update
</span>
) : (
<span className="badge-warning">Regular Update</span>
)
case 'latestVersion':
return (
<div className="text-sm text-secondary-900 dark:text-white max-w-xs truncate" title={pkg.latestVersion || 'Unknown'}>
{pkg.latestVersion || 'Unknown'}
</div>
)
default:
return null
}
}
// Get unique categories
const categories = [...new Set(packages?.map(pkg => pkg.category).filter(Boolean))] || []
// Calculate unique affected hosts
const uniqueAffectedHosts = new Set()
packages?.forEach(pkg => {
pkg.affectedHosts.forEach(host => {
uniqueAffectedHosts.add(host.hostId)
})
})
const uniqueAffectedHostsCount = uniqueAffectedHosts.size
// Calculate total packages across all hosts (including up-to-date ones)
const totalPackagesCount = hosts?.reduce((total, host) => {
return total + (host.totalPackagesCount || 0)
}, 0) || 0
// Calculate outdated packages (packages that need updates)
const outdatedPackagesCount = packages?.length || 0
// Calculate security updates
const securityUpdatesCount = packages?.filter(pkg => pkg.isSecurityUpdate).length || 0
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -74,64 +328,38 @@ const Packages = () => {
)
}
// Filter packages based on search and filters
const filteredPackages = packages?.filter(pkg => {
const matchesSearch = pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(pkg.description && pkg.description.toLowerCase().includes(searchTerm.toLowerCase()))
const matchesCategory = categoryFilter === 'all' || pkg.category === categoryFilter
const matchesSecurity = securityFilter === 'all' ||
(securityFilter === 'security' && pkg.isSecurityUpdate) ||
(securityFilter === 'regular' && !pkg.isSecurityUpdate)
return matchesSearch && matchesCategory && matchesSecurity
}) || []
// Get unique categories
const categories = [...new Set(packages?.map(pkg => pkg.category).filter(Boolean))] || []
// Calculate unique affected hosts
const uniqueAffectedHosts = new Set()
packages?.forEach(pkg => {
pkg.affectedHosts.forEach(host => {
uniqueAffectedHosts.add(host.hostId)
})
})
const uniqueAffectedHostsCount = uniqueAffectedHosts.size
return (
<div className="space-y-6">
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
{/* Summary Stats */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
<div className="card p-4">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6 flex-shrink-0">
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Package className="h-5 w-5 text-primary-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Total Packages</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{packages?.length || 0}</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{totalPackagesCount}</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Shield className="h-5 w-5 text-danger-600 mr-2" />
<Package className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Security Updates</p>
<p className="text-sm text-secondary-500 dark:text-white">Total Outdated Packages</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{packages?.filter(pkg => pkg.isSecurityUpdate).length || 0}
{outdatedPackagesCount}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Server className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Affected Hosts</p>
<p className="text-sm text-secondary-500 dark:text-white">Hosts Pending Updates</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{uniqueAffectedHostsCount}
</p>
@@ -139,152 +367,235 @@ const Packages = () => {
</div>
</div>
<div className="card p-4">
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Filter className="h-5 w-5 text-secondary-600 mr-2" />
<Shield className="h-5 w-5 text-danger-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Categories</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{categories.length}</p>
<p className="text-sm text-secondary-500 dark:text-white">Security Updates Across All Hosts</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{securityUpdatesCount}</p>
</div>
</div>
</div>
</div>
{/* Filters */}
<div className="card p-4">
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
<input
type="text"
placeholder="Search packages..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
/>
</div>
</div>
{/* Category Filter */}
<div className="sm:w-48">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Categories</option>
{categories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
{/* Security Filter */}
<div className="sm:w-48">
<select
value={securityFilter}
onChange={(e) => setSecurityFilter(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Updates</option>
<option value="security">Security Only</option>
<option value="regular">Regular Only</option>
</select>
</div>
</div>
</div>
{/* Packages List */}
<div className="card">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Packages Needing Updates ({filteredPackages.length})
</h3>
<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
<div className="px-4 py-4 sm:p-4 flex-1 flex flex-col overflow-hidden min-h-0">
<div className="flex items-center justify-end mb-4">
{/* Empty selection controls area to match hosts page spacing */}
</div>
{filteredPackages.length === 0 ? (
<div className="text-center py-8">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
{packages?.length === 0 ? 'No packages need updates' : 'No packages match your filters'}
</p>
{packages?.length === 0 && (
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
All packages are up to date across all hosts
</p>
)}
{/* Table Controls */}
<div className="mb-4 space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
<input
type="text"
placeholder="Search packages..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
/>
</div>
</div>
{/* Category Filter */}
<div className="sm:w-48">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Categories</option>
{categories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
{/* Security Filter */}
<div className="sm:w-48">
<select
value={securityFilter}
onChange={(e) => setSecurityFilter(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Updates</option>
<option value="security">Security Only</option>
<option value="regular">Regular Only</option>
</select>
</div>
{/* Host Filter */}
<div className="sm:w-48">
<select
value={hostFilter}
onChange={(e) => setHostFilter(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Hosts</option>
{hosts?.map(host => (
<option key={host.id} value={host.id}>{host.friendlyName}</option>
))}
</select>
</div>
{/* Columns Button */}
<div className="flex items-center">
<button
onClick={() => setShowColumnSettings(true)}
className="flex items-center gap-2 px-3 py-2 text-sm text-secondary-700 dark:text-secondary-300 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors"
>
<Columns className="h-4 w-4" />
Columns
</button>
</div>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
</div>
<div className="flex-1 overflow-hidden">
{filteredAndSortedPackages.length === 0 ? (
<div className="text-center py-8">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
{packages?.length === 0 ? 'No packages need updates' : 'No packages match your filters'}
</p>
{packages?.length === 0 && (
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
All packages are up to date across all hosts
</p>
)}
</div>
) : (
<div className="h-full overflow-auto">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Package
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Latest Version
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Affected Hosts
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Priority
</th>
{visibleColumns.map((column) => (
<th key={column.id} className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
onClick={() => handleSort(column.id)}
className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
>
{column.label}
{getSortIcon(column.id)}
</button>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredPackages.map((pkg) => (
<tr key={pkg.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Package className="h-5 w-5 text-secondary-400 mr-3" />
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{pkg.name}
</div>
{pkg.description && (
<div className="text-sm text-secondary-500 dark:text-secondary-300 max-w-md truncate">
{pkg.description}
</div>
)}
{pkg.category && (
<div className="text-xs text-secondary-400 dark:text-secondary-400">
Category: {pkg.category}
</div>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
{pkg.latestVersion || 'Unknown'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-secondary-900 dark:text-white">
{pkg.affectedHostsCount} host{pkg.affectedHostsCount !== 1 ? 's' : ''}
</div>
<div className="text-xs text-secondary-500 dark:text-secondary-300">
{pkg.affectedHosts.slice(0, 2).map(host => host.hostname).join(', ')}
{pkg.affectedHosts.length > 2 && ` +${pkg.affectedHosts.length - 2} more`}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{pkg.isSecurityUpdate ? (
<span className="badge-danger flex items-center gap-1">
<Shield className="h-3 w-3" />
Security Update
</span>
) : (
<span className="badge-warning">Regular Update</span>
)}
</td>
{filteredAndSortedPackages.map((pkg) => (
<tr key={pkg.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
{visibleColumns.map((column) => (
<td key={column.id} className="px-4 py-2 whitespace-nowrap text-center">
{renderCellContent(column, pkg)}
</td>
))}
</tr>
))}
</tbody>
</table>
</table>
</div>
)}
</div>
</div>
</div>
{/* Column Settings Modal */}
{showColumnSettings && (
<ColumnSettingsModal
columnConfig={columnConfig}
onClose={() => setShowColumnSettings(false)}
onToggleVisibility={toggleColumnVisibility}
onReorder={reorderColumns}
onReset={resetColumns}
/>
)}
</div>
)
}
// Column Settings Modal Component
const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => {
const [draggedIndex, setDraggedIndex] = useState(null)
const handleDragStart = (e, index) => {
setDraggedIndex(index)
e.dataTransfer.effectAllowed = 'move'
}
const handleDragOver = (e) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
const handleDrop = (e, dropIndex) => {
e.preventDefault()
if (draggedIndex !== null && draggedIndex !== dropIndex) {
onReorder(draggedIndex, dropIndex)
}
setDraggedIndex(null)
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Customize Columns</h3>
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300">
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-2">
{columnConfig.map((column, index) => (
<div
key={column.id}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, index)}
className={`flex items-center justify-between p-3 border rounded-lg cursor-move ${
draggedIndex === index ? 'opacity-50' : 'hover:bg-secondary-50 dark:hover:bg-secondary-700'
} border-secondary-200 dark:border-secondary-600`}
>
<div className="flex items-center gap-3">
<GripVertical className="h-4 w-4 text-secondary-400 dark:text-secondary-500" />
<span className="text-sm font-medium text-secondary-900 dark:text-white">
{column.label}
</span>
</div>
<button
onClick={() => onToggleVisibility(column.id)}
className={`p-1 rounded ${
column.visible
? 'text-primary-600 hover:text-primary-700'
: 'text-secondary-400 hover:text-secondary-600'
}`}
>
{column.visible ? <EyeIcon className="h-4 w-4" /> : <EyeOffIcon className="h-4 w-4" />}
</button>
</div>
)}
))}
</div>
<div className="flex justify-between mt-6">
<button
onClick={onReset}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Reset to Default
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 rounded-md hover:bg-primary-700"
>
Done
</button>
</div>
</div>
</div>

View File

@@ -1,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

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
@@ -11,7 +11,15 @@ import {
Lock,
Unlock,
Database,
Eye
Eye,
Search,
Columns,
ArrowUpDown,
ArrowUp,
ArrowDown,
X,
GripVertical,
Check
} from 'lucide-react';
import { repositoryAPI } from '../utils/api';
@@ -19,6 +27,37 @@ const Repositories = () => {
const [searchTerm, setSearchTerm] = useState('');
const [filterType, setFilterType] = useState('all'); // all, secure, insecure
const [filterStatus, setFilterStatus] = useState('all'); // all, active, inactive
const [sortField, setSortField] = useState('name');
const [sortDirection, setSortDirection] = useState('asc');
const [showColumnSettings, setShowColumnSettings] = useState(false);
// Column configuration
const [columnConfig, setColumnConfig] = useState(() => {
const defaultConfig = [
{ id: 'name', label: 'Repository', visible: true, order: 0 },
{ id: 'url', label: 'URL', visible: true, order: 1 },
{ id: 'distribution', label: 'Distribution', visible: true, order: 2 },
{ id: 'security', label: 'Security', visible: true, order: 3 },
{ id: 'status', label: 'Status', visible: true, order: 4 },
{ id: 'hostCount', label: 'Hosts', visible: true, order: 5 },
{ id: 'actions', label: 'Actions', visible: true, order: 6 }
];
const saved = localStorage.getItem('repositories-column-config');
if (saved) {
try {
return JSON.parse(saved);
} catch (e) {
console.error('Failed to parse saved column config:', e);
}
}
return defaultConfig;
});
const updateColumnConfig = (newConfig) => {
setColumnConfig(newConfig);
localStorage.setItem('repositories-column-config', JSON.stringify(newConfig));
};
// Fetch repositories
const { data: repositories = [], isLoading, error } = useQuery({
@@ -32,22 +71,122 @@ const Repositories = () => {
queryFn: () => repositoryAPI.getStats().then(res => res.data)
});
// Filter repositories based on search and filters
const filteredRepositories = repositories.filter(repo => {
const matchesSearch = repo.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
repo.url.toLowerCase().includes(searchTerm.toLowerCase()) ||
repo.distribution.toLowerCase().includes(searchTerm.toLowerCase());
// Get visible columns in order
const visibleColumns = columnConfig
.filter(col => col.visible)
.sort((a, b) => a.order - b.order);
// Sorting functions
const handleSort = (field) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const getSortIcon = (field) => {
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />
return sortDirection === 'asc' ? <ArrowUp className="h-4 w-4" /> : <ArrowDown className="h-4 w-4" />
};
// Column management functions
const toggleColumnVisibility = (columnId) => {
const newConfig = columnConfig.map(col =>
col.id === columnId ? { ...col, visible: !col.visible } : col
)
updateColumnConfig(newConfig)
};
const reorderColumns = (fromIndex, toIndex) => {
const newConfig = [...columnConfig]
const [movedColumn] = newConfig.splice(fromIndex, 1)
newConfig.splice(toIndex, 0, movedColumn)
const matchesType = filterType === 'all' ||
(filterType === 'secure' && repo.isSecure) ||
(filterType === 'insecure' && !repo.isSecure);
// Update order values
const updatedConfig = newConfig.map((col, index) => ({ ...col, order: index }))
updateColumnConfig(updatedConfig)
};
const resetColumns = () => {
const defaultConfig = [
{ id: 'name', label: 'Repository', visible: true, order: 0 },
{ id: 'url', label: 'URL', visible: true, order: 1 },
{ id: 'distribution', label: 'Distribution', visible: true, order: 2 },
{ id: 'security', label: 'Security', visible: true, order: 3 },
{ id: 'status', label: 'Status', visible: true, order: 4 },
{ id: 'hostCount', label: 'Hosts', visible: true, order: 5 },
{ id: 'actions', label: 'Actions', visible: true, order: 6 }
]
updateColumnConfig(defaultConfig)
};
// Filter and sort repositories
const filteredAndSortedRepositories = useMemo(() => {
if (!repositories) return []
const matchesStatus = filterStatus === 'all' ||
(filterStatus === 'active' && repo.isActive) ||
(filterStatus === 'inactive' && !repo.isActive);
return matchesSearch && matchesType && matchesStatus;
});
// Filter repositories
const filtered = repositories.filter(repo => {
const matchesSearch = repo.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
repo.url.toLowerCase().includes(searchTerm.toLowerCase()) ||
repo.distribution.toLowerCase().includes(searchTerm.toLowerCase());
// Debug logging
console.log('Filtering repo:', {
name: repo.name,
isSecure: repo.isSecure,
filterType,
url: repo.url
});
// Check security based on URL if isSecure property doesn't exist
const isSecure = repo.isSecure !== undefined ? repo.isSecure : repo.url.startsWith('https://');
const matchesType = filterType === 'all' ||
(filterType === 'secure' && isSecure) ||
(filterType === 'insecure' && !isSecure);
const matchesStatus = filterStatus === 'all' ||
(filterStatus === 'active' && repo.isActive === true) ||
(filterStatus === 'inactive' && repo.isActive === false);
console.log('Filter results:', {
matchesSearch,
matchesType,
matchesStatus,
final: matchesSearch && matchesType && matchesStatus
});
return matchesSearch && matchesType && matchesStatus;
});
// Sort repositories
const sorted = filtered.sort((a, b) => {
let aValue = a[sortField];
let bValue = b[sortField];
// Handle special cases
if (sortField === 'security') {
aValue = a.isSecure ? 'Secure' : 'Insecure';
bValue = b.isSecure ? 'Secure' : 'Insecure';
} else if (sortField === 'status') {
aValue = a.isActive ? 'Active' : 'Inactive';
bValue = b.isActive ? 'Active' : 'Inactive';
}
if (typeof aValue === 'string') {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
return sorted;
}, [repositories, searchTerm, filterType, filterStatus, sortField, sortDirection]);
if (isLoading) {
return (
@@ -71,202 +210,331 @@ const Repositories = () => {
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
Repositories
</h1>
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
Manage and monitor package repositories across your infrastructure
</p>
</div>
</div>
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
{/* Statistics Cards */}
{stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Database className="h-8 w-8 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Total Repositories</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.totalRepositories}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Server className="h-8 w-8 text-green-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Active Repositories</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.activeRepositories}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Shield className="h-8 w-8 text-blue-600" />
</div>
<div className="ml-4">
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Secure (HTTPS)</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.secureRepositories}</p>
</div>
</div>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="relative">
<ShieldCheck className="h-8 w-8 text-green-600" />
<span className="absolute -top-1 -right-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs font-medium px-1.5 py-0.5 rounded-full">
{stats.securityPercentage}%
</span>
</div>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Security Score</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.securityPercentage}%</p>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6 flex-shrink-0">
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Database className="h-5 w-5 text-primary-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Total Repositories</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.totalRepositories || 0}</p>
</div>
</div>
</div>
)}
{/* Search and Filters */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow p-6">
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<input
type="text"
placeholder="Search repositories..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
/>
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Server className="h-5 w-5 text-success-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Active Repositories</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.activeRepositories || 0}</p>
</div>
</div>
{/* Security Filter */}
<div>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
>
<option value="all">All Security Types</option>
<option value="secure">HTTPS Only</option>
<option value="insecure">HTTP Only</option>
</select>
</div>
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<Shield className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Secure (HTTPS)</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.secureRepositories || 0}</p>
</div>
</div>
{/* Status Filter */}
<div>
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
>
<option value="all">All Statuses</option>
<option value="active">Active Only</option>
<option value="inactive">Inactive Only</option>
</select>
</div>
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center">
<ShieldCheck className="h-5 w-5 text-danger-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Security Score</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{stats?.securityPercentage || 0}%</p>
</div>
</div>
</div>
</div>
{/* Repositories List */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Repositories ({filteredRepositories.length})
</h2>
</div>
{filteredRepositories.length === 0 ? (
<div className="px-6 py-12 text-center">
<Database className="mx-auto h-12 w-12 text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">No repositories found</h3>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
{searchTerm || filterType !== 'all' || filterStatus !== 'all'
? 'Try adjusting your search or filters.'
: 'No repositories have been reported by your hosts yet.'}
</p>
<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
<div className="px-4 py-4 sm:p-4 flex-1 flex flex-col overflow-hidden min-h-0">
<div className="flex items-center justify-end mb-4">
{/* Empty selection controls area to match packages page spacing */}
</div>
) : (
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
{filteredRepositories.map((repo) => (
<div key={repo.id} className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
{repo.isSecure ? (
<Lock className="h-4 w-4 text-green-600" />
) : (
<Unlock className="h-4 w-4 text-orange-600" />
)}
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
{repo.name}
</h3>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
repo.isActive
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}>
{repo.isActive ? 'Active' : 'Inactive'}
</span>
</div>
</div>
<div className="mt-2 space-y-1">
<p className="text-sm text-secondary-600 dark:text-secondary-300">
<Globe className="inline h-4 w-4 mr-1" />
{repo.url}
</p>
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400">
<span>Distribution: <span className="font-medium">{repo.distribution}</span></span>
<span>Type: <span className="font-medium">{repo.repoType}</span></span>
<span>Components: <span className="font-medium">{repo.components}</span></span>
</div>
</div>
</div>
<div className="flex items-center gap-4">
{/* Host Count */}
<div className="text-center">
<div className="flex items-center gap-1 text-sm text-secondary-500 dark:text-secondary-400">
<Users className="h-4 w-4" />
<span>{repo.hostCount} hosts</span>
</div>
</div>
{/* View Details */}
<Link
to={`/repositories/${repo.id}`}
className="btn-outline text-sm flex items-center gap-1"
>
<Eye className="h-4 w-4" />
View
</Link>
</div>
{/* Table Controls */}
<div className="mb-4 space-y-4">
<div className="flex flex-col sm:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
<input
type="text"
placeholder="Search repositories..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
/>
</div>
</div>
))}
{/* Security Filter */}
<div className="sm:w-48">
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Security Types</option>
<option value="secure">HTTPS Only</option>
<option value="insecure">HTTP Only</option>
</select>
</div>
{/* Status Filter */}
<div className="sm:w-48">
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
>
<option value="all">All Statuses</option>
<option value="active">Active Only</option>
<option value="inactive">Inactive Only</option>
</select>
</div>
{/* Columns Button */}
<div className="flex items-center">
<button
onClick={() => setShowColumnSettings(true)}
className="flex items-center gap-2 px-3 py-2 text-sm text-secondary-700 dark:text-secondary-300 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-colors"
>
<Columns className="h-4 w-4" />
Columns
</button>
</div>
</div>
</div>
)}
<div className="flex-1 overflow-hidden">
{filteredAndSortedRepositories.length === 0 ? (
<div className="text-center py-8">
<Database className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
{repositories?.length === 0 ? 'No repositories found' : 'No repositories match your filters'}
</p>
{repositories?.length === 0 && (
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
No repositories have been reported by your hosts yet
</p>
)}
</div>
) : (
<div className="h-full overflow-auto">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700 sticky top-0 z-10">
<tr>
{visibleColumns.map((column) => (
<th key={column.id} className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
onClick={() => handleSort(column.id)}
className="flex items-center gap-1 hover:text-secondary-700 dark:hover:text-secondary-200 transition-colors"
>
{column.label}
{getSortIcon(column.id)}
</button>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredAndSortedRepositories.map((repo) => (
<tr key={repo.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors">
{visibleColumns.map((column) => (
<td key={column.id} className="px-4 py-2 whitespace-nowrap text-center">
{renderCellContent(column, repo)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
{/* Column Settings Modal */}
{showColumnSettings && (
<ColumnSettingsModal
columnConfig={columnConfig}
onClose={() => setShowColumnSettings(false)}
onToggleVisibility={toggleColumnVisibility}
onReorder={reorderColumns}
onReset={resetColumns}
/>
)}
</div>
);
// Render cell content based on column type
function renderCellContent(column, repo) {
switch (column.id) {
case 'name':
return (
<div className="flex items-center">
<Database className="h-5 w-5 text-secondary-400 mr-3" />
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{repo.name}
</div>
</div>
</div>
)
case 'url':
return (
<div className="text-sm text-secondary-900 dark:text-white max-w-xs truncate" title={repo.url}>
{repo.url}
</div>
)
case 'distribution':
return (
<div className="text-sm text-secondary-900 dark:text-white">
{repo.distribution}
</div>
)
case 'security':
const isSecure = repo.isSecure !== undefined ? repo.isSecure : repo.url.startsWith('https://');
return (
<div className="flex items-center justify-center">
{isSecure ? (
<div className="flex items-center gap-1 text-green-600">
<Lock className="h-4 w-4" />
<span className="text-sm">Secure</span>
</div>
) : (
<div className="flex items-center gap-1 text-orange-600">
<Unlock className="h-4 w-4" />
<span className="text-sm">Insecure</span>
</div>
)}
</div>
)
case 'status':
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
repo.isActive
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}>
{repo.isActive ? 'Active' : 'Inactive'}
</span>
)
case 'hostCount':
return (
<div className="flex items-center justify-center gap-1 text-sm text-secondary-900 dark:text-white">
<Users className="h-4 w-4" />
<span>{repo.hostCount}</span>
</div>
)
case 'actions':
return (
<Link
to={`/repositories/${repo.id}`}
className="text-primary-600 hover:text-primary-900 flex items-center gap-1"
>
View
<Eye className="h-3 w-3" />
</Link>
)
default:
return null
}
}
};
// Column Settings Modal Component
const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => {
const [draggedIndex, setDraggedIndex] = useState(null)
const handleDragStart = (e, index) => {
setDraggedIndex(index)
e.dataTransfer.effectAllowed = 'move'
}
const handleDragOver = (e) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
}
const handleDrop = (e, dropIndex) => {
e.preventDefault()
if (draggedIndex !== null && draggedIndex !== dropIndex) {
onReorder(draggedIndex, dropIndex)
}
setDraggedIndex(null)
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Column Settings</h3>
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300">
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-3">
{columnConfig.map((column, index) => (
<div
key={column.id}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, index)}
className="flex items-center justify-between p-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg cursor-move hover:bg-secondary-100 dark:hover:bg-secondary-600 transition-colors"
>
<div className="flex items-center gap-3">
<GripVertical className="h-4 w-4 text-secondary-400" />
<span className="text-sm font-medium text-secondary-900 dark:text-white">
{column.label}
</span>
</div>
<button
onClick={() => onToggleVisibility(column.id)}
className={`w-4 h-4 rounded border-2 flex items-center justify-center ${
column.visible
? 'bg-primary-600 border-primary-600'
: 'bg-white dark:bg-secondary-800 border-secondary-300 dark:border-secondary-600'
}`}
>
{column.visible && <Check className="h-3 w-3 text-white" />}
</button>
</div>
))}
</div>
<div className="flex justify-between mt-6">
<button
onClick={onReset}
className="px-4 py-2 text-sm text-secondary-600 dark:text-secondary-400 hover:text-secondary-800 dark:hover:text-secondary-200"
>
Reset to Default
</button>
<button
onClick={onClose}
className="px-4 py-2 bg-primary-600 text-white text-sm rounded-md hover:bg-primary-700 transition-colors"
>
Done
</button>
</div>
</div>
</div>
)
};
export default Repositories;

View File

@@ -339,7 +339,7 @@ const RepositoryDetail = () => {
to={`/hosts/${hostRepo.host.id}`}
className="text-primary-600 hover:text-primary-700 font-medium"
>
{hostRepo.host.hostname}
{hostRepo.host.friendlyName}
</Link>
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
<span>IP: {hostRepo.host.ip}</span>

View File

@@ -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 { settingsAPI, agentVersionAPI } from '../utils/api';
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({
@@ -11,7 +13,10 @@ const Settings = () => {
frontendUrl: 'http://localhost:3000',
updateInterval: 60,
autoUpdate: false,
githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git'
githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: 'public',
sshKeyPath: '',
useCustomSshKey: false
});
const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false);
@@ -19,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
@@ -37,6 +45,22 @@ const Settings = () => {
isDefault: false
});
// Version checking state
const [versionInfo, setVersionInfo] = useState({
currentVersion: '1.2.4',
latestVersion: null,
isUpdateAvailable: false,
checking: false,
error: null
});
const [sshTestResult, setSshTestResult] = useState({
testing: false,
success: null,
message: null,
error: null
});
const queryClient = useQueryClient();
// Fetch current settings
@@ -57,7 +81,10 @@ const Settings = () => {
frontendUrl: settings.frontendUrl || 'http://localhost:3000',
updateInterval: settings.updateInterval || 60,
autoUpdate: settings.autoUpdate || false,
githubRepoUrl: settings.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
githubRepoUrl: settings.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: settings.repositoryType || 'public',
sshKeyPath: settings.sshKeyPath || '',
useCustomSshKey: !!settings.sshKeyPath
};
console.log('Setting form data to:', newFormData);
setFormData(newFormData);
@@ -80,12 +107,16 @@ const Settings = () => {
queryClient.invalidateQueries(['settings']);
// Update form data with the returned data
setFormData({
serverProtocol: data.serverProtocol || 'http',
serverHost: data.serverHost || 'localhost',
serverPort: data.serverPort || 3001,
frontendUrl: data.frontendUrl || 'http://localhost:3000',
updateInterval: data.updateInterval || 60,
autoUpdate: data.autoUpdate || false
serverProtocol: data.settings?.serverProtocol || data.serverProtocol || 'http',
serverHost: data.settings?.serverHost || data.serverHost || 'localhost',
serverPort: data.settings?.serverPort || data.serverPort || 3001,
frontendUrl: data.settings?.frontendUrl || data.frontendUrl || 'http://localhost:3000',
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)
});
setIsDirty(false);
setErrors({});
@@ -152,6 +183,68 @@ const Settings = () => {
}
});
// Version checking functions
const checkForUpdates = async () => {
setVersionInfo(prev => ({ ...prev, checking: true, error: null }));
try {
const response = await versionAPI.checkUpdates();
const data = response.data;
setVersionInfo({
currentVersion: data.currentVersion,
latestVersion: data.latestVersion,
isUpdateAvailable: data.isUpdateAvailable,
lastUpdateCheck: data.lastUpdateCheck,
checking: false,
error: null
});
} catch (error) {
console.error('Version check error:', error);
setVersionInfo(prev => ({
...prev,
checking: false,
error: error.response?.data?.error || 'Failed to check for updates'
}));
}
};
const testSshKey = async () => {
if (!formData.sshKeyPath || !formData.githubRepoUrl) {
setSshTestResult({
testing: false,
success: false,
message: null,
error: 'Please enter both SSH key path and GitHub repository URL'
});
return;
}
setSshTestResult({ testing: true, success: null, message: null, error: null });
try {
const response = await versionAPI.testSshKey({
sshKeyPath: formData.sshKeyPath,
githubRepoUrl: formData.githubRepoUrl
});
setSshTestResult({
testing: false,
success: true,
message: response.data.message,
error: null
});
} catch (error) {
console.error('SSH key test error:', error);
setSshTestResult({
testing: false,
success: false,
message: null,
error: error.response?.data?.error || 'Failed to test SSH key'
});
}
};
const handleInputChange = (field, value) => {
console.log(`handleInputChange: ${field} = ${value}`);
setFormData(prev => {
@@ -167,7 +260,16 @@ const Settings = () => {
const handleSubmit = (e) => {
e.preventDefault();
updateSettingsMutation.mutate(formData);
// Only include sshKeyPath if the toggle is enabled
const dataToSubmit = { ...formData };
if (!dataToSubmit.useCustomSshKey) {
dataToSubmit.sshKeyPath = '';
}
// Remove the frontend-only field
delete dataToSubmit.useCustomSshKey;
updateSettingsMutation.mutate(dataToSubmit);
};
const validateForm = () => {
@@ -199,7 +301,17 @@ const Settings = () => {
console.log('Saving settings:', formData);
if (validateForm()) {
console.log('Validation passed, calling mutation');
updateSettingsMutation.mutate(formData);
// Prepare data for submission
const dataToSubmit = { ...formData };
if (!dataToSubmit.useCustomSshKey) {
dataToSubmit.sshKeyPath = '';
}
// 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);
}
@@ -266,6 +378,9 @@ const Settings = () => {
>
<Icon className="h-4 w-4" />
{tab.name}
{tab.showUpgradeIcon && (
<UpgradeNotificationIcon className="h-3 w-3" />
)}
</button>
);
})}
@@ -672,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"
@@ -688,13 +842,93 @@ const Settings = () => {
</p>
</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>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
SSH Key Path
</label>
<input
type="text"
value={formData.sshKeyPath || ''}
onChange={(e) => handleInputChange('sshKeyPath', 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="/root/.ssh/id_ed25519"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path to your SSH deploy key. If not set, will auto-detect from common locations.
</p>
<div className="mt-3">
<button
type="button"
onClick={testSshKey}
disabled={sshTestResult.testing || !formData.sshKeyPath || !formData.githubRepoUrl}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{sshTestResult.testing ? 'Testing...' : 'Test SSH Key'}
</button>
{sshTestResult.success && (
<div className="mt-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md">
<div className="flex items-center">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400 mr-2" />
<p className="text-sm text-green-800 dark:text-green-200">
{sshTestResult.message}
</p>
</div>
</div>
)}
{sshTestResult.error && (
<div className="mt-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<div className="flex items-center">
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 mr-2" />
<p className="text-sm text-red-800 dark:text-red-200">
{sshTestResult.error}
</p>
</div>
</div>
)}
</div>
</div>
)}
{!formData.useCustomSshKey && (
<p className="text-xs text-secondary-500 dark:text-secondary-400">
Using auto-detection for SSH key location
</p>
)}
</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">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Current Version</span>
</div>
<span className="text-lg font-mono text-secondary-900 dark:text-white">1.2.3</span>
<span className="text-lg font-mono text-secondary-900 dark:text-white">{versionInfo.currentVersion}</span>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
@@ -702,52 +936,107 @@ const Settings = () => {
<Download 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">Latest Version</span>
</div>
<span className="text-lg font-mono text-secondary-900 dark:text-white">Checking...</span>
<span className="text-lg font-mono text-secondary-900 dark:text-white">
{versionInfo.checking ? (
<span className="text-blue-600 dark:text-blue-400">Checking...</span>
) : versionInfo.latestVersion ? (
<span className={versionInfo.isUpdateAvailable ? 'text-orange-600 dark:text-orange-400' : 'text-green-600 dark:text-green-400'}>
{versionInfo.latestVersion}
{versionInfo.isUpdateAvailable && ' (Update Available!)'}
</span>
) : (
<span className="text-secondary-500 dark:text-secondary-400">Not checked</span>
)}
</span>
</div>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => {
// TODO: Implement version check
console.log('Checking for updates...');
}}
className="btn-primary flex items-center gap-2"
>
<Download className="h-4 w-4" />
Check for Updates
</button>
{/* 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
onClick={checkForUpdates}
disabled={versionInfo.checking}
className="btn-primary flex items-center gap-2"
>
<Download className="h-4 w-4" />
{versionInfo.checking ? 'Checking...' : 'Check for Updates'}
</button>
</div>
{/* Save Button for Version Settings */}
<button
onClick={() => {
// TODO: Implement update notification
console.log('Enable update notifications');
}}
className="btn-outline flex items-center gap-2"
type="button"
onClick={handleSave}
disabled={!isDirty || updateSettingsMutation.isPending}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
!isDirty || updateSettingsMutation.isPending
? 'bg-secondary-400 cursor-not-allowed'
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
}`}
>
<AlertCircle className="h-4 w-4" />
Enable Notifications
{updateSettingsMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Settings
</>
)}
</button>
</div>
{versionInfo.error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Version Check Failed</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{versionInfo.error}
</p>
{versionInfo.error.includes('private') && (
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
For private repositories, you may need to configure GitHub authentication or make the repository public.
</p>
)}
</div>
</div>
</div>
)}
{/* Success Message for Version Settings */}
{updateSettingsMutation.isSuccess && (
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
<div className="ml-3">
<p className="text-sm text-green-700 dark:text-green-300">Settings saved successfully!</p>
</div>
</div>
</div>
)}
</div>
</div>
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-amber-400 dark:text-amber-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">Setup Instructions</h3>
<div className="mt-2 text-sm text-amber-700 dark:text-amber-300">
<p className="mb-2">To enable version checking, you need to:</p>
<ol className="list-decimal list-inside space-y-1 ml-4">
<li>Create a version tag (e.g., v1.2.3) in your GitHub repository</li>
<li>Ensure the repository is publicly accessible or configure access tokens</li>
<li>Click "Check for Updates" to verify the connection</li>
</ol>
</div>
</div>
</div>
</div>
</div>
)}
</div>

View File

@@ -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
@@ -178,6 +187,29 @@ export const formatDate = (date) => {
return new Date(date).toLocaleString()
}
// Version API
export const versionAPI = {
getCurrent: () => api.get('/version/current'),
checkUpdates: () => api.get('/version/check-updates'),
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)

View File

@@ -0,0 +1,130 @@
import {
Monitor,
Server,
HardDrive,
Cpu,
Zap,
Shield,
Globe,
Terminal
} from 'lucide-react';
// Import OS icons from react-icons
import {
SiUbuntu,
SiDebian,
SiCentos,
SiFedora,
SiArchlinux,
SiAlpinelinux,
SiLinux,
SiMacos
} from 'react-icons/si';
import {
DiUbuntu,
DiDebian,
DiLinux,
DiWindows
} from 'react-icons/di';
/**
* OS Icon mapping utility
* Maps operating system types to appropriate react-icons components
*/
export const getOSIcon = (osType) => {
if (!osType) return Monitor;
const os = osType.toLowerCase();
// Linux distributions with authentic react-icons
if (os.includes('ubuntu')) return SiUbuntu;
if (os.includes('debian')) return SiDebian;
if (os.includes('centos') || os.includes('rhel') || os.includes('red hat')) return SiCentos;
if (os.includes('fedora')) return SiFedora;
if (os.includes('arch')) return SiArchlinux;
if (os.includes('alpine')) return SiAlpinelinux;
if (os.includes('suse') || os.includes('opensuse')) return SiLinux; // SUSE uses generic Linux icon
// Generic Linux
if (os.includes('linux')) return SiLinux;
// Windows
if (os.includes('windows')) return DiWindows;
// macOS
if (os.includes('mac') || os.includes('darwin')) return SiMacos;
// FreeBSD
if (os.includes('freebsd')) return Server;
// Default fallback
return Monitor;
};
/**
* OS Color mapping utility
* Maps operating system types to appropriate colors (react-icons have built-in brand colors)
*/
export const getOSColor = (osType) => {
if (!osType) return 'text-gray-500';
// react-icons already have the proper brand colors built-in
// This function is kept for compatibility but returns neutral colors
return 'text-gray-600';
};
/**
* OS Display name utility
* Provides clean, formatted OS names for display
*/
export const getOSDisplayName = (osType) => {
if (!osType) return 'Unknown';
const os = osType.toLowerCase();
// Linux distributions
if (os.includes('ubuntu')) return 'Ubuntu';
if (os.includes('debian')) return 'Debian';
if (os.includes('centos')) return 'CentOS';
if (os.includes('rhel') || os.includes('red hat')) return 'Red Hat Enterprise Linux';
if (os.includes('fedora')) return 'Fedora';
if (os.includes('arch')) return 'Arch Linux';
if (os.includes('suse')) return 'SUSE Linux';
if (os.includes('opensuse')) return 'openSUSE';
if (os.includes('alpine')) return 'Alpine Linux';
// Generic Linux
if (os.includes('linux')) return 'Linux';
// Windows
if (os.includes('windows')) return 'Windows';
// macOS
if (os.includes('mac') || os.includes('darwin')) return 'macOS';
// FreeBSD
if (os.includes('freebsd')) return 'FreeBSD';
// Return original if no match
return osType;
};
/**
* OS Icon component with proper styling
*/
export const OSIcon = ({ osType, className = "h-4 w-4", showText = false }) => {
const IconComponent = getOSIcon(osType);
const displayName = getOSDisplayName(osType);
if (showText) {
return (
<div className="flex items-center gap-2">
<IconComponent className={className} title={displayName} />
<span className="text-sm">{displayName}</span>
</div>
);
}
return <IconComponent className={className} title={displayName} />;
};

245
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon",
"version": "1.0.0",
"version": "1.2.5",
"description": "Linux Patch Monitoring System",
"private": true,
"workspaces": [