Merge pull request #233 from PatchMon/release/1-3-1

Release/1 3 1
This commit is contained in:
9 Technology Group LTD
2025-10-28 17:06:02 +00:00
committed by GitHub
58 changed files with 6037 additions and 2872 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -356,6 +356,7 @@ api_version: "v1"
credentials_file: "/etc/patchmon/credentials.yml"
log_file: "/etc/patchmon/logs/patchmon-agent.log"
log_level: "info"
skip_ssl_verify: ${SKIP_SSL_VERIFY:-false}
EOF
# Create credentials file

View File

@@ -3,6 +3,13 @@ DATABASE_URL="postgresql://patchmon_user:your-password-here@localhost:5432/patch
PM_DB_CONN_MAX_ATTEMPTS=30
PM_DB_CONN_WAIT_INTERVAL=2
# Database Connection Pool Configuration (Prisma)
DB_CONNECTION_LIMIT=30 # Maximum connections per instance (default: 30)
DB_POOL_TIMEOUT=20 # Seconds to wait for available connection (default: 20)
DB_CONNECT_TIMEOUT=10 # Seconds to wait for initial connection (default: 10)
DB_IDLE_TIMEOUT=300 # Seconds before closing idle connections (default: 300)
DB_MAX_LIFETIME=1800 # Maximum lifetime of a connection in seconds (default: 1800)
# JWT Configuration
JWT_SECRET=your-secure-random-secret-key-change-this-in-production
JWT_EXPIRES_IN=1h

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon-backend",
"version": "1.3.0",
"version": "1.3.1",
"description": "Backend API for Linux Patch Monitoring System",
"license": "AGPL-3.0",
"main": "src/server.js",

View File

@@ -0,0 +1,4 @@
-- AlterTable
-- Add color_theme field to settings table for customizable app theming
ALTER TABLE "settings" ADD COLUMN "color_theme" TEXT NOT NULL DEFAULT 'default';

View File

@@ -0,0 +1,14 @@
-- AddMetricsTelemetry
-- Add anonymous metrics and telemetry fields to settings table
-- Add metrics fields to settings table
ALTER TABLE "settings" ADD COLUMN "metrics_enabled" BOOLEAN NOT NULL DEFAULT true;
ALTER TABLE "settings" ADD COLUMN "metrics_anonymous_id" TEXT;
ALTER TABLE "settings" ADD COLUMN "metrics_last_sent" TIMESTAMP(3);
-- Generate UUID for existing records (if any exist)
-- This will use PostgreSQL's gen_random_uuid() function
UPDATE "settings"
SET "metrics_anonymous_id" = gen_random_uuid()::text
WHERE "metrics_anonymous_id" IS NULL;

View File

@@ -170,27 +170,31 @@ model role_permissions {
}
model settings {
id String @id
server_url String @default("http://localhost:3001")
server_protocol String @default("http")
server_host String @default("localhost")
server_port Int @default(3001)
created_at DateTime @default(now())
updated_at DateTime
update_interval Int @default(60)
auto_update Boolean @default(false)
github_repo_url String @default("https://github.com/PatchMon/PatchMon.git")
ssh_key_path String?
repository_type String @default("public")
last_update_check DateTime?
latest_version String?
update_available Boolean @default(false)
signup_enabled Boolean @default(false)
default_user_role String @default("user")
ignore_ssl_self_signed Boolean @default(false)
logo_dark String? @default("/assets/logo_dark.png")
logo_light String? @default("/assets/logo_light.png")
favicon String? @default("/assets/logo_square.svg")
id String @id
server_url String @default("http://localhost:3001")
server_protocol String @default("http")
server_host String @default("localhost")
server_port Int @default(3001)
created_at DateTime @default(now())
updated_at DateTime
update_interval Int @default(60)
auto_update Boolean @default(false)
github_repo_url String @default("https://github.com/PatchMon/PatchMon.git")
ssh_key_path String?
repository_type String @default("public")
last_update_check DateTime?
latest_version String?
update_available Boolean @default(false)
signup_enabled Boolean @default(false)
default_user_role String @default("user")
ignore_ssl_self_signed Boolean @default(false)
logo_dark String? @default("/assets/logo_dark.png")
logo_light String? @default("/assets/logo_light.png")
favicon String? @default("/assets/logo_square.svg")
metrics_enabled Boolean @default(true)
metrics_anonymous_id String?
metrics_last_sent DateTime?
color_theme String @default("default")
}
model update_history {

View File

@@ -16,12 +16,28 @@ function getOptimizedDatabaseUrl() {
// 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
// Add connection pooling parameters - configurable via environment variables
const connectionLimit = process.env.DB_CONNECTION_LIMIT || "30";
const poolTimeout = process.env.DB_POOL_TIMEOUT || "20";
const connectTimeout = process.env.DB_CONNECT_TIMEOUT || "10";
const idleTimeout = process.env.DB_IDLE_TIMEOUT || "300";
const maxLifetime = process.env.DB_MAX_LIFETIME || "1800";
url.searchParams.set("connection_limit", connectionLimit);
url.searchParams.set("pool_timeout", poolTimeout);
url.searchParams.set("connect_timeout", connectTimeout);
url.searchParams.set("idle_timeout", idleTimeout);
url.searchParams.set("max_lifetime", maxLifetime);
// Log connection pool settings in development/debug mode
if (
process.env.ENABLE_LOGGING === "true" ||
process.env.LOG_LEVEL === "debug"
) {
console.log(
`[Database Pool] connection_limit=${connectionLimit}, pool_timeout=${poolTimeout}s, connect_timeout=${connectTimeout}s`,
);
}
return url.toString();
}

View File

@@ -218,6 +218,30 @@ router.post(
},
);
// Trigger manual Docker inventory cleanup
router.post(
"/trigger/docker-inventory-cleanup",
authenticateToken,
async (_req, res) => {
try {
const job = await queueManager.triggerDockerInventoryCleanup();
res.json({
success: true,
data: {
jobId: job.id,
message: "Docker inventory cleanup triggered successfully",
},
});
} catch (error) {
console.error("Error triggering Docker inventory cleanup:", error);
res.status(500).json({
success: false,
error: "Failed to trigger Docker inventory cleanup",
});
}
},
);
// Get queue health status
router.get("/health", authenticateToken, async (_req, res) => {
try {
@@ -274,6 +298,7 @@ router.get("/overview", authenticateToken, async (_req, res) => {
queueManager.getRecentJobs(QUEUE_NAMES.SESSION_CLEANUP, 1),
queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_REPO_CLEANUP, 1),
queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP, 1),
queueManager.getRecentJobs(QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP, 1),
queueManager.getRecentJobs(QUEUE_NAMES.AGENT_COMMANDS, 1),
]);
@@ -283,19 +308,22 @@ router.get("/overview", authenticateToken, async (_req, res) => {
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].delayed +
stats[QUEUE_NAMES.SESSION_CLEANUP].delayed +
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].delayed +
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].delayed,
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].delayed +
stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].delayed,
runningTasks:
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].active +
stats[QUEUE_NAMES.SESSION_CLEANUP].active +
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].active +
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].active,
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].active +
stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].active,
failedTasks:
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].failed +
stats[QUEUE_NAMES.SESSION_CLEANUP].failed +
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].failed +
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].failed,
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].failed +
stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].failed,
totalAutomations: Object.values(stats).reduce((sum, queueStats) => {
return (
@@ -375,10 +403,11 @@ router.get("/overview", authenticateToken, async (_req, res) => {
stats: stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP],
},
{
name: "Collect Host Statistics",
queue: QUEUE_NAMES.AGENT_COMMANDS,
description: "Collects package statistics from connected agents only",
schedule: `Every ${settings.update_interval} minutes (Agent-driven)`,
name: "Docker Inventory Cleanup",
queue: QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP,
description:
"Removes Docker containers and images for non-existent hosts",
schedule: "Daily at 4 AM",
lastRun: recentJobs[4][0]?.finishedOn
? new Date(recentJobs[4][0].finishedOn).toLocaleString()
: "Never",
@@ -388,6 +417,22 @@ router.get("/overview", authenticateToken, async (_req, res) => {
: recentJobs[4][0]
? "Success"
: "Never run",
stats: stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP],
},
{
name: "Collect Host Statistics",
queue: QUEUE_NAMES.AGENT_COMMANDS,
description: "Collects package statistics from connected agents only",
schedule: `Every ${settings.update_interval} minutes (Agent-driven)`,
lastRun: recentJobs[5][0]?.finishedOn
? new Date(recentJobs[5][0].finishedOn).toLocaleString()
: "Never",
lastRunTimestamp: recentJobs[5][0]?.finishedOn || 0,
status: recentJobs[5][0]?.failedReason
? "Failed"
: recentJobs[5][0]
? "Success"
: "Never run",
stats: stats[QUEUE_NAMES.AGENT_COMMANDS],
},
].sort((a, b) => {

View File

@@ -193,11 +193,16 @@ router.get(
},
);
// Get hosts with their update status
// Get hosts with their update status - OPTIMIZED
router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
try {
// Get settings once (outside the loop)
const settings = await prisma.settings.findFirst();
const updateIntervalMinutes = settings?.update_interval || 60;
const thresholdMinutes = updateIntervalMinutes * 2;
// Fetch hosts with groups
const hosts = await prisma.hosts.findMany({
// Show all hosts regardless of status
select: {
id: true,
machine_id: true,
@@ -223,61 +228,65 @@ router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
},
},
},
_count: {
select: {
host_packages: {
where: {
needs_update: true,
},
},
},
},
},
orderBy: { last_update: "desc" },
});
// Get update counts for each host separately
const hostsWithUpdateInfo = await Promise.all(
hosts.map(async (host) => {
const updatesCount = await prisma.host_packages.count({
where: {
host_id: host.id,
needs_update: true,
},
});
// OPTIMIZATION: Get all package counts in 2 batch queries instead of N*2 queries
const hostIds = hosts.map((h) => h.id);
// Get total packages count for this host
const totalPackagesCount = await prisma.host_packages.count({
where: {
host_id: host.id,
},
});
// Get the agent update interval setting for stale calculation
const settings = await prisma.settings.findFirst();
const updateIntervalMinutes = settings?.update_interval || 60;
const thresholdMinutes = updateIntervalMinutes * 2;
// Calculate effective status based on reporting interval
const isStale = moment(host.last_update).isBefore(
moment().subtract(thresholdMinutes, "minutes"),
);
let effectiveStatus = host.status;
// Override status if host hasn't reported within threshold
if (isStale && host.status === "active") {
effectiveStatus = "inactive";
}
return {
...host,
updatesCount,
totalPackagesCount,
isStale,
effectiveStatus,
};
const [updateCounts, totalCounts] = await Promise.all([
// Get update counts for all hosts at once
prisma.host_packages.groupBy({
by: ["host_id"],
where: {
host_id: { in: hostIds },
needs_update: true,
},
_count: { id: true },
}),
// Get total counts for all hosts at once
prisma.host_packages.groupBy({
by: ["host_id"],
where: {
host_id: { in: hostIds },
},
_count: { id: true },
}),
]);
// Create lookup maps for O(1) access
const updateCountMap = new Map(
updateCounts.map((item) => [item.host_id, item._count.id]),
);
const totalCountMap = new Map(
totalCounts.map((item) => [item.host_id, item._count.id]),
);
// Process hosts with counts from maps (no more DB queries!)
const hostsWithUpdateInfo = hosts.map((host) => {
const updatesCount = updateCountMap.get(host.id) || 0;
const totalPackagesCount = totalCountMap.get(host.id) || 0;
// Calculate effective status based on reporting interval
const isStale = moment(host.last_update).isBefore(
moment().subtract(thresholdMinutes, "minutes"),
);
let effectiveStatus = host.status;
// Override status if host hasn't reported within threshold
if (isStale && host.status === "active") {
effectiveStatus = "inactive";
}
return {
...host,
updatesCount,
totalPackagesCount,
isStale,
effectiveStatus,
};
});
res.json(hostsWithUpdateInfo);
} catch (error) {

View File

@@ -522,7 +522,8 @@ router.get("/updates", authenticateToken, async (req, res) => {
}
});
// POST /api/v1/docker/collect - Collect Docker data from agent
// POST /api/v1/docker/collect - Collect Docker data from agent (DEPRECATED - kept for backward compatibility)
// New agents should use POST /api/v1/integrations/docker
router.post("/collect", async (req, res) => {
try {
const { apiId, apiKey, containers, images, updates } = req.body;
@@ -745,6 +746,322 @@ router.post("/collect", async (req, res) => {
}
});
// POST /api/v1/integrations/docker - New integration endpoint for Docker data collection
router.post("/../integrations/docker", async (req, res) => {
try {
const apiId = req.headers["x-api-id"];
const apiKey = req.headers["x-api-key"];
const {
containers,
images,
updates,
daemon_info: _daemon_info,
hostname,
machine_id,
agent_version: _agent_version,
} = req.body;
console.log(
`[Docker Integration] Received data from ${hostname || machine_id}`,
);
// Validate API credentials
const host = await prisma.hosts.findFirst({
where: { api_id: apiId, api_key: apiKey },
});
if (!host) {
console.warn("[Docker Integration] Invalid API credentials");
return res.status(401).json({ error: "Invalid API credentials" });
}
console.log(
`[Docker Integration] Processing for host: ${host.friendly_name}`,
);
const now = new Date();
// Helper function to validate and parse dates
const parseDate = (dateString) => {
if (!dateString) return now;
const date = new Date(dateString);
return Number.isNaN(date.getTime()) ? now : date;
};
let containersProcessed = 0;
let imagesProcessed = 0;
let updatesProcessed = 0;
// Process containers
if (containers && Array.isArray(containers)) {
console.log(
`[Docker Integration] Processing ${containers.length} containers`,
);
for (const containerData of containers) {
const containerId = uuidv4();
// Find or create image
let imageId = null;
if (containerData.image_repository && containerData.image_tag) {
const image = await prisma.docker_images.upsert({
where: {
repository_tag_image_id: {
repository: containerData.image_repository,
tag: containerData.image_tag,
image_id: containerData.image_id || "unknown",
},
},
update: {
last_checked: now,
updated_at: now,
},
create: {
id: uuidv4(),
repository: containerData.image_repository,
tag: containerData.image_tag,
image_id: containerData.image_id || "unknown",
source: containerData.image_source || "docker-hub",
created_at: parseDate(containerData.created_at),
updated_at: now,
},
});
imageId = image.id;
}
// Upsert container
await prisma.docker_containers.upsert({
where: {
host_id_container_id: {
host_id: host.id,
container_id: containerData.container_id,
},
},
update: {
name: containerData.name,
image_id: imageId,
image_name: containerData.image_name,
image_tag: containerData.image_tag || "latest",
status: containerData.status,
state: containerData.state || containerData.status,
ports: containerData.ports || null,
started_at: containerData.started_at
? parseDate(containerData.started_at)
: null,
updated_at: now,
last_checked: now,
},
create: {
id: containerId,
host_id: host.id,
container_id: containerData.container_id,
name: containerData.name,
image_id: imageId,
image_name: containerData.image_name,
image_tag: containerData.image_tag || "latest",
status: containerData.status,
state: containerData.state || containerData.status,
ports: containerData.ports || null,
created_at: parseDate(containerData.created_at),
started_at: containerData.started_at
? parseDate(containerData.started_at)
: null,
updated_at: now,
},
});
containersProcessed++;
}
}
// Process standalone images
if (images && Array.isArray(images)) {
console.log(`[Docker Integration] Processing ${images.length} images`);
for (const imageData of images) {
await prisma.docker_images.upsert({
where: {
repository_tag_image_id: {
repository: imageData.repository,
tag: imageData.tag,
image_id: imageData.image_id,
},
},
update: {
size_bytes: imageData.size_bytes
? BigInt(imageData.size_bytes)
: null,
digest: imageData.digest || null,
last_checked: now,
updated_at: now,
},
create: {
id: uuidv4(),
repository: imageData.repository,
tag: imageData.tag,
image_id: imageData.image_id,
digest: imageData.digest,
size_bytes: imageData.size_bytes
? BigInt(imageData.size_bytes)
: null,
source: imageData.source || "docker-hub",
created_at: parseDate(imageData.created_at),
updated_at: now,
},
});
imagesProcessed++;
}
}
// Process updates
if (updates && Array.isArray(updates)) {
console.log(`[Docker Integration] Processing ${updates.length} updates`);
for (const updateData of updates) {
// Find the image by repository and image_id
const image = await prisma.docker_images.findFirst({
where: {
repository: updateData.repository,
tag: updateData.current_tag,
image_id: updateData.image_id,
},
});
if (image) {
// Store digest info in changelog_url field as JSON
const digestInfo = JSON.stringify({
method: "digest_comparison",
current_digest: updateData.current_digest,
available_digest: updateData.available_digest,
});
// Upsert the update record
await prisma.docker_image_updates.upsert({
where: {
image_id_available_tag: {
image_id: image.id,
available_tag: updateData.available_tag,
},
},
update: {
updated_at: now,
changelog_url: digestInfo,
severity: "digest_changed",
},
create: {
id: uuidv4(),
image_id: image.id,
current_tag: updateData.current_tag,
available_tag: updateData.available_tag,
severity: "digest_changed",
changelog_url: digestInfo,
updated_at: now,
},
});
updatesProcessed++;
}
}
}
console.log(
`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${updatesProcessed} updates`,
);
res.json({
message: "Docker data collected successfully",
containers_received: containersProcessed,
images_received: imagesProcessed,
updates_found: updatesProcessed,
});
} catch (error) {
console.error("[Docker Integration] Error collecting Docker data:", error);
console.error("[Docker Integration] Error stack:", error.stack);
res.status(500).json({
error: "Failed to collect Docker data",
message: error.message,
details: process.env.NODE_ENV === "development" ? error.stack : undefined,
});
}
});
// DELETE /api/v1/docker/containers/:id - Delete a container
router.delete("/containers/:id", authenticateToken, async (req, res) => {
try {
const { id } = req.params;
// Check if container exists
const container = await prisma.docker_containers.findUnique({
where: { id },
});
if (!container) {
return res.status(404).json({ error: "Container not found" });
}
// Delete the container
await prisma.docker_containers.delete({
where: { id },
});
console.log(`🗑️ Deleted container: ${container.name} (${id})`);
res.json({
success: true,
message: `Container ${container.name} deleted successfully`,
});
} catch (error) {
console.error("Error deleting container:", error);
res.status(500).json({ error: "Failed to delete container" });
}
});
// DELETE /api/v1/docker/images/:id - Delete an image
router.delete("/images/:id", authenticateToken, async (req, res) => {
try {
const { id } = req.params;
// Check if image exists
const image = await prisma.docker_images.findUnique({
where: { id },
include: {
_count: {
select: {
docker_containers: true,
},
},
},
});
if (!image) {
return res.status(404).json({ error: "Image not found" });
}
// Check if image is in use by containers
if (image._count.docker_containers > 0) {
return res.status(400).json({
error: `Cannot delete image: ${image._count.docker_containers} container(s) are using this image`,
containersCount: image._count.docker_containers,
});
}
// Delete image updates first
await prisma.docker_image_updates.deleteMany({
where: { image_id: id },
});
// Delete the image
await prisma.docker_images.delete({
where: { id },
});
console.log(`🗑️ Deleted image: ${image.repository}:${image.tag} (${id})`);
res.json({
success: true,
message: `Image ${image.repository}:${image.tag} deleted successfully`,
});
} catch (error) {
console.error("Error deleting image:", error);
res.status(500).json({ error: "Failed to delete image" });
}
});
// GET /api/v1/docker/agent - Serve the Docker agent installation script
router.get("/agent", async (_req, res) => {
try {

View File

@@ -356,6 +356,26 @@ router.post(
});
} catch (error) {
console.error("Host creation error:", error);
// Check if error is related to connection pool exhaustion
if (
error.message &&
(error.message.includes("connection pool") ||
error.message.includes("Timed out fetching") ||
error.message.includes("pool timeout"))
) {
console.error("⚠️ DATABASE CONNECTION POOL EXHAUSTED!");
console.error(
`⚠️ Current limit: DB_CONNECTION_LIMIT=${process.env.DB_CONNECTION_LIMIT || "30"}`,
);
console.error(
`⚠️ Pool timeout: DB_POOL_TIMEOUT=${process.env.DB_POOL_TIMEOUT || "20"}s`,
);
console.error(
"⚠️ Suggestion: Increase DB_CONNECTION_LIMIT in your .env file",
);
}
res.status(500).json({ error: "Failed to create host" });
}
},
@@ -786,19 +806,41 @@ router.get("/info", validateApiCredentials, async (req, res) => {
// Ping endpoint for health checks (now uses API credentials)
router.post("/ping", validateApiCredentials, async (req, res) => {
try {
// Update last update timestamp
const now = new Date();
const lastUpdate = req.hostRecord.last_update;
// Detect if this is an agent startup (first ping or after long absence)
const timeSinceLastUpdate = lastUpdate ? now - lastUpdate : null;
const isStartup =
!timeSinceLastUpdate || timeSinceLastUpdate > 5 * 60 * 1000; // 5 minutes
// Log agent startup
if (isStartup) {
console.log(
`🚀 Agent startup detected: ${req.hostRecord.friendly_name} (${req.hostRecord.hostname || req.hostRecord.api_id})`,
);
// Check if status was previously offline
if (req.hostRecord.status === "offline") {
console.log(`✅ Agent back online: ${req.hostRecord.friendly_name}`);
}
}
// Update last update timestamp and set status to active
await prisma.hosts.update({
where: { id: req.hostRecord.id },
data: {
last_update: new Date(),
updated_at: new Date(),
last_update: now,
updated_at: now,
status: "active",
},
});
const response = {
message: "Ping successful",
timestamp: new Date().toISOString(),
timestamp: now.toISOString(),
friendlyName: req.hostRecord.friendly_name,
agentStartup: isStartup,
};
// Check if this is a crontab update trigger
@@ -1441,10 +1483,12 @@ router.get("/install", async (req, res) => {
// Determine curl flags dynamically from settings (ignore self-signed)
let curlFlags = "-s";
let skipSSLVerify = "false";
try {
const settings = await prisma.settings.findFirst();
if (settings && settings.ignore_ssl_self_signed === true) {
curlFlags = "-sk";
skipSSLVerify = "true";
}
} catch (_) {}
@@ -1454,12 +1498,13 @@ router.get("/install", async (req, res) => {
// Get architecture parameter (default to amd64)
const architecture = req.query.arch || "amd64";
// Inject the API credentials, server URL, curl flags, force flag, and architecture into the script
// Inject the API credentials, server URL, curl flags, SSL verify flag, force flag, and architecture into the script
const envVars = `#!/bin/bash
export PATCHMON_URL="${serverUrl}"
export API_ID="${host.api_id}"
export API_KEY="${host.api_key}"
export CURL_FLAGS="${curlFlags}"
export SKIP_SSL_VERIFY="${skipSSLVerify}"
export FORCE_INSTALL="${forceInstall ? "true" : "false"}"
export ARCHITECTURE="${architecture}"

View File

@@ -0,0 +1,242 @@
const express = require("express");
const { getPrismaClient } = require("../config/prisma");
const { v4: uuidv4 } = require("uuid");
const prisma = getPrismaClient();
const router = express.Router();
// POST /api/v1/integrations/docker - Docker data collection endpoint
router.post("/docker", async (req, res) => {
try {
const apiId = req.headers["x-api-id"];
const apiKey = req.headers["x-api-key"];
const {
containers,
images,
updates,
daemon_info: _daemon_info,
hostname,
machine_id,
agent_version: _agent_version,
} = req.body;
console.log(
`[Docker Integration] Received data from ${hostname || machine_id}`,
);
// Validate API credentials
const host = await prisma.hosts.findFirst({
where: { api_id: apiId, api_key: apiKey },
});
if (!host) {
console.warn("[Docker Integration] Invalid API credentials");
return res.status(401).json({ error: "Invalid API credentials" });
}
console.log(
`[Docker Integration] Processing for host: ${host.friendly_name}`,
);
const now = new Date();
// Helper function to validate and parse dates
const parseDate = (dateString) => {
if (!dateString) return now;
const date = new Date(dateString);
return Number.isNaN(date.getTime()) ? now : date;
};
let containersProcessed = 0;
let imagesProcessed = 0;
let updatesProcessed = 0;
// Process containers
if (containers && Array.isArray(containers)) {
console.log(
`[Docker Integration] Processing ${containers.length} containers`,
);
for (const containerData of containers) {
const containerId = uuidv4();
// Find or create image
let imageId = null;
if (containerData.image_repository && containerData.image_tag) {
const image = await prisma.docker_images.upsert({
where: {
repository_tag_image_id: {
repository: containerData.image_repository,
tag: containerData.image_tag,
image_id: containerData.image_id || "unknown",
},
},
update: {
last_checked: now,
updated_at: now,
},
create: {
id: uuidv4(),
repository: containerData.image_repository,
tag: containerData.image_tag,
image_id: containerData.image_id || "unknown",
source: containerData.image_source || "docker-hub",
created_at: parseDate(containerData.created_at),
updated_at: now,
},
});
imageId = image.id;
}
// Upsert container
await prisma.docker_containers.upsert({
where: {
host_id_container_id: {
host_id: host.id,
container_id: containerData.container_id,
},
},
update: {
name: containerData.name,
image_id: imageId,
image_name: containerData.image_name,
image_tag: containerData.image_tag || "latest",
status: containerData.status,
state: containerData.state || containerData.status,
ports: containerData.ports || null,
started_at: containerData.started_at
? parseDate(containerData.started_at)
: null,
updated_at: now,
last_checked: now,
},
create: {
id: containerId,
host_id: host.id,
container_id: containerData.container_id,
name: containerData.name,
image_id: imageId,
image_name: containerData.image_name,
image_tag: containerData.image_tag || "latest",
status: containerData.status,
state: containerData.state || containerData.status,
ports: containerData.ports || null,
created_at: parseDate(containerData.created_at),
started_at: containerData.started_at
? parseDate(containerData.started_at)
: null,
updated_at: now,
},
});
containersProcessed++;
}
}
// Process standalone images
if (images && Array.isArray(images)) {
console.log(`[Docker Integration] Processing ${images.length} images`);
for (const imageData of images) {
await prisma.docker_images.upsert({
where: {
repository_tag_image_id: {
repository: imageData.repository,
tag: imageData.tag,
image_id: imageData.image_id,
},
},
update: {
size_bytes: imageData.size_bytes
? BigInt(imageData.size_bytes)
: null,
digest: imageData.digest || null,
last_checked: now,
updated_at: now,
},
create: {
id: uuidv4(),
repository: imageData.repository,
tag: imageData.tag,
image_id: imageData.image_id,
digest: imageData.digest,
size_bytes: imageData.size_bytes
? BigInt(imageData.size_bytes)
: null,
source: imageData.source || "docker-hub",
created_at: parseDate(imageData.created_at),
updated_at: now,
},
});
imagesProcessed++;
}
}
// Process updates
if (updates && Array.isArray(updates)) {
console.log(`[Docker Integration] Processing ${updates.length} updates`);
for (const updateData of updates) {
// Find the image by repository and image_id
const image = await prisma.docker_images.findFirst({
where: {
repository: updateData.repository,
tag: updateData.current_tag,
image_id: updateData.image_id,
},
});
if (image) {
// Store digest info in changelog_url field as JSON
const digestInfo = JSON.stringify({
method: "digest_comparison",
current_digest: updateData.current_digest,
available_digest: updateData.available_digest,
});
// Upsert the update record
await prisma.docker_image_updates.upsert({
where: {
image_id_available_tag: {
image_id: image.id,
available_tag: updateData.available_tag,
},
},
update: {
updated_at: now,
changelog_url: digestInfo,
severity: "digest_changed",
},
create: {
id: uuidv4(),
image_id: image.id,
current_tag: updateData.current_tag,
available_tag: updateData.available_tag,
severity: "digest_changed",
changelog_url: digestInfo,
updated_at: now,
},
});
updatesProcessed++;
}
}
}
console.log(
`[Docker Integration] Successfully processed: ${containersProcessed} containers, ${imagesProcessed} images, ${updatesProcessed} updates`,
);
res.json({
message: "Docker data collected successfully",
containers_received: containersProcessed,
images_received: imagesProcessed,
updates_found: updatesProcessed,
});
} catch (error) {
console.error("[Docker Integration] Error collecting Docker data:", error);
console.error("[Docker Integration] Error stack:", error.stack);
res.status(500).json({
error: "Failed to collect Docker data",
message: error.message,
details: process.env.NODE_ENV === "development" ? error.stack : undefined,
});
}
});
module.exports = router;

View File

@@ -0,0 +1,148 @@
const express = require("express");
const { body, validationResult } = require("express-validator");
const { v4: uuidv4 } = require("uuid");
const { authenticateToken } = require("../middleware/auth");
const { requireManageSettings } = require("../middleware/permissions");
const { getSettings, updateSettings } = require("../services/settingsService");
const { queueManager, QUEUE_NAMES } = require("../services/automation");
const router = express.Router();
// Get metrics settings
router.get("/", authenticateToken, requireManageSettings, async (_req, res) => {
try {
const settings = await getSettings();
// Generate anonymous ID if it doesn't exist
if (!settings.metrics_anonymous_id) {
const anonymousId = uuidv4();
await updateSettings(settings.id, {
metrics_anonymous_id: anonymousId,
});
settings.metrics_anonymous_id = anonymousId;
}
res.json({
metrics_enabled: settings.metrics_enabled ?? true,
metrics_anonymous_id: settings.metrics_anonymous_id,
metrics_last_sent: settings.metrics_last_sent,
});
} catch (error) {
console.error("Metrics settings fetch error:", error);
res.status(500).json({ error: "Failed to fetch metrics settings" });
}
});
// Update metrics settings
router.put(
"/",
authenticateToken,
requireManageSettings,
[
body("metrics_enabled")
.isBoolean()
.withMessage("Metrics enabled must be a boolean"),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { metrics_enabled } = req.body;
const settings = await getSettings();
await updateSettings(settings.id, {
metrics_enabled,
});
console.log(
`Metrics ${metrics_enabled ? "enabled" : "disabled"} by user`,
);
res.json({
message: "Metrics settings updated successfully",
metrics_enabled,
});
} catch (error) {
console.error("Metrics settings update error:", error);
res.status(500).json({ error: "Failed to update metrics settings" });
}
},
);
// Regenerate anonymous ID
router.post(
"/regenerate-id",
authenticateToken,
requireManageSettings,
async (_req, res) => {
try {
const settings = await getSettings();
const newAnonymousId = uuidv4();
await updateSettings(settings.id, {
metrics_anonymous_id: newAnonymousId,
});
console.log("Anonymous ID regenerated");
res.json({
message: "Anonymous ID regenerated successfully",
metrics_anonymous_id: newAnonymousId,
});
} catch (error) {
console.error("Anonymous ID regeneration error:", error);
res.status(500).json({ error: "Failed to regenerate anonymous ID" });
}
},
);
// Manually send metrics now
router.post(
"/send-now",
authenticateToken,
requireManageSettings,
async (_req, res) => {
try {
const settings = await getSettings();
if (!settings.metrics_enabled) {
return res.status(400).json({
error: "Metrics are disabled. Please enable metrics first.",
});
}
// Trigger metrics directly (no queue delay for manual trigger)
const metricsReporting =
queueManager.automations[QUEUE_NAMES.METRICS_REPORTING];
const result = await metricsReporting.process(
{ name: "manual-send" },
false,
);
if (result.success) {
console.log("✅ Manual metrics sent successfully");
res.json({
message: "Metrics sent successfully",
data: result,
});
} else {
console.error("❌ Failed to send metrics:", result);
res.status(500).json({
error: "Failed to send metrics",
details: result.reason || result.error,
});
}
} catch (error) {
console.error("Send metrics error:", error);
res.status(500).json({
error: "Failed to send metrics",
details: error.message,
});
}
},
);
module.exports = router;

View File

@@ -101,74 +101,107 @@ router.get("/", async (req, res) => {
prisma.packages.count({ where }),
]);
// Get additional stats for each package
const packagesWithStats = await Promise.all(
packages.map(async (pkg) => {
// Build base where clause for this package
const baseWhere = { package_id: pkg.id };
// OPTIMIZATION: Batch query all stats instead of N individual queries
const packageIds = packages.map((pkg) => pkg.id);
// If host filter is specified, add host filter to all queries
const hostWhere = host ? { ...baseWhere, host_id: host } : baseWhere;
const [updatesCount, securityCount, packageHosts] = await Promise.all([
prisma.host_packages.count({
where: {
...hostWhere,
needs_update: true,
},
}),
prisma.host_packages.count({
where: {
...hostWhere,
needs_update: true,
is_security_update: true,
},
}),
prisma.host_packages.findMany({
where: {
...hostWhere,
// If host filter is specified, include all packages for that host
// Otherwise, only include packages that need updates
...(host ? {} : { needs_update: true }),
},
select: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
os_type: true,
},
},
current_version: true,
available_version: true,
needs_update: true,
is_security_update: true,
},
take: 10, // Limit to first 10 for performance
}),
]);
return {
...pkg,
packageHostsCount: pkg._count.host_packages,
packageHosts: packageHosts.map((hp) => ({
hostId: hp.hosts.id,
friendlyName: hp.hosts.friendly_name,
osType: hp.hosts.os_type,
currentVersion: hp.current_version,
availableVersion: hp.available_version,
needsUpdate: hp.needs_update,
isSecurityUpdate: hp.is_security_update,
})),
stats: {
totalInstalls: pkg._count.host_packages,
updatesNeeded: updatesCount,
securityUpdates: securityCount,
// Get all counts and host data in 3 batch queries instead of N*3 queries
const [allUpdatesCounts, allSecurityCounts, allPackageHostsData] =
await Promise.all([
// Batch count all packages that need updates
prisma.host_packages.groupBy({
by: ["package_id"],
where: {
package_id: { in: packageIds },
needs_update: true,
...(host ? { host_id: host } : {}),
},
};
}),
_count: { id: true },
}),
// Batch count all packages with security updates
prisma.host_packages.groupBy({
by: ["package_id"],
where: {
package_id: { in: packageIds },
needs_update: true,
is_security_update: true,
...(host ? { host_id: host } : {}),
},
_count: { id: true },
}),
// Batch fetch all host data for packages
prisma.host_packages.findMany({
where: {
package_id: { in: packageIds },
...(host ? { host_id: host } : { needs_update: true }),
},
select: {
package_id: true,
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
os_type: true,
},
},
current_version: true,
available_version: true,
needs_update: true,
is_security_update: true,
},
// Limit to first 10 per package
take: 100, // Increased from package-based limit
}),
]);
// Create lookup maps for O(1) access
const updatesCountMap = new Map(
allUpdatesCounts.map((item) => [item.package_id, item._count.id]),
);
const securityCountMap = new Map(
allSecurityCounts.map((item) => [item.package_id, item._count.id]),
);
const packageHostsMap = new Map();
// Group host data by package_id
for (const hp of allPackageHostsData) {
if (!packageHostsMap.has(hp.package_id)) {
packageHostsMap.set(hp.package_id, []);
}
const hosts = packageHostsMap.get(hp.package_id);
hosts.push({
hostId: hp.hosts.id,
friendlyName: hp.hosts.friendly_name,
osType: hp.hosts.os_type,
currentVersion: hp.current_version,
availableVersion: hp.available_version,
needsUpdate: hp.needs_update,
isSecurityUpdate: hp.is_security_update,
});
// Limit to 10 hosts per package
if (hosts.length > 10) {
packageHostsMap.set(hp.package_id, hosts.slice(0, 10));
}
}
// Map packages with stats from lookup maps (no more DB queries!)
const packagesWithStats = packages.map((pkg) => {
const updatesCount = updatesCountMap.get(pkg.id) || 0;
const securityCount = securityCountMap.get(pkg.id) || 0;
const packageHosts = packageHostsMap.get(pkg.id) || [];
return {
...pkg,
packageHostsCount: pkg._count.host_packages,
packageHosts,
stats: {
totalInstalls: pkg._count.host_packages,
updatesNeeded: updatesCount,
securityUpdates: securityCount,
},
};
});
res.json({
packages: packagesWithStats,

View File

@@ -158,6 +158,7 @@ router.put(
logoDark,
logoLight,
favicon,
colorTheme,
} = req.body;
// Get current settings to check for update interval changes
@@ -189,6 +190,7 @@ router.put(
if (logoDark !== undefined) updateData.logo_dark = logoDark;
if (logoLight !== undefined) updateData.logo_light = logoLight;
if (favicon !== undefined) updateData.favicon = favicon;
if (colorTheme !== undefined) updateData.color_theme = colorTheme;
const updatedSettings = await updateSettings(
currentSettings.id,

View File

@@ -14,13 +14,16 @@ const router = express.Router();
function getCurrentVersion() {
try {
const packageJson = require("../../package.json");
return packageJson?.version || "1.3.0";
if (!packageJson?.version) {
throw new Error("Version not found in package.json");
}
return packageJson.version;
} catch (packageError) {
console.warn(
"Could not read version from package.json, using fallback:",
console.error(
"Could not read version from package.json:",
packageError.message,
);
return "1.3.0";
return "unknown";
}
}

View File

@@ -11,7 +11,31 @@ const {
const router = express.Router();
// Get WebSocket connection status by api_id (no database access - pure memory lookup)
// Get WebSocket connection status for multiple hosts at once (bulk endpoint)
router.get("/status", authenticateToken, async (req, res) => {
try {
const { apiIds } = req.query; // Comma-separated list of api_ids
const idArray = apiIds ? apiIds.split(",").filter((id) => id.trim()) : [];
const statusMap = {};
idArray.forEach((apiId) => {
statusMap[apiId] = getConnectionInfo(apiId);
});
res.json({
success: true,
data: statusMap,
});
} catch (error) {
console.error("Error fetching bulk WebSocket status:", error);
res.status(500).json({
success: false,
error: "Failed to fetch WebSocket status",
});
}
});
// Get WebSocket connection status by api_id (single endpoint)
router.get("/status/:apiId", authenticateToken, async (req, res) => {
try {
const { apiId } = req.params;

View File

@@ -66,8 +66,10 @@ const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes");
const gethomepageRoutes = require("./routes/gethomepageRoutes");
const automationRoutes = require("./routes/automationRoutes");
const dockerRoutes = require("./routes/dockerRoutes");
const integrationRoutes = require("./routes/integrationRoutes");
const wsRoutes = require("./routes/wsRoutes");
const agentVersionRoutes = require("./routes/agentVersionRoutes");
const metricsRoutes = require("./routes/metricsRoutes");
const { initSettings } = require("./services/settingsService");
const { queueManager } = require("./services/automation");
const { authenticateToken, requireAdmin } = require("./middleware/auth");
@@ -471,8 +473,10 @@ app.use(
app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes);
app.use(`/api/${apiVersion}/automation`, automationRoutes);
app.use(`/api/${apiVersion}/docker`, dockerRoutes);
app.use(`/api/${apiVersion}/integrations`, integrationRoutes);
app.use(`/api/${apiVersion}/ws`, wsRoutes);
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
// Bull Board - will be populated after queue manager initializes
let bullBoardRouter = null;
@@ -1198,6 +1202,15 @@ async function startServer() {
initAgentWs(server, prisma);
await agentVersionService.initialize();
// Send metrics on startup (silent - no console output)
try {
const metricsReporting =
queueManager.automations[QUEUE_NAMES.METRICS_REPORTING];
await metricsReporting.sendSilent();
} catch (_error) {
// Silent failure - don't block server startup if metrics fail
}
server.listen(PORT, () => {
if (process.env.ENABLE_LOGGING === "true") {
logger.info(`Server running on port ${PORT}`);

View File

@@ -428,26 +428,29 @@ class AgentVersionService {
async getVersionInfo() {
let hasUpdate = false;
let updateStatus = "unknown";
let effectiveLatestVersion = this.currentVersion; // Always use local version if available
// If we have a local version, use it as the latest regardless of GitHub
if (this.currentVersion) {
effectiveLatestVersion = this.currentVersion;
// Latest version should ALWAYS come from GitHub, not from local binaries
// currentVersion = what's installed locally
// latestVersion = what's available on GitHub
if (this.latestVersion) {
console.log(`📦 Latest version from GitHub: ${this.latestVersion}`);
} else {
console.log(
`🔄 Using local agent version ${this.currentVersion} as latest`,
);
} else if (this.latestVersion) {
// Fallback to GitHub version only if no local version
effectiveLatestVersion = this.latestVersion;
console.log(
`🔄 No local version found, using GitHub version ${this.latestVersion}`,
`⚠️ No GitHub release version available (API may be unavailable)`,
);
}
if (this.currentVersion && effectiveLatestVersion) {
if (this.currentVersion) {
console.log(`💾 Current local agent version: ${this.currentVersion}`);
} else {
console.log(`⚠️ No local agent binary found`);
}
// Determine update status by comparing current vs latest (from GitHub)
if (this.currentVersion && this.latestVersion) {
const comparison = compareVersions(
this.currentVersion,
effectiveLatestVersion,
this.latestVersion,
);
if (comparison < 0) {
hasUpdate = true;
@@ -459,25 +462,25 @@ class AgentVersionService {
hasUpdate = false;
updateStatus = "up-to-date";
}
} else if (effectiveLatestVersion && !this.currentVersion) {
} else if (this.latestVersion && !this.currentVersion) {
hasUpdate = true;
updateStatus = "no-agent";
} else if (this.currentVersion && !effectiveLatestVersion) {
} else if (this.currentVersion && !this.latestVersion) {
// We have a current version but no latest version (GitHub API unavailable)
hasUpdate = false;
updateStatus = "github-unavailable";
} else if (!this.currentVersion && !effectiveLatestVersion) {
} else if (!this.currentVersion && !this.latestVersion) {
updateStatus = "no-data";
}
return {
currentVersion: this.currentVersion,
latestVersion: effectiveLatestVersion,
latestVersion: this.latestVersion, // Always return GitHub version, not local
hasUpdate: hasUpdate,
updateStatus: updateStatus,
lastChecked: this.lastChecked,
supportedArchitectures: this.supportedArchitectures,
status: effectiveLatestVersion ? "ready" : "no-releases",
status: this.latestVersion ? "ready" : "no-releases",
};
}

View File

@@ -99,8 +99,22 @@ function init(server, prismaClient) {
// Notify subscribers of connection
notifyConnectionChange(apiId, true);
ws.on("message", () => {
// Currently we don't need to handle agent->server messages
ws.on("message", async (data) => {
// Handle incoming messages from agent (e.g., Docker status updates)
try {
const message = JSON.parse(data.toString());
if (message.type === "docker_status") {
// Handle Docker container status events
await handleDockerStatusEvent(apiId, message);
}
// Add more message types here as needed
} catch (err) {
console.error(
`[agent-ws] error parsing message from ${apiId}:`,
err,
);
}
});
ws.on("close", () => {
@@ -255,6 +269,62 @@ function subscribeToConnectionChanges(apiId, callback) {
};
}
// Handle Docker container status events from agent
async function handleDockerStatusEvent(apiId, message) {
try {
const { event: _event, container_id, name, status, timestamp } = message;
console.log(
`[Docker Event] ${apiId}: Container ${name} (${container_id}) - ${status}`,
);
// Find the host
const host = await prisma.hosts.findUnique({
where: { api_id: apiId },
});
if (!host) {
console.error(`[Docker Event] Host not found for api_id: ${apiId}`);
return;
}
// Update container status in database
const container = await prisma.docker_containers.findUnique({
where: {
host_id_container_id: {
host_id: host.id,
container_id: container_id,
},
},
});
if (container) {
await prisma.docker_containers.update({
where: { id: container.id },
data: {
status: status,
state: status,
updated_at: new Date(timestamp || Date.now()),
last_checked: new Date(),
},
});
console.log(
`[Docker Event] Updated container ${name} status to ${status}`,
);
} else {
console.log(
`[Docker Event] Container ${name} not found in database (may be new)`,
);
}
// TODO: Broadcast to connected dashboard clients via SSE or WebSocket
// This would notify the frontend UI in real-time
} catch (error) {
console.error(`[Docker Event] Error handling Docker status event:`, error);
}
}
module.exports = {
init,
broadcastSettingsUpdate,

View File

@@ -0,0 +1,164 @@
const { prisma } = require("./shared/prisma");
/**
* Docker Inventory Cleanup Automation
* Removes Docker containers and images for hosts that no longer exist
*/
class DockerInventoryCleanup {
constructor(queueManager) {
this.queueManager = queueManager;
this.queueName = "docker-inventory-cleanup";
}
/**
* Process Docker inventory cleanup job
*/
async process(_job) {
const startTime = Date.now();
console.log("🧹 Starting Docker inventory cleanup...");
try {
// Step 1: Find and delete orphaned containers (containers for non-existent hosts)
const orphanedContainers = await prisma.docker_containers.findMany({
where: {
host_id: {
// Find containers where the host doesn't exist
notIn: await prisma.hosts
.findMany({ select: { id: true } })
.then((hosts) => hosts.map((h) => h.id)),
},
},
});
let deletedContainersCount = 0;
const deletedContainers = [];
for (const container of orphanedContainers) {
try {
await prisma.docker_containers.delete({
where: { id: container.id },
});
deletedContainersCount++;
deletedContainers.push({
id: container.id,
container_id: container.container_id,
name: container.name,
image_name: container.image_name,
host_id: container.host_id,
});
console.log(
`🗑️ Deleted orphaned container: ${container.name} (host_id: ${container.host_id})`,
);
} catch (deleteError) {
console.error(
`❌ Failed to delete container ${container.id}:`,
deleteError.message,
);
}
}
// Step 2: Find and delete orphaned images (images with no containers using them)
const orphanedImages = await prisma.docker_images.findMany({
where: {
docker_containers: {
none: {},
},
},
include: {
_count: {
select: {
docker_containers: true,
docker_image_updates: true,
},
},
},
});
let deletedImagesCount = 0;
const deletedImages = [];
for (const image of orphanedImages) {
try {
// First delete any image updates associated with this image
if (image._count.docker_image_updates > 0) {
await prisma.docker_image_updates.deleteMany({
where: { image_id: image.id },
});
}
// Then delete the image itself
await prisma.docker_images.delete({
where: { id: image.id },
});
deletedImagesCount++;
deletedImages.push({
id: image.id,
repository: image.repository,
tag: image.tag,
image_id: image.image_id,
});
console.log(
`🗑️ Deleted orphaned image: ${image.repository}:${image.tag}`,
);
} catch (deleteError) {
console.error(
`❌ Failed to delete image ${image.id}:`,
deleteError.message,
);
}
}
const executionTime = Date.now() - startTime;
console.log(
`✅ Docker inventory cleanup completed in ${executionTime}ms - Deleted ${deletedContainersCount} containers and ${deletedImagesCount} images`,
);
return {
success: true,
deletedContainersCount,
deletedImagesCount,
deletedContainers,
deletedImages,
executionTime,
};
} catch (error) {
const executionTime = Date.now() - startTime;
console.error(
`❌ Docker inventory cleanup failed after ${executionTime}ms:`,
error.message,
);
throw error;
}
}
/**
* Schedule recurring Docker inventory cleanup (daily at 4 AM)
*/
async schedule() {
const job = await this.queueManager.queues[this.queueName].add(
"docker-inventory-cleanup",
{},
{
repeat: { cron: "0 4 * * *" }, // Daily at 4 AM
jobId: "docker-inventory-cleanup-recurring",
},
);
console.log("✅ Docker inventory cleanup scheduled");
return job;
}
/**
* Trigger manual Docker inventory cleanup
*/
async triggerManual() {
const job = await this.queueManager.queues[this.queueName].add(
"docker-inventory-cleanup-manual",
{},
{ priority: 1 },
);
console.log("✅ Manual Docker inventory cleanup triggered");
return job;
}
}
module.exports = DockerInventoryCleanup;

View File

@@ -52,17 +52,24 @@ class GitHubUpdateCheck {
}
// Read version from package.json
let currentVersion = "1.3.0"; // fallback
let currentVersion = null;
try {
const packageJson = require("../../../package.json");
if (packageJson?.version) {
currentVersion = packageJson.version;
}
} catch (packageError) {
console.warn(
console.error(
"Could not read version from package.json:",
packageError.message,
);
throw new Error(
"Could not determine current version from package.json",
);
}
if (!currentVersion) {
throw new Error("Version not found in package.json");
}
const isUpdateAvailable =

View File

@@ -8,6 +8,8 @@ const GitHubUpdateCheck = require("./githubUpdateCheck");
const SessionCleanup = require("./sessionCleanup");
const OrphanedRepoCleanup = require("./orphanedRepoCleanup");
const OrphanedPackageCleanup = require("./orphanedPackageCleanup");
const DockerInventoryCleanup = require("./dockerInventoryCleanup");
const MetricsReporting = require("./metricsReporting");
// Queue names
const QUEUE_NAMES = {
@@ -15,6 +17,8 @@ const QUEUE_NAMES = {
SESSION_CLEANUP: "session-cleanup",
ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup",
ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup",
DOCKER_INVENTORY_CLEANUP: "docker-inventory-cleanup",
METRICS_REPORTING: "metrics-reporting",
AGENT_COMMANDS: "agent-commands",
};
@@ -91,6 +95,11 @@ class QueueManager {
new OrphanedRepoCleanup(this);
this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP] =
new OrphanedPackageCleanup(this);
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] =
new DockerInventoryCleanup(this);
this.automations[QUEUE_NAMES.METRICS_REPORTING] = new MetricsReporting(
this,
);
console.log("✅ All automation classes initialized");
}
@@ -149,6 +158,24 @@ class QueueManager {
workerOptions,
);
// Docker Inventory Cleanup Worker
this.workers[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] = new Worker(
QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP,
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].process.bind(
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP],
),
workerOptions,
);
// Metrics Reporting Worker
this.workers[QUEUE_NAMES.METRICS_REPORTING] = new Worker(
QUEUE_NAMES.METRICS_REPORTING,
this.automations[QUEUE_NAMES.METRICS_REPORTING].process.bind(
this.automations[QUEUE_NAMES.METRICS_REPORTING],
),
workerOptions,
);
// Agent Commands Worker
this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker(
QUEUE_NAMES.AGENT_COMMANDS,
@@ -205,6 +232,8 @@ class QueueManager {
await this.automations[QUEUE_NAMES.SESSION_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule();
}
/**
@@ -228,6 +257,16 @@ class QueueManager {
].triggerManual();
}
async triggerDockerInventoryCleanup() {
return this.automations[
QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP
].triggerManual();
}
async triggerMetricsReporting() {
return this.automations[QUEUE_NAMES.METRICS_REPORTING].triggerManual();
}
/**
* Get queue statistics
*/

View File

@@ -0,0 +1,172 @@
const axios = require("axios");
const { prisma } = require("./shared/prisma");
const { updateSettings } = require("../../services/settingsService");
const METRICS_API_URL =
process.env.METRICS_API_URL || "https://metrics.patchmon.cloud";
/**
* Metrics Reporting Automation
* Sends anonymous usage metrics every 24 hours
*/
class MetricsReporting {
constructor(queueManager) {
this.queueManager = queueManager;
this.queueName = "metrics-reporting";
}
/**
* Process metrics reporting job
*/
async process(_job, silent = false) {
const startTime = Date.now();
if (!silent) console.log("📊 Starting metrics reporting...");
try {
// Fetch fresh settings directly from database (bypass cache)
const settings = await prisma.settings.findFirst({
orderBy: { updated_at: "desc" },
});
// Check if metrics are enabled
if (settings.metrics_enabled !== true) {
if (!silent) console.log("📊 Metrics reporting is disabled");
return { success: false, reason: "disabled" };
}
// Check if we have an anonymous ID
if (!settings.metrics_anonymous_id) {
if (!silent) console.log("📊 No anonymous ID found, skipping metrics");
return { success: false, reason: "no_id" };
}
// Get host count
const hostCount = await prisma.hosts.count();
// Get version
const packageJson = require("../../../package.json");
const version = packageJson.version;
// Prepare metrics data
const metricsData = {
anonymous_id: settings.metrics_anonymous_id,
host_count: hostCount,
version,
};
if (!silent)
console.log(
`📊 Sending metrics: ${hostCount} hosts, version ${version}`,
);
// Send to metrics API
try {
const response = await axios.post(
`${METRICS_API_URL}/metrics/submit`,
metricsData,
{
timeout: 10000,
headers: {
"Content-Type": "application/json",
},
},
);
// Update last sent timestamp
await updateSettings(settings.id, {
metrics_last_sent: new Date(),
});
const executionTime = Date.now() - startTime;
if (!silent)
console.log(
`✅ Metrics sent successfully in ${executionTime}ms:`,
response.data,
);
return {
success: true,
data: response.data,
hostCount,
version,
executionTime,
};
} catch (apiError) {
const executionTime = Date.now() - startTime;
if (!silent)
console.error(
`❌ Failed to send metrics to API after ${executionTime}ms:`,
apiError.message,
);
return {
success: false,
reason: "api_error",
error: apiError.message,
executionTime,
};
}
} catch (error) {
const executionTime = Date.now() - startTime;
if (!silent)
console.error(
`❌ Error in metrics reporting after ${executionTime}ms:`,
error.message,
);
// Don't throw on silent mode, just return failure
if (silent) {
return {
success: false,
reason: "error",
error: error.message,
executionTime,
};
}
throw error;
}
}
/**
* Schedule recurring metrics reporting (daily at 2 AM)
*/
async schedule() {
const job = await this.queueManager.queues[this.queueName].add(
"metrics-reporting",
{},
{
repeat: { cron: "0 2 * * *" }, // Daily at 2 AM
jobId: "metrics-reporting-recurring",
},
);
console.log("✅ Metrics reporting scheduled (daily at 2 AM)");
return job;
}
/**
* Trigger manual metrics reporting
*/
async triggerManual() {
const job = await this.queueManager.queues[this.queueName].add(
"metrics-reporting-manual",
{},
{ priority: 1 },
);
console.log("✅ Manual metrics reporting triggered");
return job;
}
/**
* Send metrics immediately (silent mode)
* Used for automatic sending on server startup
*/
async sendSilent() {
try {
const result = await this.process({ name: "startup-silent" }, true);
return result;
} catch (error) {
// Silent failure on startup
return { success: false, reason: "error", error: error.message };
}
}
}
module.exports = MetricsReporting;

View File

@@ -33,7 +33,8 @@ async function checkPublicRepo(owner, repo) {
try {
const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
let currentVersion = "1.3.0"; // fallback
// Get current version for User-Agent (or use generic if unavailable)
let currentVersion = "unknown";
try {
const packageJson = require("../../../package.json");
if (packageJson?.version) {
@@ -41,7 +42,7 @@ async function checkPublicRepo(owner, repo) {
}
} catch (packageError) {
console.warn(
"Could not read version from package.json for User-Agent, using fallback:",
"Could not read version from package.json for User-Agent:",
packageError.message,
);
}

View File

@@ -1,10 +1,13 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.3.0/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"includes": ["**", "!**/*.css"]
},
"formatter": {
"enabled": true
},

View File

@@ -136,6 +136,24 @@ When you do this, updating to a new version requires manually updating the image
| `PM_DB_CONN_MAX_ATTEMPTS` | Maximum database connection attempts | `30` |
| `PM_DB_CONN_WAIT_INTERVAL` | Wait interval between connection attempts in seconds | `2` |
##### Database Connection Pool Configuration (Prisma)
| Variable | Description | Default |
| --------------------- | ---------------------------------------------------------- | ------- |
| `DB_CONNECTION_LIMIT` | Maximum number of database connections per instance | `30` |
| `DB_POOL_TIMEOUT` | Seconds to wait for an available connection before timeout | `20` |
| `DB_CONNECT_TIMEOUT` | Seconds to wait for initial database connection | `10` |
| `DB_IDLE_TIMEOUT` | Seconds before closing idle connections | `300` |
| `DB_MAX_LIFETIME` | Maximum lifetime of a connection in seconds | `1800` |
> [!TIP]
> The connection pool limit should be adjusted based on your deployment size:
> - **Small deployment (1-10 hosts)**: `DB_CONNECTION_LIMIT=15` is sufficient
> - **Medium deployment (10-50 hosts)**: `DB_CONNECTION_LIMIT=30` (default)
> - **Large deployment (50+ hosts)**: `DB_CONNECTION_LIMIT=50` or higher
>
> Each connection pool serves one backend instance. If you have concurrent operations (multiple users, background jobs, agent checkins), increase the pool size accordingly.
##### Redis Configuration
| Variable | Description | Default |

View File

@@ -46,8 +46,10 @@ COPY --chown=node:node backend/ ./backend/
WORKDIR /app/backend
RUN npm ci --ignore-scripts &&\
npx prisma generate &&\
RUN npm cache clean --force &&\
rm -rf node_modules ~/.npm /root/.npm &&\
npm ci --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=0 &&\
PRISMA_CLI_BINARY_TYPE=binary npm run db:generate &&\
npm prune --omit=dev &&\
npm cache clean --force

View File

@@ -50,6 +50,12 @@ services:
SERVER_HOST: localhost
SERVER_PORT: 3000
CORS_ORIGIN: http://localhost:3000
# Database Connection Pool Configuration (Prisma)
DB_CONNECTION_LIMIT: 30
DB_POOL_TIMEOUT: 20
DB_CONNECT_TIMEOUT: 10
DB_IDLE_TIMEOUT: 300
DB_MAX_LIFETIME: 1800
# Rate Limiting (times in milliseconds)
RATE_LIMIT_WINDOW_MS: 900000
RATE_LIMIT_MAX: 5000

View File

@@ -56,6 +56,12 @@ services:
SERVER_HOST: localhost
SERVER_PORT: 3000
CORS_ORIGIN: http://localhost:3000
# Database Connection Pool Configuration (Prisma)
DB_CONNECTION_LIMIT: 30
DB_POOL_TIMEOUT: 20
DB_CONNECT_TIMEOUT: 10
DB_IDLE_TIMEOUT: 300
DB_MAX_LIFETIME: 1800
# Rate Limiting (times in milliseconds)
RATE_LIMIT_WINDOW_MS: 900000
RATE_LIMIT_MAX: 5000

View File

@@ -17,16 +17,17 @@ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
# Builder stage for production
FROM node:lts-alpine AS builder
WORKDIR /app
WORKDIR /app/frontend
COPY package*.json ./
COPY frontend/package*.json ./frontend/
COPY frontend/package*.json ./
RUN npm ci --ignore-scripts
RUN npm cache clean --force &&\
rm -rf node_modules ~/.npm /root/.npm &&\
npm install --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=0
COPY frontend/ ./frontend/
COPY frontend/ ./
RUN npm run build:frontend
RUN npm run build
# Production stage
FROM nginxinc/nginx-unprivileged:alpine

View File

@@ -6,5 +6,5 @@ VITE_API_URL=http://localhost:3001/api/v1
# Application Metadata
VITE_APP_NAME=PatchMon
VITE_APP_VERSION=1.3.0
VITE_APP_VERSION=1.3.1

View File

@@ -1,7 +1,7 @@
{
"name": "patchmon-frontend",
"private": true,
"version": "1.3.0",
"version": "1.3.1",
"license": "AGPL-3.0",
"type": "module",
"scripts": {
@@ -27,7 +27,8 @@
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1",
"react-icons": "^5.5.0",
"react-router-dom": "^6.30.1"
"react-router-dom": "^6.30.1",
"trianglify": "^4.1.1"
},
"devDependencies": {
"@types/react": "^18.3.14",

View File

@@ -7,6 +7,7 @@ import ProtectedRoute from "./components/ProtectedRoute";
import SettingsLayout from "./components/SettingsLayout";
import { isAuthPhase } from "./constants/authPhases";
import { AuthProvider, useAuth } from "./contexts/AuthContext";
import { ColorThemeProvider } from "./contexts/ColorThemeContext";
import { ThemeProvider } from "./contexts/ThemeContext";
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
@@ -41,6 +42,7 @@ const SettingsServerConfig = lazy(
() => import("./pages/settings/SettingsServerConfig"),
);
const SettingsUsers = lazy(() => import("./pages/settings/SettingsUsers"));
const SettingsMetrics = lazy(() => import("./pages/settings/SettingsMetrics"));
// Loading fallback component
const LoadingFallback = () => (
@@ -388,6 +390,16 @@ function AppRoutes() {
</ProtectedRoute>
}
/>
<Route
path="/settings/metrics"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsMetrics />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/options"
element={
@@ -416,13 +428,15 @@ function AppRoutes() {
function App() {
return (
<ThemeProvider>
<AuthProvider>
<UpdateNotificationProvider>
<LogoProvider>
<AppRoutes />
</LogoProvider>
</UpdateNotificationProvider>
</AuthProvider>
<ColorThemeProvider>
<AuthProvider>
<UpdateNotificationProvider>
<LogoProvider>
<AppRoutes />
</LogoProvider>
</UpdateNotificationProvider>
</AuthProvider>
</ColorThemeProvider>
</ThemeProvider>
);
}

View File

@@ -26,9 +26,11 @@ import {
Zap,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { FaYoutube } from "react-icons/fa";
import { FaReddit, FaYoutube } from "react-icons/fa";
import { Link, useLocation, useNavigate } from "react-router-dom";
import trianglify from "trianglify";
import { useAuth } from "../contexts/AuthContext";
import { useColorTheme } from "../contexts/ColorThemeContext";
import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
import { dashboardAPI, versionAPI } from "../utils/api";
import DiscordIcon from "./DiscordIcon";
@@ -61,7 +63,9 @@ const Layout = ({ children }) => {
canManageSettings,
} = useAuth();
const { updateAvailable } = useUpdateNotification();
const { themeConfig } = useColorTheme();
const userMenuRef = useRef(null);
const bgCanvasRef = useRef(null);
// Fetch dashboard stats for the "Last updated" info
const {
@@ -233,27 +237,103 @@ const Layout = ({ children }) => {
navigate("/hosts?action=add");
};
// Generate Trianglify background for dark mode
useEffect(() => {
const generateBackground = () => {
if (
bgCanvasRef.current &&
themeConfig?.login &&
document.documentElement.classList.contains("dark")
) {
// Get current date as seed for daily variation
const today = new Date();
const dateSeed = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
// Generate pattern with selected theme configuration
const pattern = trianglify({
width: window.innerWidth,
height: window.innerHeight,
cellSize: themeConfig.login.cellSize,
variance: themeConfig.login.variance,
seed: dateSeed,
xColors: themeConfig.login.xColors,
yColors: themeConfig.login.yColors,
});
// Render to canvas
pattern.toCanvas(bgCanvasRef.current);
}
};
generateBackground();
// Regenerate on window resize or theme change
const handleResize = () => {
generateBackground();
};
window.addEventListener("resize", handleResize);
// Watch for dark mode changes
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === "class") {
generateBackground();
}
});
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => {
window.removeEventListener("resize", handleResize);
observer.disconnect();
};
}, [themeConfig]);
// Fetch GitHub stars count
const fetchGitHubStars = useCallback(async () => {
// Skip if already fetched recently
// Try to load cached star count first
const cachedStars = localStorage.getItem("githubStarsCount");
if (cachedStars) {
setGithubStars(parseInt(cachedStars, 10));
}
// Skip API call if fetched recently
const lastFetch = localStorage.getItem("githubStarsFetchTime");
const now = Date.now();
if (lastFetch && now - parseInt(lastFetch, 15) < 600000) {
// 15 minute cache
if (lastFetch && now - parseInt(lastFetch, 10) < 600000) {
// 10 minute cache
return;
}
try {
const response = await fetch(
"https://api.github.com/repos/9technologygroup/patchmon.net",
{
headers: {
Accept: "application/vnd.github.v3+json",
},
},
);
if (response.ok) {
const data = await response.json();
setGithubStars(data.stargazers_count);
localStorage.setItem(
"githubStarsCount",
data.stargazers_count.toString(),
);
localStorage.setItem("githubStarsFetchTime", now.toString());
} else if (response.status === 403 || response.status === 429) {
console.warn("GitHub API rate limit exceeded, using cached value");
}
} catch (error) {
console.error("Failed to fetch GitHub stars:", error);
// Keep using cached value if available
}
}, []);
@@ -303,11 +383,76 @@ const Layout = ({ children }) => {
fetchGitHubStars();
}, [fetchGitHubStars]);
// Set CSS custom properties for glassmorphism and theme colors in dark mode
useEffect(() => {
const updateThemeStyles = () => {
const isDark = document.documentElement.classList.contains("dark");
const root = document.documentElement;
if (isDark && themeConfig?.app) {
// Glass navigation bars - very light for pattern visibility
root.style.setProperty("--sidebar-bg", "rgba(0, 0, 0, 0.15)");
root.style.setProperty("--sidebar-blur", "blur(12px)");
root.style.setProperty("--topbar-bg", "rgba(0, 0, 0, 0.15)");
root.style.setProperty("--topbar-blur", "blur(12px)");
root.style.setProperty("--button-bg", "rgba(255, 255, 255, 0.15)");
root.style.setProperty("--button-blur", "blur(8px)");
// Theme-colored cards and buttons - darker to stand out
root.style.setProperty("--card-bg", themeConfig.app.cardBg);
root.style.setProperty("--card-border", themeConfig.app.cardBorder);
root.style.setProperty("--card-bg-hover", themeConfig.app.bgTertiary);
root.style.setProperty("--theme-button-bg", themeConfig.app.buttonBg);
root.style.setProperty(
"--theme-button-hover",
themeConfig.app.buttonHover,
);
} else {
// Light mode - standard colors
root.style.setProperty("--sidebar-bg", "white");
root.style.setProperty("--sidebar-blur", "none");
root.style.setProperty("--topbar-bg", "white");
root.style.setProperty("--topbar-blur", "none");
root.style.setProperty("--button-bg", "white");
root.style.setProperty("--button-blur", "none");
root.style.setProperty("--card-bg", "white");
root.style.setProperty("--card-border", "#e5e7eb");
root.style.setProperty("--card-bg-hover", "#f9fafb");
root.style.setProperty("--theme-button-bg", "#f3f4f6");
root.style.setProperty("--theme-button-hover", "#e5e7eb");
}
};
updateThemeStyles();
// Watch for dark mode changes
const observer = new MutationObserver(() => {
updateThemeStyles();
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, [themeConfig]);
return (
<div className="min-h-screen bg-secondary-50">
<div className="min-h-screen bg-secondary-50 dark:bg-black relative overflow-hidden">
{/* Full-screen Trianglify Background (Dark Mode Only) */}
<canvas
ref={bgCanvasRef}
className="fixed inset-0 w-full h-full hidden dark:block"
style={{ zIndex: 0 }}
/>
<div
className="fixed inset-0 bg-gradient-to-br from-black/10 to-black/20 hidden dark:block pointer-events-none"
style={{ zIndex: 1 }}
/>
{/* Mobile sidebar */}
<div
className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? "block" : "hidden"}`}
className={`fixed inset-0 z-[60] lg:hidden ${sidebarOpen ? "block" : "hidden"}`}
>
<button
type="button"
@@ -315,7 +460,14 @@ const Layout = ({ children }) => {
onClick={() => setSidebarOpen(false)}
aria-label="Close sidebar"
/>
<div className="relative flex w-full max-w-[280px] flex-col bg-white dark:bg-secondary-800 pb-4 pt-5 shadow-xl">
<div
className="relative flex w-full max-w-[280px] flex-col bg-white dark:border-r dark:border-white/10 pb-4 pt-5 shadow-xl"
style={{
backgroundColor: "var(--sidebar-bg, white)",
backdropFilter: "var(--sidebar-blur, none)",
WebkitBackdropFilter: "var(--sidebar-blur, none)",
}}
>
<div className="absolute right-0 top-0 -mr-12 pt-2">
<button
type="button"
@@ -534,17 +686,43 @@ const Layout = ({ children }) => {
{/* Desktop sidebar */}
<div
className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 relative ${
className={`hidden lg:fixed lg:inset-y-0 z-[100] lg:flex lg:flex-col transition-all duration-300 relative ${
sidebarCollapsed ? "lg:w-16" : "lg:w-56"
} bg-white dark:bg-secondary-800`}
} bg-white dark:bg-transparent`}
>
{/* Collapse/Expand button on border */}
<button
type="button"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="absolute top-5 -right-3 z-[200] flex items-center justify-center w-6 h-6 rounded-full bg-white border border-secondary-300 dark:border-white/20 shadow-md hover:bg-secondary-50 transition-colors"
style={{
backgroundColor: "var(--button-bg, white)",
backdropFilter: "var(--button-blur, none)",
WebkitBackdropFilter: "var(--button-blur, none)",
}}
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{sidebarCollapsed ? (
<ChevronRight className="h-4 w-4 text-secondary-700 dark:text-white" />
) : (
<ChevronLeft className="h-4 w-4 text-secondary-700 dark:text-white" />
)}
</button>
<div
className={`flex grow flex-col gap-y-5 overflow-y-auto border-r border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 ${
className={`flex grow flex-col gap-y-5 border-r border-secondary-200 dark:border-white/10 bg-white ${
sidebarCollapsed ? "px-2 shadow-lg" : "px-6"
}`}
style={{
backgroundColor: "var(--sidebar-bg, white)",
backdropFilter: "var(--sidebar-blur, none)",
WebkitBackdropFilter: "var(--sidebar-blur, none)",
overflowY: "auto",
overflowX: "visible",
}}
>
<div
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 dark:border-secondary-600 ${
className={`flex h-16 shrink-0 items-center border-b border-secondary-200 dark:border-white/10 ${
sidebarCollapsed ? "justify-center" : "justify-center"
}`}
>
@@ -562,19 +740,6 @@ const Layout = ({ children }) => {
</Link>
)}
</div>
{/* Collapse/Expand button on border */}
<button
type="button"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="absolute top-5 -right-3 z-10 flex items-center justify-center w-6 h-6 rounded-full bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 shadow-md hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{sidebarCollapsed ? (
<ChevronRight className="h-4 w-4 text-secondary-700 dark:text-white" />
) : (
<ChevronLeft className="h-4 w-4 text-secondary-700 dark:text-white" />
)}
</button>
<nav className="flex flex-1 flex-col">
<ul className="flex flex-1 flex-col gap-y-6">
{/* Show message for users with very limited permissions */}
@@ -930,12 +1095,19 @@ const Layout = ({ children }) => {
{/* Main content */}
<div
className={`flex flex-col min-h-screen transition-all duration-300 ${
className={`flex flex-col min-h-screen transition-all duration-300 relative z-10 ${
sidebarCollapsed ? "lg:pl-16" : "lg:pl-56"
}`}
>
{/* Top bar */}
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
<div
className="sticky top-0 z-[90] flex h-16 shrink-0 items-center gap-x-4 border-b border-secondary-200 dark:border-white/10 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8"
style={{
backgroundColor: "var(--topbar-bg, white)",
backdropFilter: "var(--topbar-blur, none)",
WebkitBackdropFilter: "var(--topbar-blur, none)",
}}
>
<button
type="button"
className="-m-2.5 p-2.5 text-secondary-700 dark:text-white lg:hidden"
@@ -987,8 +1159,8 @@ const Layout = ({ children }) => {
>
<Github className="h-5 w-5 flex-shrink-0" />
{githubStars !== null && (
<div className="flex items-center gap-0.5">
<Star className="h-3 w-3 fill-current text-yellow-500" />
<div className="flex items-center gap-1">
<Star className="h-4 w-4 fill-current text-yellow-500" />
<span className="text-sm font-medium">{githubStars}</span>
</div>
)}
@@ -1059,7 +1231,17 @@ const Layout = ({ children }) => {
>
<FaYoutube className="h-5 w-5" />
</a>
{/* 7) Web */}
{/* 8) Reddit */}
<a
href="https://www.reddit.com/r/patchmon"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
title="Reddit Community"
>
<FaReddit className="h-5 w-5" />
</a>
{/* 9) Web */}
<a
href="https://patchmon.net"
target="_blank"
@@ -1074,7 +1256,7 @@ const Layout = ({ children }) => {
</div>
</div>
<main className="flex-1 py-6 bg-secondary-50 dark:bg-secondary-800">
<main className="flex-1 py-6 bg-secondary-50 dark:bg-transparent">
<div className="px-4 sm:px-6 lg:px-8">{children}</div>
</main>
</div>

View File

@@ -1,4 +1,5 @@
import {
BarChart3,
Bell,
ChevronLeft,
ChevronRight,
@@ -141,6 +142,11 @@ const SettingsLayout = ({ children }) => {
href: "/settings/server-version",
icon: Code,
},
{
name: "Metrics",
href: "/settings/metrics",
icon: BarChart3,
},
],
});
}

View File

@@ -1,6 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, Image, RotateCcw, Upload, X } from "lucide-react";
import {
AlertCircle,
Image,
Palette,
RotateCcw,
Upload,
X,
} from "lucide-react";
import { useState } from "react";
import { THEME_PRESETS, useColorTheme } from "../../contexts/ColorThemeContext";
import { settingsAPI } from "../../utils/api";
const BrandingTab = () => {
@@ -12,6 +20,7 @@ const BrandingTab = () => {
});
const [showLogoUploadModal, setShowLogoUploadModal] = useState(false);
const [selectedLogoType, setSelectedLogoType] = useState("dark");
const { colorTheme, setColorTheme } = useColorTheme();
const queryClient = useQueryClient();
@@ -75,6 +84,22 @@ const BrandingTab = () => {
},
});
// Theme update mutation
const updateThemeMutation = useMutation({
mutationFn: (theme) => settingsAPI.update({ colorTheme: theme }),
onSuccess: (_data, theme) => {
queryClient.invalidateQueries(["settings"]);
setColorTheme(theme);
},
onError: (error) => {
console.error("Update theme error:", error);
},
});
const handleThemeChange = (theme) => {
updateThemeMutation.mutate(theme);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -102,17 +127,110 @@ const BrandingTab = () => {
}
return (
<div className="space-y-6">
<div className="flex items-center mb-6">
<Image className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Logo & Branding
</h2>
<div className="space-y-8">
{/* Header */}
<div>
<div className="flex items-center mb-6">
<Image className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Logo & Branding
</h2>
</div>
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
Customize your PatchMon installation with custom logos, favicon, and
color themes. These will be displayed throughout the application.
</p>
</div>
{/* Color Theme Selector */}
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center mb-4">
<Palette className="h-5 w-5 text-primary-600 mr-2" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Color Theme
</h3>
</div>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
Choose a color theme that will be applied to the login page and
background areas throughout the app.
</p>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
{Object.entries(THEME_PRESETS).map(([themeKey, theme]) => {
const isSelected = colorTheme === themeKey;
const gradientColors = theme.login.xColors;
return (
<button
key={themeKey}
type="button"
onClick={() => handleThemeChange(themeKey)}
disabled={updateThemeMutation.isPending}
className={`relative p-4 rounded-lg border-2 transition-all ${
isSelected
? "border-primary-500 ring-2 ring-primary-200 dark:ring-primary-800"
: "border-secondary-200 dark:border-secondary-600 hover:border-primary-300"
} ${updateThemeMutation.isPending ? "opacity-50 cursor-not-allowed" : "cursor-pointer"}`}
>
{/* Theme Preview */}
<div
className="h-20 rounded-md mb-3 overflow-hidden"
style={{
background: `linear-gradient(135deg, ${gradientColors.join(", ")})`,
}}
/>
{/* Theme Name */}
<div className="text-sm font-medium text-secondary-900 dark:text-white mb-1">
{theme.name}
</div>
{/* Selected Indicator */}
{isSelected && (
<div className="absolute top-2 right-2 bg-primary-500 text-white rounded-full p-1">
<svg
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
aria-label="Selected theme"
>
<title>Selected</title>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
)}
</button>
);
})}
</div>
{updateThemeMutation.isPending && (
<div className="mt-4 flex items-center gap-2 text-sm text-primary-600 dark:text-primary-400">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
Updating theme...
</div>
)}
{updateThemeMutation.isError && (
<div className="mt-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
<p className="text-sm text-red-800 dark:text-red-200">
Failed to update theme: {updateThemeMutation.error?.message}
</p>
</div>
)}
</div>
{/* Logo Section Header */}
<div className="flex items-center mb-4">
<Image className="h-5 w-5 text-primary-600 mr-2" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Logos
</h3>
</div>
<p className="text-sm text-secondary-500 dark:text-secondary-300 mb-6">
Customize your PatchMon installation with custom logos and favicon.
These will be displayed throughout the application.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Dark Logo */}

View File

@@ -54,7 +54,7 @@ const UsersTab = () => {
});
// Update user mutation
const _updateUserMutation = useMutation({
const updateUserMutation = useMutation({
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries(["users"]);

View File

@@ -0,0 +1,194 @@
import { createContext, useContext, useEffect, useState } from "react";
const ColorThemeContext = createContext();
// Theme configurations matching the login backgrounds
export const THEME_PRESETS = {
default: {
name: "Normal Dark",
login: {
cellSize: 90,
variance: 0.85,
xColors: ["#0f172a", "#1e293b", "#334155", "#475569", "#64748b"],
yColors: ["#0f172a", "#1e293b", "#334155", "#475569", "#64748b"],
},
app: {
bgPrimary: "#1e293b",
bgSecondary: "#1e293b",
bgTertiary: "#334155",
borderColor: "#475569",
cardBg: "#1e293b",
cardBorder: "#334155",
buttonBg: "#334155",
buttonHover: "#475569",
},
},
cyber_blue: {
name: "Cyber Blue",
login: {
cellSize: 90,
variance: 0.85,
xColors: ["#0a0820", "#1a1f3a", "#2d3561", "#4a5584", "#667eaf"],
yColors: ["#0a0820", "#1a1f3a", "#2d3561", "#4a5584", "#667eaf"],
},
app: {
bgPrimary: "#0a0820",
bgSecondary: "#1a1f3a",
bgTertiary: "#2d3561",
borderColor: "#4a5584",
cardBg: "#1a1f3a",
cardBorder: "#2d3561",
buttonBg: "#2d3561",
buttonHover: "#4a5584",
},
},
neon_purple: {
name: "Neon Purple",
login: {
cellSize: 80,
variance: 0.9,
xColors: ["#0f0a1e", "#1e0f3e", "#4a0082", "#7209b7", "#b5179e"],
yColors: ["#0f0a1e", "#1e0f3e", "#4a0082", "#7209b7", "#b5179e"],
},
app: {
bgPrimary: "#0f0a1e",
bgSecondary: "#1e0f3e",
bgTertiary: "#4a0082",
borderColor: "#7209b7",
cardBg: "#1e0f3e",
cardBorder: "#4a0082",
buttonBg: "#4a0082",
buttonHover: "#7209b7",
},
},
matrix_green: {
name: "Matrix Green",
login: {
cellSize: 70,
variance: 0.7,
xColors: ["#001a00", "#003300", "#004d00", "#006600", "#00b300"],
yColors: ["#001a00", "#003300", "#004d00", "#006600", "#00b300"],
},
app: {
bgPrimary: "#001a00",
bgSecondary: "#003300",
bgTertiary: "#004d00",
borderColor: "#006600",
cardBg: "#003300",
cardBorder: "#004d00",
buttonBg: "#004d00",
buttonHover: "#006600",
},
},
ocean_blue: {
name: "Ocean Blue",
login: {
cellSize: 85,
variance: 0.8,
xColors: ["#001845", "#023e7d", "#0077b6", "#0096c7", "#00b4d8"],
yColors: ["#001845", "#023e7d", "#0077b6", "#0096c7", "#00b4d8"],
},
app: {
bgPrimary: "#001845",
bgSecondary: "#023e7d",
bgTertiary: "#0077b6",
borderColor: "#0096c7",
cardBg: "#023e7d",
cardBorder: "#0077b6",
buttonBg: "#0077b6",
buttonHover: "#0096c7",
},
},
sunset_gradient: {
name: "Sunset Gradient",
login: {
cellSize: 95,
variance: 0.75,
xColors: ["#1a0033", "#330066", "#4d0099", "#6600cc", "#9933ff"],
yColors: ["#1a0033", "#660033", "#990033", "#cc0066", "#ff0099"],
},
app: {
bgPrimary: "#1a0033",
bgSecondary: "#330066",
bgTertiary: "#4d0099",
borderColor: "#6600cc",
cardBg: "#330066",
cardBorder: "#4d0099",
buttonBg: "#4d0099",
buttonHover: "#6600cc",
},
},
};
export const ColorThemeProvider = ({ children }) => {
const [colorTheme, setColorTheme] = useState("default");
const [isLoading, setIsLoading] = useState(true);
// Fetch theme from settings on mount
useEffect(() => {
const fetchTheme = async () => {
try {
// Check localStorage first for unauthenticated pages (login)
const cachedTheme = localStorage.getItem("colorTheme");
if (cachedTheme) {
setColorTheme(cachedTheme);
}
// Try to fetch from API (will fail on login page, that's ok)
try {
const token = localStorage.getItem("token");
if (token) {
const response = await fetch("/api/v1/settings", {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
if (data.color_theme) {
setColorTheme(data.color_theme);
localStorage.setItem("colorTheme", data.color_theme);
}
}
}
} catch (_apiError) {
// Silent fail - use cached or default theme
console.log("Could not fetch theme from API, using cached/default");
}
} catch (error) {
console.error("Error loading color theme:", error);
} finally {
setIsLoading(false);
}
};
fetchTheme();
}, []);
const updateColorTheme = (theme) => {
setColorTheme(theme);
localStorage.setItem("colorTheme", theme);
};
const value = {
colorTheme,
setColorTheme: updateColorTheme,
themeConfig: THEME_PRESETS[colorTheme] || THEME_PRESETS.default,
isLoading,
};
return (
<ColorThemeContext.Provider value={value}>
{children}
</ColorThemeContext.Provider>
);
};
export const useColorTheme = () => {
const context = useContext(ColorThemeContext);
if (!context) {
throw new Error("useColorTheme must be used within ColorThemeProvider");
}
return context;
};

View File

@@ -9,7 +9,7 @@
}
body {
@apply bg-secondary-50 dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 antialiased;
@apply bg-secondary-50 dark:bg-transparent text-secondary-900 dark:text-secondary-100 antialiased;
}
}
@@ -39,19 +39,46 @@
}
.btn-outline {
@apply btn border-secondary-300 dark:border-secondary-600 text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-secondary-500;
@apply btn border-secondary-300 text-secondary-700 bg-white hover:bg-secondary-50 focus:ring-secondary-500;
}
.dark .btn-outline {
background-color: var(--theme-button-bg, #1e293b);
border-color: var(--card-border, #334155);
color: white;
}
.dark .btn-outline:hover {
background-color: var(--theme-button-hover, #334155);
}
.card {
@apply bg-white dark:bg-secondary-800 rounded-lg shadow-card dark:shadow-card-dark border border-secondary-200 dark:border-secondary-600;
@apply bg-white rounded-lg shadow-card border border-secondary-200;
}
.dark .card {
background-color: var(--card-bg, #1e293b);
border-color: var(--card-border, #334155);
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
.card-hover {
@apply card hover:shadow-card-hover transition-shadow duration-150;
@apply card transition-all duration-150;
}
.dark .card-hover:hover {
background-color: var(--card-bg-hover, #334155);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
}
.input {
@apply block w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100;
@apply block w-full px-3 py-2 border border-secondary-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white text-secondary-900;
}
.dark .input {
background-color: var(--card-bg, #1e293b);
border-color: var(--card-border, #334155);
color: white;
}
.label {
@@ -84,6 +111,27 @@
}
@layer utilities {
/* Theme-aware backgrounds for general elements */
.dark .bg-secondary-800 {
background-color: var(--card-bg, #1e293b) !important;
}
.dark .bg-secondary-700 {
background-color: var(--card-bg-hover, #334155) !important;
}
.dark .bg-secondary-900 {
background-color: var(--theme-button-bg, #1e293b) !important;
}
.dark .border-secondary-600 {
border-color: var(--card-border, #334155) !important;
}
.dark .border-secondary-700 {
border-color: var(--theme-button-hover, #475569) !important;
}
.text-shadow {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}

View File

@@ -169,6 +169,20 @@ const Automation = () => {
year: "numeric",
});
}
if (schedule === "Daily at 4 AM") {
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(4, 0, 0, 0);
return tomorrow.toLocaleString([], {
hour12: true,
hour: "numeric",
minute: "2-digit",
day: "numeric",
month: "numeric",
year: "numeric",
});
}
if (schedule === "Every hour") {
const now = new Date();
const nextHour = new Date(now);
@@ -209,6 +223,13 @@ const Automation = () => {
tomorrow.setHours(3, 0, 0, 0);
return tomorrow.getTime();
}
if (schedule === "Daily at 4 AM") {
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(4, 0, 0, 0);
return tomorrow.getTime();
}
if (schedule === "Every hour") {
const now = new Date();
const nextHour = new Date(now);
@@ -269,6 +290,8 @@ const Automation = () => {
endpoint = "/automation/trigger/orphaned-repo-cleanup";
} else if (jobType === "orphaned-packages") {
endpoint = "/automation/trigger/orphaned-package-cleanup";
} else if (jobType === "docker-inventory") {
endpoint = "/automation/trigger/docker-inventory-cleanup";
} else if (jobType === "agent-collection") {
endpoint = "/automation/trigger/agent-collection";
}
@@ -584,6 +607,10 @@ const Automation = () => {
automation.queue.includes("orphaned-package")
) {
triggerManualJob("orphaned-packages");
} else if (
automation.queue.includes("docker-inventory")
) {
triggerManualJob("docker-inventory");
} else if (
automation.queue.includes("agent-commands")
) {

View File

@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowDown,
@@ -11,6 +11,7 @@ import {
Search,
Server,
Shield,
Trash2,
X,
} from "lucide-react";
import { useMemo, useState } from "react";
@@ -18,12 +19,15 @@ import { Link } from "react-router-dom";
import api from "../utils/api";
const Docker = () => {
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [activeTab, setActiveTab] = useState("containers");
const [sortField, setSortField] = useState("status");
const [sortDirection, setSortDirection] = useState("asc");
const [statusFilter, setStatusFilter] = useState("all");
const [sourceFilter, setSourceFilter] = useState("all");
const [deleteContainerModal, setDeleteContainerModal] = useState(null);
const [deleteImageModal, setDeleteImageModal] = useState(null);
// Fetch Docker dashboard data
const { data: dashboard, isLoading: dashboardLoading } = useQuery({
@@ -36,7 +40,11 @@ const Docker = () => {
});
// Fetch containers
const { data: containersData, isLoading: containersLoading } = useQuery({
const {
data: containersData,
isLoading: containersLoading,
refetch: refetchContainers,
} = useQuery({
queryKey: ["docker", "containers", statusFilter],
queryFn: async () => {
const params = new URLSearchParams();
@@ -49,7 +57,11 @@ const Docker = () => {
});
// Fetch images
const { data: imagesData, isLoading: imagesLoading } = useQuery({
const {
data: imagesData,
isLoading: imagesLoading,
refetch: refetchImages,
} = useQuery({
queryKey: ["docker", "images", sourceFilter],
queryFn: async () => {
const params = new URLSearchParams();
@@ -81,6 +93,42 @@ const Docker = () => {
enabled: activeTab === "updates",
});
// Delete container mutation
const deleteContainerMutation = useMutation({
mutationFn: async (containerId) => {
const response = await api.delete(`/docker/containers/${containerId}`);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries(["docker", "containers"]);
queryClient.invalidateQueries(["docker", "dashboard"]);
setDeleteContainerModal(null);
},
onError: (error) => {
alert(
`Failed to delete container: ${error.response?.data?.error || error.message}`,
);
},
});
// Delete image mutation
const deleteImageMutation = useMutation({
mutationFn: async (imageId) => {
const response = await api.delete(`/docker/images/${imageId}`);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries(["docker", "images"]);
queryClient.invalidateQueries(["docker", "dashboard"]);
setDeleteImageModal(null);
},
onError: (error) => {
alert(
`Failed to delete image: ${error.response?.data?.error || error.message}`,
);
},
});
// Filter and sort containers
const filteredContainers = useMemo(() => {
if (!containersData?.containers) return [];
@@ -288,32 +336,36 @@ const Docker = () => {
};
return (
<div className="space-y-6">
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex justify-between items-center">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
Docker Inventory
</h1>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Monitor containers, images, and updates across your infrastructure
</p>
</div>
<button
type="button"
onClick={() => {
// Trigger refresh of all queries
window.location.reload();
}}
className="inline-flex items-center px-4 py-2 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"
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</button>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
// Trigger refresh based on active tab
if (activeTab === "containers") refetchContainers();
else if (activeTab === "images") refetchImages();
else window.location.reload();
}}
className="btn-outline flex items-center justify-center p-2"
title="Refresh data"
>
<RefreshCw className="h-4 w-4" />
</button>
</div>
</div>
{/* Dashboard Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
{/* Stats Summary */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
@@ -400,11 +452,11 @@ const Docker = () => {
</div>
</div>
{/* Tabs and Content */}
<div className="card">
{/* Docker List */}
<div className="card flex-1 flex flex-col overflow-hidden min-h-0">
{/* Tab Navigation */}
<div className="border-b border-secondary-200 dark:border-secondary-700">
<nav className="-mb-px flex space-x-8 px-6" aria-label="Tabs">
<div className="border-b border-secondary-200 dark:border-secondary-600">
<nav className="-mb-px flex space-x-8 px-4" aria-label="Tabs">
{[
{ id: "containers", label: "Containers", icon: Container },
{ id: "images", label: "Images", icon: Package },
@@ -443,7 +495,7 @@ const Docker = () => {
</div>
{/* Filters and Search */}
<div className="p-6 border-b border-secondary-200 dark:border-secondary-700">
<div className="p-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative">
@@ -498,7 +550,7 @@ const Docker = () => {
</div>
{/* Tab Content */}
<div className="p-6">
<div className="p-4 flex-1 overflow-auto">
{/* Containers Tab */}
{activeTab === "containers" && (
<div className="overflow-x-auto">
@@ -522,83 +574,80 @@ const Docker = () => {
</p>
</div>
) : (
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
<thead className="bg-secondary-50 dark:bg-secondary-900">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
onClick={() => handleSort("name")}
>
<div className="flex items-center gap-2">
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("name")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Container Name
{getSortIcon("name")}
</div>
</button>
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
onClick={() => handleSort("image")}
>
<div className="flex items-center gap-2">
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("image")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Image
{getSortIcon("image")}
</div>
</button>
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
onClick={() => handleSort("status")}
>
<div className="flex items-center gap-2">
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("status")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Status
{getSortIcon("status")}
</div>
</button>
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
onClick={() => handleSort("host")}
>
<div className="flex items-center gap-2">
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("host")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Host
{getSortIcon("host")}
</div>
</button>
</th>
<th
scope="col"
className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredContainers.map((container) => (
<tr
key={container.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Container className="h-5 w-5 text-secondary-400 mr-3" />
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center gap-2">
<Container className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
<Link
to={`/docker/containers/${container.id}`}
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
>
{container.name}
</Link>
</div>
</td>
<td className="px-6 py-4">
<td className="px-4 py-2">
<div className="text-sm text-secondary-900 dark:text-white">
{container.image_name}:{container.image_tag}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<td className="px-4 py-2 whitespace-nowrap text-center">
{getStatusBadge(container.status)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<td className="px-4 py-2 whitespace-nowrap">
<Link
to={`/hosts/${container.host_id}`}
className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
@@ -608,14 +657,24 @@ const Docker = () => {
"Unknown"}
</Link>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link
to={`/docker/containers/${container.id}`}
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
>
View
<ExternalLink className="ml-1 h-4 w-4" />
</Link>
<td className="px-4 py-2 whitespace-nowrap text-center">
<div className="flex items-center justify-center gap-3">
<Link
to={`/docker/containers/${container.id}`}
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1"
title="View details"
>
<ExternalLink className="h-4 w-4" />
</Link>
<button
type="button"
onClick={() => setDeleteContainerModal(container)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 inline-flex items-center"
title="Delete container from inventory"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
@@ -648,88 +707,79 @@ const Docker = () => {
</p>
</div>
) : (
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
<thead className="bg-secondary-50 dark:bg-secondary-900">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
onClick={() => handleSort("repository")}
>
<div className="flex items-center gap-2">
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("repository")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Repository
{getSortIcon("repository")}
</div>
</button>
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
onClick={() => handleSort("tag")}
>
<div className="flex items-center gap-2">
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("tag")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Tag
{getSortIcon("tag")}
</div>
</button>
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Source
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
onClick={() => handleSort("containers")}
>
<div className="flex items-center gap-2">
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("containers")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Containers
{getSortIcon("containers")}
</div>
</button>
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Updates
</th>
<th
scope="col"
className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredImages.map((image) => (
<tr
key={image.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Package className="h-5 w-5 text-secondary-400 mr-3" />
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
<Link
to={`/docker/images/${image.id}`}
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
>
{image.repository}
</Link>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<td className="px-4 py-2 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
{image.tag}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<td className="px-4 py-2 whitespace-nowrap text-center">
{getSourceBadge(image.source)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
{image._count?.docker_containers || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<td className="px-4 py-2 whitespace-nowrap text-center">
{image.hasUpdates ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
<AlertTriangle className="h-3 w-3 mr-1" />
@@ -741,14 +791,24 @@ const Docker = () => {
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<Link
to={`/docker/images/${image.id}`}
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
>
View
<ExternalLink className="ml-1 h-4 w-4" />
</Link>
<td className="px-4 py-2 whitespace-nowrap text-center">
<div className="flex items-center justify-center gap-3">
<Link
to={`/docker/images/${image.id}`}
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
title="View details"
>
<ExternalLink className="h-4 w-4" />
</Link>
<button
type="button"
onClick={() => setDeleteImageModal(image)}
className="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300 inline-flex items-center"
title="Delete image from inventory"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
@@ -781,86 +841,80 @@ const Docker = () => {
</p>
</div>
) : (
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
<thead className="bg-secondary-50 dark:bg-secondary-900">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
onClick={() => handleSort("name")}
>
<div className="flex items-center gap-2">
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("name")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Host Name
{getSortIcon("name")}
</div>
</button>
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
onClick={() => handleSort("containers")}
>
<div className="flex items-center gap-2">
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("containers")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Containers
{getSortIcon("containers")}
</div>
</button>
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Running
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-800"
onClick={() => handleSort("images")}
>
<div className="flex items-center gap-2">
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
<button
type="button"
onClick={() => handleSort("images")}
className="flex items-center gap-2 hover:text-secondary-700"
>
Images
{getSortIcon("images")}
</div>
</button>
</th>
<th
scope="col"
className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{filteredHosts.map((host) => (
<tr
key={host.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Server className="h-5 w-5 text-secondary-400 mr-3" />
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
<Link
to={`/docker/hosts/${host.id}`}
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
>
{host.friendly_name || host.hostname}
</Link>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
{host.dockerStats?.totalContainers || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-green-600 dark:text-green-400">
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-green-600 dark:text-green-400 font-medium">
{host.dockerStats?.runningContainers || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
<td className="px-4 py-2 whitespace-nowrap text-center text-sm text-secondary-900 dark:text-white">
{host.dockerStats?.totalImages || 0}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<td className="px-4 py-2 whitespace-nowrap text-center">
<Link
to={`/docker/hosts/${host.id}`}
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1"
title="View details"
>
View
<ExternalLink className="ml-1 h-4 w-4" />
<ExternalLink className="h-4 w-4" />
</Link>
</td>
</tr>
@@ -892,82 +946,64 @@ const Docker = () => {
</p>
</div>
) : (
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-700">
<thead className="bg-secondary-50 dark:bg-secondary-900">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
<tr>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Image
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Tag
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Detection Method
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Status
</th>
<th
scope="col"
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Affected
</th>
<th
scope="col"
className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-400 uppercase tracking-wider"
>
<th className="px-4 py-2 text-center text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-700">
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{updatesData.updates.map((update) => (
<tr
key={update.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
className="hover:bg-secondary-50 dark:hover:bg-secondary-700 transition-colors"
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<Package className="h-5 w-5 text-secondary-400 mr-3" />
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center gap-2">
<Package className="h-4 w-4 text-secondary-400 dark:text-secondary-500 flex-shrink-0" />
<Link
to={`/docker/images/${update.image_id}`}
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
className="text-sm font-medium text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 truncate"
>
{update.docker_images?.repository}
</Link>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<td className="px-4 py-2 whitespace-nowrap text-center">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
{update.current_tag}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<td className="px-4 py-2 whitespace-nowrap text-center">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
<Package className="h-3 w-3 mr-1" />
Digest Comparison
Digest
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<td className="px-4 py-2 whitespace-nowrap text-center">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
<AlertTriangle className="h-3 w-3 mr-1" />
Update Available
Available
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
<td className="px-4 py-2 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
{update.affectedContainersCount} container
{update.affectedContainersCount !== 1 ? "s" : ""}
{update.affectedHosts?.length > 0 && (
@@ -978,13 +1014,13 @@ const Docker = () => {
</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<td className="px-4 py-2 whitespace-nowrap text-center">
<Link
to={`/docker/images/${update.image_id}`}
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center"
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1"
title="View details"
>
View
<ExternalLink className="ml-1 h-4 w-4" />
<ExternalLink className="h-4 w-4" />
</Link>
</td>
</tr>
@@ -996,6 +1032,141 @@ const Docker = () => {
)}
</div>
</div>
{/* Delete Container Modal */}
{deleteContainerModal && (
<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 max-w-md w-full mx-4">
<div className="flex items-start mb-4">
<div className="flex-shrink-0">
<AlertTriangle className="h-6 w-6 text-red-600" />
</div>
<div className="ml-3 flex-1">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Delete Container
</h3>
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
<p className="mb-2">
Are you sure you want to delete this container from the
inventory?
</p>
<div className="bg-secondary-100 dark:bg-secondary-700 p-3 rounded-md">
<p className="font-medium text-secondary-900 dark:text-white">
{deleteContainerModal.name}
</p>
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1">
Image: {deleteContainerModal.image_name}:
{deleteContainerModal.image_tag}
</p>
<p className="text-xs text-secondary-600 dark:text-secondary-400">
Host:{" "}
{deleteContainerModal.host?.friendly_name || "Unknown"}
</p>
</div>
<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
This only removes the container from PatchMon's inventory.
It does NOT stop or delete the actual Docker container on
the host.
</p>
</div>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-3">
<button
type="button"
onClick={() =>
deleteContainerMutation.mutate(deleteContainerModal.id)
}
disabled={deleteContainerMutation.isPending}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{deleteContainerMutation.isPending
? "Deleting..."
: "Delete from Inventory"}
</button>
<button
type="button"
onClick={() => setDeleteContainerModal(null)}
disabled={deleteContainerMutation.isPending}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-700 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Delete Image Modal */}
{deleteImageModal && (
<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 max-w-md w-full mx-4">
<div className="flex items-start mb-4">
<div className="flex-shrink-0">
<AlertTriangle className="h-6 w-6 text-red-600" />
</div>
<div className="ml-3 flex-1">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Delete Image
</h3>
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
<p className="mb-2">
Are you sure you want to delete this image from the
inventory?
</p>
<div className="bg-secondary-100 dark:bg-secondary-700 p-3 rounded-md">
<p className="font-medium text-secondary-900 dark:text-white">
{deleteImageModal.repository}:{deleteImageModal.tag}
</p>
<p className="text-xs text-secondary-600 dark:text-secondary-400 mt-1">
Source: {deleteImageModal.source}
</p>
<p className="text-xs text-secondary-600 dark:text-secondary-400">
Containers using this:{" "}
{deleteImageModal._count?.docker_containers || 0}
</p>
</div>
{deleteImageModal._count?.docker_containers > 0 ? (
<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
⚠️ Cannot delete: This image is in use by{" "}
{deleteImageModal._count.docker_containers} container(s).
Delete the containers first.
</p>
) : (
<p className="mt-3 text-red-600 dark:text-red-400 font-medium">
⚠️ This only removes the image from PatchMon's inventory.
It does NOT delete the actual Docker image from hosts.
</p>
)}
</div>
</div>
</div>
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse gap-3">
<button
type="button"
onClick={() => deleteImageMutation.mutate(deleteImageModal.id)}
disabled={
deleteImageMutation.isPending ||
deleteImageModal._count?.docker_containers > 0
}
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-red-600 text-base font-medium text-white hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{deleteImageMutation.isPending
? "Deleting..."
: "Delete from Inventory"}
</button>
<button
type="button"
onClick={() => setDeleteImageModal(null)}
disabled={deleteImageMutation.isPending}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-700 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:w-auto sm:text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -402,105 +402,71 @@ const Hosts = () => {
const token = localStorage.getItem("token");
if (!token) return;
// Fetch initial WebSocket status for all hosts
// Fetch initial WebSocket status for all hosts
const fetchInitialStatus = async () => {
const statusPromises = hosts
const apiIds = hosts
.filter((host) => host.api_id)
.map(async (host) => {
try {
const response = await fetch(`/api/v1/ws/status/${host.api_id}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (response.ok) {
const data = await response.json();
return { apiId: host.api_id, status: data.data };
}
} catch (_error) {
// Silently handle errors
}
return {
apiId: host.api_id,
status: { connected: false, secure: false },
};
});
.map((host) => host.api_id);
const results = await Promise.all(statusPromises);
const initialStatusMap = {};
results.forEach(({ apiId, status }) => {
initialStatusMap[apiId] = status;
});
if (apiIds.length === 0) return;
setWsStatusMap(initialStatusMap);
try {
const response = await fetch(
`/api/v1/ws/status?apiIds=${apiIds.join(",")}`,
{
headers: {
Authorization: `Bearer ${token}`,
},
},
);
if (response.ok) {
const result = await response.json();
setWsStatusMap(result.data);
}
} catch (_error) {
// Silently handle errors
}
};
fetchInitialStatus();
}, [hosts]);
// Subscribe to WebSocket status changes for all hosts via SSE
// Subscribe to WebSocket status changes for all hosts via polling (lightweight alternative to SSE)
useEffect(() => {
if (!hosts || hosts.length === 0) return;
const token = localStorage.getItem("token");
if (!token) return;
const eventSources = new Map();
let isMounted = true;
// Use polling instead of SSE to avoid connection pool issues
// Poll every 10 seconds instead of 19 persistent connections
const pollInterval = setInterval(() => {
const apiIds = hosts
.filter((host) => host.api_id)
.map((host) => host.api_id);
const connectHost = (apiId) => {
if (!isMounted || eventSources.has(apiId)) return;
if (apiIds.length === 0) return;
try {
const es = new EventSource(
`/api/v1/ws/status/${apiId}/stream?token=${encodeURIComponent(token)}`,
);
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (isMounted) {
setWsStatusMap((prev) => {
const newMap = { ...prev, [apiId]: data };
return newMap;
});
}
} catch (_err) {
// Silently handle parse errors
fetch(`/api/v1/ws/status?apiIds=${apiIds.join(",")}`, {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((response) => response.json())
.then((result) => {
if (result.success && result.data) {
setWsStatusMap(result.data);
}
};
es.onerror = (_error) => {
console.log(`[SSE] Connection error for ${apiId}, retrying...`);
es?.close();
eventSources.delete(apiId);
if (isMounted) {
// Retry connection after 5 seconds with exponential backoff
setTimeout(() => connectHost(apiId), 5000);
}
};
eventSources.set(apiId, es);
} catch (_err) {
// Silently handle connection errors
}
};
// Connect to all hosts
for (const host of hosts) {
if (host.api_id) {
connectHost(host.api_id);
} else {
}
}
})
.catch(() => {
// Silently handle errors
});
}, 10000); // Poll every 10 seconds
// Cleanup function
return () => {
isMounted = false;
for (const es of eventSources.values()) {
es.close();
}
eventSources.clear();
clearInterval(pollInterval);
};
}, [hosts]);

View File

@@ -1,18 +1,26 @@
import {
AlertCircle,
ArrowLeft,
BookOpen,
Eye,
EyeOff,
Github,
Globe,
Lock,
Mail,
Smartphone,
Route,
Star,
User,
} from "lucide-react";
import { useEffect, useId, useState } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { FaReddit, FaYoutube } from "react-icons/fa";
import { useNavigate } from "react-router-dom";
import trianglify from "trianglify";
import DiscordIcon from "../components/DiscordIcon";
import { useAuth } from "../contexts/AuthContext";
import { useColorTheme } from "../contexts/ColorThemeContext";
import { authAPI, isCorsError } from "../utils/api";
const Login = () => {
@@ -42,9 +50,48 @@ const Login = () => {
const [requiresTfa, setRequiresTfa] = useState(false);
const [tfaUsername, setTfaUsername] = useState("");
const [signupEnabled, setSignupEnabled] = useState(false);
const [latestRelease, setLatestRelease] = useState(null);
const [githubStars, setGithubStars] = useState(null);
const canvasRef = useRef(null);
const { themeConfig } = useColorTheme();
const navigate = useNavigate();
// Generate Trianglify background based on selected theme
useEffect(() => {
const generateBackground = () => {
if (canvasRef.current && themeConfig?.login) {
// Get current date as seed for daily variation
const today = new Date();
const dateSeed = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`;
// Generate pattern with selected theme configuration
const pattern = trianglify({
width: canvasRef.current.offsetWidth,
height: canvasRef.current.offsetHeight,
cellSize: themeConfig.login.cellSize,
variance: themeConfig.login.variance,
seed: dateSeed,
xColors: themeConfig.login.xColors,
yColors: themeConfig.login.yColors,
});
// Render to canvas
pattern.toCanvas(canvasRef.current);
}
};
generateBackground();
// Regenerate on window resize
const handleResize = () => {
generateBackground();
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [themeConfig]); // Regenerate when theme changes
// Check if signup is enabled
useEffect(() => {
const checkSignupEnabled = async () => {
@@ -63,6 +110,99 @@ const Login = () => {
checkSignupEnabled();
}, []);
// Fetch latest release and stars from GitHub
useEffect(() => {
const fetchGitHubData = async () => {
try {
// Try to get cached data first
const cachedRelease = localStorage.getItem("githubLatestRelease");
const cachedStars = localStorage.getItem("githubStarsCount");
const cacheTime = localStorage.getItem("githubReleaseCacheTime");
const now = Date.now();
// Load cached data immediately
if (cachedRelease) {
setLatestRelease(JSON.parse(cachedRelease));
}
if (cachedStars) {
setGithubStars(parseInt(cachedStars, 10));
}
// Use cache if less than 1 hour old
if (cacheTime && now - parseInt(cacheTime, 10) < 3600000) {
return;
}
// Fetch repository info (includes star count)
const repoResponse = await fetch(
"https://api.github.com/repos/PatchMon/PatchMon",
{
headers: {
Accept: "application/vnd.github.v3+json",
},
},
);
if (repoResponse.ok) {
const repoData = await repoResponse.json();
setGithubStars(repoData.stargazers_count);
localStorage.setItem(
"githubStarsCount",
repoData.stargazers_count.toString(),
);
}
// Fetch latest release
const releaseResponse = await fetch(
"https://api.github.com/repos/PatchMon/PatchMon/releases/latest",
{
headers: {
Accept: "application/vnd.github.v3+json",
},
},
);
if (releaseResponse.ok) {
const data = await releaseResponse.json();
const releaseInfo = {
version: data.tag_name,
name: data.name,
publishedAt: new Date(data.published_at).toLocaleDateString(
"en-US",
{
year: "numeric",
month: "long",
day: "numeric",
},
),
body: data.body?.split("\n").slice(0, 3).join("\n") || "", // First 3 lines
};
setLatestRelease(releaseInfo);
localStorage.setItem(
"githubLatestRelease",
JSON.stringify(releaseInfo),
);
}
localStorage.setItem("githubReleaseCacheTime", now.toString());
} catch (error) {
console.error("Failed to fetch GitHub data:", error);
// Set fallback data if nothing cached
if (!latestRelease) {
setLatestRelease({
version: "v1.3.0",
name: "Latest Release",
publishedAt: "Recently",
body: "Monitor and manage your Linux package updates",
});
}
}
};
fetchGitHubData();
}, [latestRelease]);
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
@@ -239,312 +379,532 @@ const Login = () => {
};
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">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100">
<Lock size={24} color="#2563eb" strokeWidth={2} />
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900">
{isSignupMode ? "Create PatchMon Account" : "Sign in to PatchMon"}
</h2>
<p className="mt-2 text-center text-sm text-secondary-600">
Monitor and manage your Linux package updates
</p>
</div>
<div className="min-h-screen relative flex">
{/* Full-screen Trianglify Background */}
<canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
<div className="absolute inset-0 bg-gradient-to-br from-black/40 to-black/60" />
{!requiresTfa ? (
<form
className="mt-8 space-y-6"
onSubmit={isSignupMode ? handleSignupSubmit : handleSubmit}
>
<div className="space-y-4">
{/* Left side - Info Panel (hidden on mobile) */}
<div className="hidden lg:flex lg:w-1/2 xl:w-3/5 relative z-10">
<div className="flex flex-col justify-between text-white p-12 h-full w-full">
<div className="flex-1 flex flex-col justify-center items-start max-w-xl mx-auto">
<div className="space-y-6">
<div>
<label
htmlFor={usernameId}
className="block text-sm font-medium text-secondary-700"
<img
src="/assets/logo_dark.png"
alt="PatchMon"
className="h-16 mb-4"
/>
<p className="text-sm text-blue-200 font-medium tracking-wide uppercase">
Linux Patch Monitoring
</p>
</div>
{latestRelease ? (
<div className="space-y-4 bg-black/20 backdrop-blur-sm rounded-lg p-6 border border-white/10">
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
<span className="text-green-300 text-sm font-semibold">
Latest Release
</span>
</div>
<span className="text-2xl font-bold text-white">
{latestRelease.version}
</span>
</div>
{latestRelease.name && (
<h3 className="text-lg font-semibold text-white">
{latestRelease.name}
</h3>
)}
<div className="flex items-center gap-2 text-sm text-gray-300">
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-label="Release date"
>
<title>Release date</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
<span>Released {latestRelease.publishedAt}</span>
</div>
{latestRelease.body && (
<p className="text-sm text-gray-300 leading-relaxed line-clamp-3">
{latestRelease.body}
</p>
)}
<a
href="https://github.com/PatchMon/PatchMon/releases/latest"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-blue-300 hover:text-blue-200 transition-colors font-medium"
>
View Release Notes
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-label="External link"
>
<title>External link</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
</div>
) : (
<div className="space-y-4 bg-black/20 backdrop-blur-sm rounded-lg p-6 border border-white/10">
<div className="animate-pulse space-y-3">
<div className="h-6 bg-white/20 rounded w-3/4" />
<div className="h-4 bg-white/20 rounded w-1/2" />
<div className="h-4 bg-white/20 rounded w-full" />
</div>
</div>
)}
</div>
</div>
{/* Social Links Footer */}
<div className="max-w-xl mx-auto w-full">
<div className="border-t border-white/10 pt-6">
<p className="text-sm text-gray-400 mb-4">Connect with us</p>
<div className="flex flex-wrap items-center gap-2">
{/* GitHub */}
<a
href="https://github.com/PatchMon/PatchMon"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="GitHub Repository"
>
{isSignupMode ? "Username" : "Username or Email"}
</label>
<div className="mt-1 relative">
<input
id={usernameId}
name="username"
type="text"
required
value={formData.username}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-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"
placeholder={
isSignupMode
? "Enter your username"
: "Enter your username or email"
}
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
<User size={20} color="#64748b" strokeWidth={2} />
<Github className="h-5 w-5 text-white" />
{githubStars !== null && (
<div className="flex items-center gap-1">
<Star className="h-3.5 w-3.5 fill-current text-yellow-400" />
<span className="text-sm font-medium text-white">
{githubStars}
</span>
</div>
)}
</a>
{/* Roadmap */}
<a
href="https://github.com/orgs/PatchMon/projects/2/views/1"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Roadmap"
>
<Route className="h-5 w-5 text-white" />
</a>
{/* Docs */}
<a
href="https://docs.patchmon.net"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Documentation"
>
<BookOpen className="h-5 w-5 text-white" />
</a>
{/* Discord */}
<a
href="https://patchmon.net/discord"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Discord Community"
>
<DiscordIcon className="h-5 w-5 text-white" />
</a>
{/* Email */}
<a
href="mailto:support@patchmon.net"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Email Support"
>
<Mail className="h-5 w-5 text-white" />
</a>
{/* YouTube */}
<a
href="https://youtube.com/@patchmonTV"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="YouTube Channel"
>
<FaYoutube className="h-5 w-5 text-white" />
</a>
{/* Reddit */}
<a
href="https://www.reddit.com/r/patchmon"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Reddit Community"
>
<FaReddit className="h-5 w-5 text-white" />
</a>
{/* Website */}
<a
href="https://patchmon.net"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-white/10 hover:bg-white/20 backdrop-blur-sm rounded-lg transition-colors border border-white/10"
title="Visit patchmon.net"
>
<Globe className="h-5 w-5 text-white" />
</a>
</div>
</div>
</div>
</div>
</div>
{/* Right side - Login Form */}
<div className="flex-1 flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8 relative z-10">
<div className="max-w-md w-full space-y-8 bg-white dark:bg-secondary-900 rounded-2xl shadow-2xl p-8 lg:p-10">
<div>
<div className="mx-auto h-16 w-16 flex items-center justify-center">
<img
src="/assets/favicon.svg"
alt="PatchMon Logo"
className="h-16 w-16"
/>
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900 dark:text-secondary-100">
{isSignupMode ? "Create PatchMon Account" : "Sign in to PatchMon"}
</h2>
<p className="mt-2 text-center text-sm text-secondary-600 dark:text-secondary-400">
Monitor and manage your Linux package updates
</p>
</div>
{!requiresTfa ? (
<form
className="mt-8 space-y-6"
onSubmit={isSignupMode ? handleSignupSubmit : handleSubmit}
>
<div className="space-y-4">
<div>
<label
htmlFor={usernameId}
className="block text-sm font-medium text-secondary-900 dark:text-secondary-100"
>
{isSignupMode ? "Username" : "Username or Email"}
</label>
<div className="mt-1 relative">
<input
id={usernameId}
name="username"
type="text"
required
value={formData.username}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-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"
placeholder={
isSignupMode
? "Enter your username"
: "Enter your username or email"
}
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
<User size={20} color="#64748b" strokeWidth={2} />
</div>
</div>
</div>
{isSignupMode && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
htmlFor={firstNameId}
className="block text-sm font-medium text-secondary-900 dark:text-secondary-100"
>
First Name
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-secondary-400" />
</div>
<input
id={firstNameId}
name="firstName"
type="text"
required
value={formData.firstName}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-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"
placeholder="Enter your first name"
/>
</div>
</div>
<div>
<label
htmlFor={lastNameId}
className="block text-sm font-medium text-secondary-900 dark:text-secondary-100"
>
Last Name
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-secondary-400" />
</div>
<input
id={lastNameId}
name="lastName"
type="text"
required
value={formData.lastName}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-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"
placeholder="Enter your last name"
/>
</div>
</div>
</div>
<div>
<label
htmlFor={emailId}
className="block text-sm font-medium text-secondary-900 dark:text-secondary-100"
>
Email
</label>
<div className="mt-1 relative">
<input
id={emailId}
name="email"
type="email"
required
value={formData.email}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-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"
placeholder="Enter your email"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
<Mail size={20} color="#64748b" strokeWidth={2} />
</div>
</div>
</div>
</>
)}
<div>
<label
htmlFor={passwordId}
className="block text-sm font-medium text-secondary-900 dark:text-secondary-100"
>
Password
</label>
<div className="mt-1 relative">
<input
id={passwordId}
name="password"
type={showPassword ? "text" : "password"}
required
value={formData.password}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-10 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"
placeholder="Enter your password"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
<Lock size={20} color="#64748b" strokeWidth={2} />
</div>
<div className="absolute right-3 top-1/2 -translate-y-1/2 z-20 flex items-center">
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="bg-transparent border-none cursor-pointer p-1 flex items-center justify-center"
>
{showPassword ? (
<EyeOff size={20} color="#64748b" strokeWidth={2} />
) : (
<Eye size={20} color="#64748b" strokeWidth={2} />
)}
</button>
</div>
</div>
</div>
</div>
{isSignupMode && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
htmlFor={firstNameId}
className="block text-sm font-medium text-secondary-700"
>
First Name
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-secondary-400" />
</div>
<input
id={firstNameId}
name="firstName"
type="text"
required
value={formData.firstName}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-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"
placeholder="Enter your first name"
/>
</div>
</div>
<div>
<label
htmlFor={lastNameId}
className="block text-sm font-medium text-secondary-700"
>
Last Name
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-secondary-400" />
</div>
<input
id={lastNameId}
name="lastName"
type="text"
required
value={formData.lastName}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-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"
placeholder="Enter your last name"
/>
</div>
{error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<div className="flex">
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
<div className="ml-3">
<p className="text-sm text-danger-700">{error}</p>
</div>
</div>
<div>
<label
htmlFor={emailId}
className="block text-sm font-medium text-secondary-700"
>
Email
</label>
<div className="mt-1 relative">
<input
id={emailId}
name="email"
type="email"
required
value={formData.email}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-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"
placeholder="Enter your email"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
<Mail size={20} color="#64748b" strokeWidth={2} />
</div>
</div>
</div>
</>
</div>
)}
<div>
<label
htmlFor={passwordId}
className="block text-sm font-medium text-secondary-700"
<button
type="submit"
disabled={isLoading}
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"
>
Password
</label>
<div className="mt-1 relative">
<input
id={passwordId}
name="password"
type={showPassword ? "text" : "password"}
required
value={formData.password}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-10 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"
placeholder="Enter your password"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
<Lock size={20} color="#64748b" strokeWidth={2} />
</div>
<div className="absolute right-3 top-1/2 -translate-y-1/2 z-20 flex items-center">
{isLoading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
{isSignupMode ? "Creating account..." : "Signing in..."}
</div>
) : isSignupMode ? (
"Create Account"
) : (
"Sign in"
)}
</button>
</div>
{signupEnabled && (
<div className="text-center">
<p className="text-sm text-secondary-700 dark:text-secondary-300">
{isSignupMode
? "Already have an account?"
: "Don't have an account?"}{" "}
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="bg-transparent border-none cursor-pointer p-1 flex items-center justify-center"
onClick={toggleMode}
className="font-medium text-primary-600 hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300 focus:outline-none focus:underline"
>
{showPassword ? (
<EyeOff size={20} color="#64748b" strokeWidth={2} />
) : (
<Eye size={20} color="#64748b" strokeWidth={2} />
)}
{isSignupMode ? "Sign in" : "Sign up"}
</button>
</div>
</p>
</div>
</div>
</div>
{error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<div className="flex">
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
<div className="ml-3">
<p className="text-sm text-danger-700">{error}</p>
</div>
</div>
</div>
)}
<div>
<button
type="submit"
disabled={isLoading}
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>
{isSignupMode ? "Creating account..." : "Signing in..."}
</div>
) : isSignupMode ? (
"Create Account"
) : (
"Sign in"
)}
</button>
</div>
{signupEnabled && (
)}
</form>
) : (
<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}>
<div className="text-center">
<p className="text-sm text-secondary-600">
{isSignupMode
? "Already have an account?"
: "Don't have an account?"}{" "}
<button
type="button"
onClick={toggleMode}
className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline"
>
{isSignupMode ? "Sign in" : "Sign up"}
</button>
<div className="mx-auto h-16 w-16 flex items-center justify-center">
<img
src="/assets/favicon.svg"
alt="PatchMon Logo"
className="h-16 w-16"
/>
</div>
<h3 className="mt-4 text-lg font-medium text-secondary-900 dark:text-secondary-100">
Two-Factor Authentication
</h3>
<p className="mt-2 text-sm text-secondary-600 dark:text-secondary-400">
Enter the 6-digit code from your authenticator app
</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 size={24} color="#2563eb" strokeWidth={2} />
</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={tokenId}
className="block text-sm font-medium text-secondary-700"
>
Verification Code
</label>
<div className="mt-1">
<input
id={tokenId}
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>
<div className="flex items-center">
<input
id={rememberMeId}
name="remember_me"
type="checkbox"
checked={tfaData.remember_me}
onChange={handleTfaInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label
htmlFor={rememberMeId}
className="ml-2 block text-sm text-secondary-700"
>
Remember me on this computer (skip TFA for 30 days)
</label>
</div>
{error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<div className="flex">
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
<div className="ml-3">
<p className="text-sm text-danger-700">{error}</p>
</div>
<div>
<label
htmlFor={tokenId}
className="block text-sm font-medium text-secondary-900 dark:text-secondary-100"
>
Verification Code
</label>
<div className="mt-1">
<input
id={tokenId}
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>
)}
<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 className="flex items-center">
<input
id={rememberMeId}
name="remember_me"
type="checkbox"
checked={tfaData.remember_me}
onChange={handleTfaInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label
htmlFor={rememberMeId}
className="ml-2 block text-sm text-secondary-900 dark:text-secondary-200"
>
Remember me on this computer (skip TFA for 30 days)
</label>
</div>
{error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<div className="flex">
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
<div className="ml-3">
<p className="text-sm text-danger-700">{error}</p>
</div>
</div>
) : (
"Verify Code"
)}
</button>
</div>
)}
<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 items-center gap-2"
>
<ArrowLeft size={16} color="#475569" strokeWidth={2} />
Back to Login
</button>
</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>
<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>
)}
<button
type="button"
onClick={handleBackToLogin}
className="group relative w-full flex justify-center py-2 px-4 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 items-center gap-2"
>
<ArrowLeft
size={16}
className="text-secondary-700 dark:text-secondary-200"
strokeWidth={2}
/>
Back to Login
</button>
</div>
<div className="text-center">
<p className="text-sm text-secondary-600 dark:text-secondary-400">
Don't have access to your authenticator? Use a backup code.
</p>
</div>
</form>
)}
</div>
</div>
</div>
);

View File

@@ -0,0 +1,399 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
BarChart3,
CheckCircle,
Eye,
EyeOff,
Globe,
Info,
RefreshCw,
Send,
Shield,
} from "lucide-react";
import { useState } from "react";
import SettingsLayout from "../../components/SettingsLayout";
// API functions - will be added to utils/api.js
const metricsAPI = {
getSettings: () =>
fetch("/api/v1/metrics", {
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
}).then((res) => res.json()),
updateSettings: (data) =>
fetch("/api/v1/metrics", {
method: "PUT",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
body: JSON.stringify(data),
}).then((res) => res.json()),
regenerateId: () =>
fetch("/api/v1/metrics/regenerate-id", {
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
}).then((res) => res.json()),
sendNow: () =>
fetch("/api/v1/metrics/send-now", {
method: "POST",
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
}).then((res) => res.json()),
};
const SettingsMetrics = () => {
const queryClient = useQueryClient();
const [showFullId, setShowFullId] = useState(false);
// Fetch metrics settings
const {
data: metricsSettings,
isLoading,
error,
} = useQuery({
queryKey: ["metrics-settings"],
queryFn: () => metricsAPI.getSettings(),
});
// Toggle metrics mutation
const toggleMetricsMutation = useMutation({
mutationFn: (enabled) =>
metricsAPI.updateSettings({ metrics_enabled: enabled }),
onSuccess: () => {
queryClient.invalidateQueries(["metrics-settings"]);
},
});
// Regenerate ID mutation
const regenerateIdMutation = useMutation({
mutationFn: () => metricsAPI.regenerateId(),
onSuccess: () => {
queryClient.invalidateQueries(["metrics-settings"]);
},
});
// Send now mutation
const sendNowMutation = useMutation({
mutationFn: () => metricsAPI.sendNow(),
onSuccess: () => {
queryClient.invalidateQueries(["metrics-settings"]);
},
});
if (isLoading) {
return (
<SettingsLayout>
<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>
</SettingsLayout>
);
}
if (error) {
return (
<SettingsLayout>
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md 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">
Error loading metrics settings
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{error.message || "Failed to load settings"}
</p>
</div>
</div>
</div>
</SettingsLayout>
);
}
const maskId = (id) => {
if (!id) return "";
if (showFullId) return id;
return `${id.substring(0, 8)}...${id.substring(id.length - 8)}`;
};
return (
<SettingsLayout>
<div className="space-y-6">
{/* Header */}
<div className="flex items-center mb-6">
<BarChart3 className="h-6 w-6 text-primary-600 mr-3" />
<div>
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Anonymous Metrics & Telemetry
</h2>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Help us understand PatchMon's global usage (100% anonymous)
</p>
</div>
</div>
{/* Privacy Information */}
<div className="bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg p-6">
<div className="flex">
<Shield className="h-6 w-6 text-blue-600 dark:text-blue-400 flex-shrink-0" />
<div className="ml-4 flex-1">
<h3 className="text-base font-semibold text-blue-900 dark:text-blue-100 mb-3">
Your Privacy Matters
</h3>
<div className="text-sm text-blue-800 dark:text-blue-200 space-y-2">
<p className="flex items-start">
<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
<span>
<strong>We do NOT collect:</strong> IP addresses, hostnames,
system details, or any personally identifiable information
</span>
</p>
<p className="flex items-start">
<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
<span>
<strong>We ONLY collect:</strong> An anonymous UUID (for
deduplication) and the number of hosts you're monitoring
</span>
</p>
<p className="flex items-start">
<CheckCircle className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
<span>
<strong>Purpose:</strong> Display a live counter on our
website showing global PatchMon adoption
</span>
</p>
<p className="flex items-start">
<Globe className="h-4 w-4 mr-2 mt-0.5 flex-shrink-0" />
<span>
<strong>Open Source:</strong> All code is public and
auditable on GitHub
</span>
</p>
</div>
</div>
</div>
</div>
{/* Metrics Toggle */}
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<div className="flex items-start justify-between">
<div className="flex-1">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
Enable Anonymous Metrics
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-400">
Share anonymous usage statistics to help us showcase PatchMon's
global adoption. Data is sent automatically every 24 hours.
</p>
</div>
<button
type="button"
onClick={() =>
toggleMetricsMutation.mutate(!metricsSettings?.metrics_enabled)
}
disabled={toggleMetricsMutation.isPending}
className={`ml-4 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
metricsSettings?.metrics_enabled
? "bg-primary-600"
: "bg-secondary-200 dark:bg-secondary-700"
} ${toggleMetricsMutation.isPending ? "opacity-50" : ""}`}
>
<span
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
metricsSettings?.metrics_enabled
? "translate-x-5"
: "translate-x-0"
}`}
/>
</button>
</div>
{/* Status */}
<div className="mt-4 pt-4 border-t border-secondary-200 dark:border-secondary-700">
<div className="flex items-center text-sm">
{metricsSettings?.metrics_enabled ? (
<>
<CheckCircle className="h-4 w-4 text-green-500 mr-2" />
<span className="text-green-700 dark:text-green-400">
Metrics enabled - Thank you for supporting PatchMon!
</span>
</>
) : (
<>
<EyeOff className="h-4 w-4 text-secondary-500 mr-2" />
<span className="text-secondary-600 dark:text-secondary-400">
Metrics disabled - No data is being sent
</span>
</>
)}
</div>
</div>
</div>
{/* Anonymous ID Section */}
<div className="bg-white dark:bg-secondary-800 rounded-lg border border-secondary-200 dark:border-secondary-700 p-6">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
Your Anonymous Instance ID
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-400">
This UUID identifies your instance without revealing any
personal information
</p>
</div>
</div>
<div className="mt-4 space-y-4">
<div className="flex items-center gap-3">
<div className="flex-1 bg-secondary-50 dark:bg-secondary-700 rounded-md p-3 font-mono text-sm break-all">
{maskId(metricsSettings?.metrics_anonymous_id)}
</div>
<button
type="button"
onClick={() => setShowFullId(!showFullId)}
className="p-2 text-secondary-600 dark:text-secondary-400 hover:text-secondary-900 dark:hover:text-white"
title={showFullId ? "Hide ID" : "Show full ID"}
>
{showFullId ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
</div>
<div className="flex gap-3">
<button
type="button"
onClick={() => regenerateIdMutation.mutate()}
disabled={regenerateIdMutation.isPending}
className="inline-flex items-center px-4 py-2 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
>
{regenerateIdMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-secondary-700 dark:border-secondary-200 mr-2"></div>
Regenerating...
</>
) : (
<>
<RefreshCw className="h-4 w-4 mr-2" />
Regenerate ID
</>
)}
</button>
<button
type="button"
onClick={() => sendNowMutation.mutate()}
disabled={
!metricsSettings?.metrics_enabled || sendNowMutation.isPending
}
className="inline-flex items-center px-4 py-2 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"
>
{sendNowMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Sending...
</>
) : (
<>
<Send className="h-4 w-4 mr-2" />
Send Metrics Now
</>
)}
</button>
</div>
{metricsSettings?.metrics_last_sent && (
<p className="text-xs text-secondary-500 dark:text-secondary-400">
Last sent:{" "}
{new Date(metricsSettings.metrics_last_sent).toLocaleString()}
</p>
)}
</div>
{/* Success/Error Messages */}
{regenerateIdMutation.isSuccess && (
<div className="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded-md p-3">
<div className="flex">
<CheckCircle className="h-4 w-4 text-green-400 dark:text-green-300 mt-0.5" />
<p className="ml-2 text-sm text-green-700 dark:text-green-300">
Anonymous ID regenerated successfully
</p>
</div>
</div>
)}
{sendNowMutation.isSuccess && (
<div className="mt-4 bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-700 rounded-md p-3">
<div className="flex">
<CheckCircle className="h-4 w-4 text-green-400 dark:text-green-300 mt-0.5" />
<div className="ml-2 text-sm text-green-700 dark:text-green-300">
<p className="font-medium">Metrics sent successfully!</p>
{sendNowMutation.data?.data && (
<p className="mt-1">
Sent: {sendNowMutation.data.data.hostCount} hosts, version{" "}
{sendNowMutation.data.data.version}
</p>
)}
</div>
</div>
</div>
)}
{sendNowMutation.isError && (
<div className="mt-4 bg-red-50 dark:bg-red-900/30 border border-red-200 dark:border-red-700 rounded-md p-3">
<div className="flex">
<AlertCircle className="h-4 w-4 text-red-400 dark:text-red-300 mt-0.5" />
<div className="ml-2 text-sm text-red-700 dark:text-red-300">
{sendNowMutation.error?.message || "Failed to send metrics"}
</div>
</div>
</div>
)}
</div>
{/* Information Panel */}
<div className="bg-secondary-50 dark:bg-secondary-800/50 border border-secondary-200 dark:border-secondary-700 rounded-lg p-6">
<div className="flex">
<Info className="h-5 w-5 text-secondary-500 dark:text-secondary-400 flex-shrink-0 mt-0.5" />
<div className="ml-3 text-sm text-secondary-700 dark:text-secondary-300">
<h4 className="font-medium mb-2">How it works:</h4>
<ul className="space-y-1 list-disc list-inside">
<li>
Metrics are sent automatically every 24 hours when enabled
</li>
<li>
Only host count and version number are transmitted (no
sensitive data)
</li>
<li>The anonymous UUID prevents duplicate counting</li>
<li>You can regenerate your ID or opt-out at any time</li>
<li>
All collected data is displayed publicly on{" "}
<a
href="https://patchmon.net"
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 dark:text-primary-400 hover:underline"
>
patchmon.net
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</SettingsLayout>
);
};
export default SettingsMetrics;

View File

@@ -5,7 +5,7 @@ const API_BASE_URL = import.meta.env.VITE_API_URL || "/api/v1";
// Create axios instance with default config
const api = axios.create({
baseURL: API_BASE_URL,
timeout: 10000,
timeout: 10000, // 10 seconds
headers: {
"Content-Type": "application/json",
},

View File

@@ -1,3 +1,4 @@
import { Agent as HttpAgent } from "node:http";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
@@ -14,6 +15,15 @@ export default defineConfig({
target: `http://${process.env.BACKEND_HOST || "localhost"}:${process.env.BACKEND_PORT || "3001"}`,
changeOrigin: true,
secure: false,
// Configure HTTP agent to support more concurrent connections
// Fixes 1000ms timeout issue when using HTTP (not HTTPS) with multiple hosts
agent: new HttpAgent({
keepAlive: true,
maxSockets: 50, // Increase from default 6 to handle multiple hosts
maxFreeSockets: 10,
timeout: 60000,
keepAliveMsecs: 1000,
}),
configure:
process.env.VITE_ENABLE_LOGGING === "true"
? (proxy, _options) => {

2374
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon",
"version": "1.3.0",
"version": "1.3.1",
"description": "Linux Patch Monitoring System",
"license": "AGPL-3.0",
"private": true,
@@ -25,7 +25,7 @@
"lint:fix": "biome check --write ."
},
"devDependencies": {
"@biomejs/biome": "2.2.4",
"@biomejs/biome": "^2.3.0",
"concurrently": "^8.2.2",
"lefthook": "^1.13.4"
},

796
setup.sh
View File

@@ -707,6 +707,10 @@ configure_redis() {
chown redis:redis /etc/redis/users.acl
chmod 640 /etc/redis/users.acl
print_status "Created Redis ACL file"
else
# Backup existing ACL file
cp /etc/redis/users.acl /etc/redis/users.acl.backup.$(date +%Y%m%d_%H%M%S) 2>/dev/null || true
print_info "Backed up existing ACL file"
fi
# Configure ACL file in redis.conf
@@ -727,8 +731,14 @@ configure_redis() {
print_status "Removed user definitions from redis.conf"
fi
# Create admin user in ACL file if it doesn't exist
if ! grep -q "^user admin" /etc/redis/users.acl; then
# Create or update admin user in ACL file
if grep -q "^user admin" /etc/redis/users.acl; then
print_info "Admin user already exists in ACL, updating password..."
# Remove old admin line and add new one
sed -i '/^user admin/d' /etc/redis/users.acl
echo "user admin on sanitize-payload >$REDIS_PASSWORD ~* &* +@all" >> /etc/redis/users.acl
print_status "Updated admin user password"
else
echo "user admin on sanitize-payload >$REDIS_PASSWORD ~* &* +@all" >> /etc/redis/users.acl
print_status "Added admin user to ACL file"
fi
@@ -737,65 +747,126 @@ configure_redis() {
print_info "Restarting Redis to apply ACL configuration..."
systemctl restart redis-server
# Wait for Redis to start
sleep 3
# Wait for Redis to start with retry logic
sleep 5
# Test admin connection
if ! redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ping > /dev/null 2>&1; then
print_error "Failed to configure Redis ACL authentication"
return 1
fi
# Test admin connection with retries
local max_retries=3
local retry=0
local admin_works=false
print_status "Redis ACL authentication configuration successful"
# Create Redis user with ACL
print_info "Creating Redis ACL user: $REDIS_USER"
# Create user with password and permissions - capture output for error handling
local acl_result
acl_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SETUSER "$REDIS_USER" on ">${REDIS_USER_PASSWORD}" ~* +@all 2>&1)
if [ "$acl_result" = "OK" ]; then
print_status "Redis user '$REDIS_USER' created successfully"
# Save ACL users to file to persist across restarts
local save_result
save_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SAVE 2>&1)
if [ "$save_result" = "OK" ]; then
print_status "Redis ACL users saved to file"
else
print_warning "Failed to save ACL users to file: $save_result"
while [ $retry -lt $max_retries ]; do
if redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ping > /dev/null 2>&1; then
admin_works=true
break
fi
print_info "Waiting for Redis to be ready... (attempt $((retry + 1))/$max_retries)"
sleep 2
retry=$((retry + 1))
done
if [ "$admin_works" = false ]; then
print_error "Failed to verify admin connection after Redis restart"
print_error "Redis ACL configuration may have issues"
# Verify user was actually created
local verify_result
verify_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL GETUSER "$REDIS_USER" 2>&1)
# Try to fix by disabling ACL and using requirepass instead
print_warning "Attempting fallback: using requirepass instead of ACL..."
sed -i 's/^aclfile/# aclfile/' /etc/redis/redis.conf
sed -i "s/^# requirepass .*/requirepass $REDIS_PASSWORD/" /etc/redis/redis.conf
if ! grep -q "^requirepass" /etc/redis/redis.conf; then
echo "requirepass $REDIS_PASSWORD" >> /etc/redis/redis.conf
fi
systemctl restart redis-server
sleep 3
if [ "$verify_result" = "(nil)" ]; then
print_error "User creation reported OK but user does not exist"
# Test requirepass
if redis-cli -h 127.0.0.1 -p 6379 -a "$REDIS_PASSWORD" --no-auth-warning ping > /dev/null 2>&1; then
print_status "Fallback successful - using requirepass authentication"
# For requirepass mode, we'll set REDIS_USER empty later
print_info "Note: Using legacy requirepass mode instead of ACL"
else
print_error "Fallback also failed - Redis authentication is broken"
return 1
fi
else
print_error "Failed to create Redis user: $acl_result"
return 1
print_status "Redis ACL authentication configuration successful"
fi
# Test user connection
print_info "Testing Redis user connection..."
if redis-cli -h 127.0.0.1 -p 6379 --user "$REDIS_USER" --pass "$REDIS_USER_PASSWORD" --no-auth-warning -n "$REDIS_DB" ping > /dev/null 2>&1; then
print_status "Redis user connection test successful"
# Create Redis user with ACL (only if admin_works, meaning we're using ACL mode)
if [ "$admin_works" = true ]; then
print_info "Creating Redis ACL user: $REDIS_USER"
# Create user with password and permissions - capture output for error handling
local acl_result
acl_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SETUSER "$REDIS_USER" on ">${REDIS_USER_PASSWORD}" ~* +@all 2>&1)
if [ "$acl_result" = "OK" ]; then
print_status "Redis user '$REDIS_USER' created successfully"
# Save ACL users to file to persist across restarts
local save_result
save_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SAVE 2>&1)
if [ "$save_result" = "OK" ]; then
print_status "Redis ACL users saved to file"
else
print_warning "Failed to save ACL users to file: $save_result"
fi
# Verify user was actually created
local verify_result
verify_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL GETUSER "$REDIS_USER" 2>&1)
if [ "$verify_result" = "(nil)" ]; then
print_error "User creation reported OK but user does not exist"
return 1
fi
# Test user connection
print_info "Testing Redis user connection..."
if redis-cli -h 127.0.0.1 -p 6379 --user "$REDIS_USER" --pass "$REDIS_USER_PASSWORD" --no-auth-warning -n "$REDIS_DB" ping > /dev/null 2>&1; then
print_status "Redis user connection test successful"
else
print_error "Redis user connection test failed"
return 1
fi
# Mark the selected database as in-use
redis-cli -h 127.0.0.1 -p 6379 --user "$REDIS_USER" --pass "$REDIS_USER_PASSWORD" --no-auth-warning -n "$REDIS_DB" SET "patchmon:initialized" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > /dev/null
print_status "Marked Redis database $REDIS_DB as in-use"
else
print_error "Failed to create Redis user: $acl_result"
return 1
fi
else
print_error "Redis user connection test failed"
return 1
# Using requirepass mode - no per-user ACL
print_info "Using requirepass mode - testing connection..."
# For requirepass, we don't use username, just password
if redis-cli -h 127.0.0.1 -p 6379 -a "$REDIS_PASSWORD" --no-auth-warning -n "$REDIS_DB" ping > /dev/null 2>&1; then
print_status "Redis requirepass connection test successful"
# Mark the selected database as in-use
redis-cli -h 127.0.0.1 -p 6379 -a "$REDIS_PASSWORD" --no-auth-warning -n "$REDIS_DB" SET "patchmon:initialized" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > /dev/null
print_status "Marked Redis database $REDIS_DB as in-use"
# Set REDIS_USER to empty for requirepass mode
REDIS_USER=""
REDIS_USER_PASSWORD="$REDIS_PASSWORD"
else
print_error "Redis requirepass connection test failed"
return 1
fi
fi
# Mark the selected database as in-use
redis-cli -h 127.0.0.1 -p 6379 --user "$REDIS_USER" --pass "$REDIS_USER_PASSWORD" --no-auth-warning -n "$REDIS_DB" SET "patchmon:initialized" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > /dev/null
print_status "Marked Redis database $REDIS_DB as in-use"
# Note: Redis credentials will be written to .env by create_env_files() function
print_status "Redis user '$REDIS_USER' configured successfully"
print_status "Redis configured successfully"
if [ -n "$REDIS_USER" ]; then
print_info "Redis Mode: ACL with user '$REDIS_USER'"
else
print_info "Redis Mode: requirepass (legacy single-password auth)"
fi
print_info "Redis credentials will be saved to backend/.env"
return 0
@@ -1060,6 +1131,13 @@ DATABASE_URL="postgresql://$DB_USER:$DB_PASS@localhost:5432/$DB_NAME"
PM_DB_CONN_MAX_ATTEMPTS=30
PM_DB_CONN_WAIT_INTERVAL=2
# Database Connection Pool Configuration (Prisma)
DB_CONNECTION_LIMIT=30
DB_POOL_TIMEOUT=20
DB_CONNECT_TIMEOUT=10
DB_IDLE_TIMEOUT=300
DB_MAX_LIFETIME=1800
# JWT Configuration
JWT_SECRET="$JWT_SECRET"
JWT_EXPIRES_IN=1h
@@ -1110,22 +1188,127 @@ EOF
cat > frontend/.env << EOF
VITE_API_URL=$SERVER_PROTOCOL_SEL://$FQDN/api/v1
VITE_APP_NAME=PatchMon
VITE_APP_VERSION=1.3.0
VITE_APP_VERSION=1.3.1
EOF
print_status "Environment files created"
}
# Run database migrations
# Check and fix failed Prisma migrations
fix_failed_migrations() {
local db_name="$1"
local db_user="$2"
local db_pass="$3"
local db_host="${4:-localhost}"
local max_retries=3
print_info "Checking for failed migrations in database..."
# Query for failed migrations (where started_at is set but finished_at is NULL)
local failed_migrations
failed_migrations=$(PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -t -A -c \
"SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NULL AND started_at IS NOT NULL;" 2>/dev/null || echo "")
if [ -z "$failed_migrations" ]; then
print_status "No failed migrations found"
return 0
fi
print_warning "Found failed migration(s):"
echo "$failed_migrations" | while read -r migration; do
[ -n "$migration" ] && print_warning " - $migration"
done
print_info "Attempting to resolve failed migrations..."
# For each failed migration, mark it as rolled back and remove it
echo "$failed_migrations" | while read -r migration; do
if [ -n "$migration" ]; then
print_info "Processing failed migration: $migration"
# Mark the migration as rolled back
PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c \
"UPDATE _prisma_migrations SET rolled_back_at = NOW() WHERE migration_name = '$migration' AND finished_at IS NULL;" >/dev/null 2>&1
# Delete the failed migration record to allow retry
PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c \
"DELETE FROM _prisma_migrations WHERE migration_name = '$migration' AND finished_at IS NULL;" >/dev/null 2>&1
print_status "Marked migration '$migration' for retry"
fi
done
print_status "Failed migrations have been cleared for retry"
return 0
}
# Run database migrations with self-healing
run_migrations() {
print_info "Running database migrations as user $INSTANCE_USER..."
cd "$APP_DIR/backend"
# Suppress Prisma CLI output (still logged to install log via tee)
run_as_user "$INSTANCE_USER" "cd $APP_DIR/backend && npx prisma migrate deploy" >/dev/null 2>&1 || true
local max_attempts=3
local attempt=1
local migration_success=false
while [ $attempt -le $max_attempts ]; do
print_info "Migration attempt $attempt of $max_attempts..."
# Try to run migrations
local migrate_output
migrate_output=$(run_as_user "$INSTANCE_USER" "cd $APP_DIR/backend && npx prisma migrate deploy 2>&1" || echo "MIGRATION_FAILED")
# Check if migration succeeded
if ! echo "$migrate_output" | grep -q "MIGRATION_FAILED\|Error:\|P3009"; then
print_status "Migrations completed successfully"
migration_success=true
break
fi
# Check specifically for P3009 (failed migrations found)
if echo "$migrate_output" | grep -q "P3009\|migrate found failed migrations"; then
print_warning "Detected failed migrations (P3009 error)"
# Extract the failed migration name if possible
local failed_migration
failed_migration=$(echo "$migrate_output" | grep -oP "The \`\K[^\`]+" | head -1 || echo "")
if [ -n "$failed_migration" ]; then
print_info "Failed migration identified: $failed_migration"
fi
# Attempt to fix failed migrations
print_info "Attempting to self-heal migration issues..."
if fix_failed_migrations "$DB_NAME" "$DB_USER" "$DB_PASS" "localhost"; then
print_status "Migration issues resolved, retrying..."
attempt=$((attempt + 1))
sleep 2
continue
else
print_error "Failed to resolve migration issues"
break
fi
else
# Other migration error
print_error "Migration failed with error:"
echo "$migrate_output" | grep -A 5 "Error:"
break
fi
done
if [ "$migration_success" = false ]; then
print_error "Migrations failed after $max_attempts attempts"
print_info "You may need to manually resolve migration issues"
print_info "Check migrations: cd $APP_DIR/backend && npx prisma migrate status"
return 1
fi
# Generate Prisma client
run_as_user "$INSTANCE_USER" "cd $APP_DIR/backend && npx prisma generate" >/dev/null 2>&1 || true
print_status "Database migrations completed as $INSTANCE_USER"
return 0
}
# Admin account creation removed - handled by application's first-time setup
@@ -1462,7 +1645,60 @@ start_services() {
print_status "PatchMon service started successfully"
else
print_error "Failed to start PatchMon service"
systemctl status "$SERVICE_NAME"
echo ""
# Show last 25 lines of service logs for debugging
print_warning "=== Last 25 lines of service logs ==="
journalctl -u "$SERVICE_NAME" -n 25 --no-pager || true
print_warning "==================================="
echo ""
# Check for specific error patterns
local logs=$(journalctl -u "$SERVICE_NAME" -n 50 --no-pager 2>/dev/null || echo "")
if echo "$logs" | grep -q "WRONGPASS\|NOAUTH"; then
print_error "❌ Detected Redis authentication error!"
print_info "The service cannot authenticate with Redis."
echo ""
print_info "Current Redis configuration in .env:"
grep "^REDIS_" "$APP_DIR/backend/.env" || true
echo ""
print_info "Debug steps:"
print_info " 1. Check Redis is running:"
print_info " systemctl status redis-server"
echo ""
print_info " 2. Check Redis ACL users:"
print_info " redis-cli ACL LIST"
echo ""
print_info " 3. Test Redis connection:"
local test_user=$(grep "^REDIS_USER=" "$APP_DIR/backend/.env" | cut -d'=' -f2)
local test_pass=$(grep "^REDIS_PASSWORD=" "$APP_DIR/backend/.env" | cut -d'=' -f2)
local test_db=$(grep "^REDIS_DB=" "$APP_DIR/backend/.env" | cut -d'=' -f2)
print_info " redis-cli --user $test_user --pass $test_pass -n ${test_db:-0} ping"
echo ""
print_info " 4. Check Redis configuration files:"
print_info " cat /etc/redis/redis.conf | grep aclfile"
print_info " cat /etc/redis/users.acl"
echo ""
elif echo "$logs" | grep -q "ECONNREFUSED.*postgresql\|Connection refused.*5432"; then
print_error "❌ Detected PostgreSQL connection error!"
print_info "Check if PostgreSQL is running:"
print_info " systemctl status postgresql"
elif echo "$logs" | grep -q "ECONNREFUSED.*redis\|Connection refused.*6379"; then
print_error "❌ Detected Redis connection error!"
print_info "Check if Redis is running:"
print_info " systemctl status redis-server"
elif echo "$logs" | grep -q "database.*does not exist"; then
print_error "❌ Database does not exist!"
print_info "Database: $DB_NAME"
elif echo "$logs" | grep -q "Error:"; then
print_error "❌ Application error detected in logs"
fi
echo ""
print_info "View full logs: journalctl -u $SERVICE_NAME -f"
print_info "Check service status: systemctl status $SERVICE_NAME"
return 1
fi
}
@@ -2012,6 +2248,65 @@ select_installation_to_update() {
done
}
# Repair/recreate Redis user with correct permissions
repair_redis_user() {
local redis_user="$1"
local redis_pass="$2"
local redis_db="${3:-0}"
print_info "Attempting to repair Redis user: $redis_user"
# Find admin password
local admin_password=""
if [ -f /etc/redis/users.acl ] && grep -q "^user admin" /etc/redis/users.acl; then
admin_password=$(grep "^user admin" /etc/redis/users.acl | grep -oP '>\K[^ ]+' | head -1)
fi
if [ -z "$admin_password" ]; then
print_error "Cannot repair Redis user - no admin credentials found"
return 1
fi
# Test admin connection
if ! redis-cli -h localhost -p 6379 --user admin --pass "$admin_password" --no-auth-warning ping >/dev/null 2>&1; then
print_error "Admin credentials don't work - cannot repair user"
return 1
fi
print_status "Admin access confirmed"
# Delete existing user if it exists (and is broken)
print_info "Removing old user configuration..."
redis-cli -h localhost -p 6379 --user admin --pass "$admin_password" --no-auth-warning ACL DELUSER "$redis_user" >/dev/null 2>&1 || true
# Create user with full permissions
print_info "Creating user with full permissions..."
local create_result
create_result=$(redis-cli -h localhost -p 6379 --user admin --pass "$admin_password" --no-auth-warning ACL SETUSER "$redis_user" on ">${redis_pass}" ~* +@all 2>&1)
if echo "$create_result" | grep -q "OK"; then
# Save ACL
redis-cli -h localhost -p 6379 --user admin --pass "$admin_password" --no-auth-warning ACL SAVE >/dev/null 2>&1
# Verify the new user works
if redis-cli -h localhost -p 6379 --user "$redis_user" --pass "$redis_pass" --no-auth-warning -n "$redis_db" ping >/dev/null 2>&1; then
if redis-cli -h localhost -p 6379 --user "$redis_user" --pass "$redis_pass" --no-auth-warning -n "$redis_db" info >/dev/null 2>&1; then
print_status "Redis user repaired successfully"
return 0
else
print_error "User created but INFO command still fails"
return 1
fi
else
print_error "User created but PING command fails"
return 1
fi
else
print_error "Failed to create user: $create_result"
return 1
fi
}
# Check and update Redis configuration for existing installation
update_redis_configuration() {
print_info "Checking Redis configuration..."
@@ -2021,12 +2316,57 @@ update_redis_configuration() {
if grep -q "^REDIS_HOST=" "$instance_dir/backend/.env" && \
grep -q "^REDIS_PASSWORD=" "$instance_dir/backend/.env"; then
print_status "Redis configuration already exists in .env"
return 0
# Verify the credentials actually work
local redis_user=$(grep "^REDIS_USER=" "$instance_dir/backend/.env" | cut -d'=' -f2 | tr -d '"')
local redis_pass=$(grep "^REDIS_PASSWORD=" "$instance_dir/backend/.env" | cut -d'=' -f2 | tr -d '"')
local redis_db=$(grep "^REDIS_DB=" "$instance_dir/backend/.env" | cut -d'=' -f2 | tr -d '"')
if [ -n "$redis_user" ] && [ -n "$redis_pass" ]; then
# Test with username and password
local ping_works=false
local info_works=false
if redis-cli -h localhost -p 6379 --user "$redis_user" --pass "$redis_pass" --no-auth-warning -n "${redis_db:-0}" ping >/dev/null 2>&1; then
ping_works=true
fi
if redis-cli -h localhost -p 6379 --user "$redis_user" --pass "$redis_pass" --no-auth-warning -n "${redis_db:-0}" info >/dev/null 2>&1; then
info_works=true
fi
if [ "$ping_works" = true ] && [ "$info_works" = true ]; then
print_status "Redis credentials verified with redis-cli (tested ping and info commands)"
# Force refresh the Redis user during updates to ensure correct ACL permissions
# This prevents issues where redis-cli works but Node.js client doesn't
print_info "Refreshing Redis user permissions to ensure compatibility..."
if repair_redis_user "$redis_user" "$redis_pass" "$redis_db"; then
print_status "Redis user permissions refreshed successfully"
return 0
else
print_warning "Could not refresh Redis user, but credentials seem to work - continuing..."
return 0
fi
else
print_warning "Redis credentials not working properly (ping: $ping_works, info: $info_works)"
print_info "Attempting to repair Redis user..."
if repair_redis_user "$redis_user" "$redis_pass" "$redis_db"; then
print_status "Redis user repaired successfully"
return 0
else
print_warning "Could not repair Redis user, will reconfigure from scratch..."
fi
fi
else
print_warning "Redis credentials incomplete in .env (missing user or password)"
fi
fi
fi
print_warning "Redis configuration not found in .env - this is a legacy installation"
print_info "Setting up Redis for this instance..."
print_warning "Redis configuration not found or invalid in .env - setting up Redis for this instance..."
# Detect package manager if not already set
if [ -z "$PKG_INSTALL" ]; then
@@ -2054,6 +2394,39 @@ update_redis_configuration() {
REDIS_USER="patchmon_${DB_SAFE_NAME}"
REDIS_USER_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
# Test Redis connection to determine authentication status
print_info "Testing Redis authentication status..."
local needs_auth=false
local admin_password=""
# Try ping without auth
if redis-cli -h localhost -p 6379 ping >/dev/null 2>&1; then
print_info "Redis is accessible without authentication"
needs_auth=false
else
print_info "Redis requires authentication"
needs_auth=true
# Try to find existing admin password from ACL file
if [ -f /etc/redis/users.acl ] && grep -q "^user admin" /etc/redis/users.acl; then
# Extract password from ACL file (format: >password)
admin_password=$(grep "^user admin" /etc/redis/users.acl | grep -oP '>\K[^ ]+' | head -1)
if [ -n "$admin_password" ]; then
print_info "Found existing admin credentials in ACL file"
# Test admin credentials
if redis-cli -h localhost -p 6379 --user admin --pass "$admin_password" --no-auth-warning ping >/dev/null 2>&1; then
print_status "Existing admin credentials work"
REDIS_PASSWORD="$admin_password"
else
print_warning "Existing admin credentials don't work, will create new configuration"
admin_password=""
fi
fi
fi
fi
# Find available Redis database
print_info "Finding available Redis database..."
local redis_db=0
@@ -2061,9 +2434,14 @@ update_redis_configuration() {
while [ $redis_db -lt $max_attempts ]; do
local key_count
key_count=$(redis-cli -h localhost -p 6379 -n "$redis_db" DBSIZE 2>&1 | grep -v "ERR" || echo "1")
if [ "$key_count" = "0" ] || [ "$key_count" = "(integer) 0" ]; then
if [ "$needs_auth" = true ] && [ -n "$admin_password" ]; then
key_count=$(redis-cli -h localhost -p 6379 --user admin --pass "$admin_password" --no-auth-warning -n "$redis_db" DBSIZE 2>&1 | grep -oP '\d+' || echo "1")
else
key_count=$(redis-cli -h localhost -p 6379 -n "$redis_db" DBSIZE 2>&1 | grep -oP '\d+' || echo "1")
fi
if [ "$key_count" = "0" ]; then
print_status "Found available Redis database: $redis_db"
REDIS_DB=$redis_db
break
@@ -2076,50 +2454,146 @@ update_redis_configuration() {
REDIS_DB=0
fi
# Generate admin password if not exists
REDIS_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
# Configure Redis with ACL if needed
print_info "Configuring Redis ACL..."
# Create ACL file if it doesn't exist
if [ ! -f /etc/redis/users.acl ]; then
touch /etc/redis/users.acl
chown redis:redis /etc/redis/users.acl
chmod 640 /etc/redis/users.acl
fi
# Configure ACL file in redis.conf
if ! grep -q "^aclfile" /etc/redis/redis.conf 2>/dev/null; then
echo "aclfile /etc/redis/users.acl" >> /etc/redis/redis.conf
fi
# Remove requirepass (incompatible with ACL)
if grep -q "^requirepass" /etc/redis/redis.conf 2>/dev/null; then
sed -i 's/^requirepass.*/# &/' /etc/redis/redis.conf
fi
# Create admin user if it doesn't exist
if ! grep -q "^user admin" /etc/redis/users.acl; then
echo "user admin on sanitize-payload >$REDIS_PASSWORD ~* &* +@all" >> /etc/redis/users.acl
if [ "$needs_auth" = false ]; then
print_info "Configuring Redis ACL for security..."
# Generate new admin password
REDIS_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
# Backup redis.conf
if [ -f /etc/redis/redis.conf ]; then
cp /etc/redis/redis.conf /etc/redis/redis.conf.backup.$(date +%Y%m%d_%H%M%S) 2>/dev/null || true
fi
# Create ACL file if it doesn't exist
if [ ! -f /etc/redis/users.acl ]; then
touch /etc/redis/users.acl
chown redis:redis /etc/redis/users.acl
chmod 640 /etc/redis/users.acl
print_status "Created Redis ACL file"
else
# Backup existing ACL file
cp /etc/redis/users.acl /etc/redis/users.acl.backup.$(date +%Y%m%d_%H%M%S) 2>/dev/null || true
print_info "Backed up existing ACL file"
fi
# Configure ACL file in redis.conf
if ! grep -q "^aclfile" /etc/redis/redis.conf 2>/dev/null; then
echo "aclfile /etc/redis/users.acl" >> /etc/redis/redis.conf
print_status "Added ACL file configuration to redis.conf"
fi
# Remove requirepass (incompatible with ACL)
if grep -q "^requirepass" /etc/redis/redis.conf 2>/dev/null; then
sed -i 's/^requirepass.*/# &/' /etc/redis/redis.conf
print_status "Disabled requirepass (incompatible with ACL)"
fi
# Create or update admin user in ACL file
if grep -q "^user admin" /etc/redis/users.acl; then
print_info "Admin user already exists in ACL, updating password..."
# Remove old admin line and add new one
sed -i '/^user admin/d' /etc/redis/users.acl
echo "user admin on sanitize-payload >$REDIS_PASSWORD ~* &* +@all" >> /etc/redis/users.acl
print_status "Updated admin user password"
else
echo "user admin on sanitize-payload >$REDIS_PASSWORD ~* &* +@all" >> /etc/redis/users.acl
print_status "Created admin user in ACL"
fi
# Restart Redis to apply ACL
print_info "Restarting Redis to apply ACL configuration..."
systemctl restart redis-server
sleep 3
sleep 5
# Verify admin can connect
local max_retries=3
local retry=0
local admin_works=false
while [ $retry -lt $max_retries ]; do
if redis-cli -h localhost -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ping >/dev/null 2>&1; then
admin_works=true
break
fi
print_info "Waiting for Redis to be ready... (attempt $((retry + 1))/$max_retries)"
sleep 2
retry=$((retry + 1))
done
if [ "$admin_works" = false ]; then
print_error "Failed to verify admin connection after Redis restart"
print_error "Redis ACL configuration may have issues"
# Try to fix by disabling ACL and using requirepass instead
print_warning "Attempting fallback: using requirepass instead of ACL..."
sed -i 's/^aclfile/# aclfile/' /etc/redis/redis.conf
sed -i "s/^# requirepass .*/requirepass $REDIS_PASSWORD/" /etc/redis/redis.conf
if ! grep -q "^requirepass" /etc/redis/redis.conf; then
echo "requirepass $REDIS_PASSWORD" >> /etc/redis/redis.conf
fi
systemctl restart redis-server
sleep 3
# Test requirepass
if redis-cli -h localhost -p 6379 -a "$REDIS_PASSWORD" --no-auth-warning ping >/dev/null 2>&1; then
print_status "Fallback successful - using requirepass authentication"
# For requirepass, we don't use username
REDIS_USER=""
else
print_error "Fallback also failed - Redis authentication is broken"
return 1
fi
else
print_status "Redis ACL configuration successful"
fi
elif [ -z "$admin_password" ]; then
print_error "Redis requires authentication but no valid admin credentials found"
print_error "Please check /etc/redis/users.acl or /etc/redis/redis.conf"
print_info "Manual fix: Reset Redis authentication or provide admin credentials"
return 1
fi
# Create instance-specific Redis user
print_info "Creating Redis user: $REDIS_USER"
# Try to authenticate with admin (may already exist from another instance)
local acl_result
acl_result=$(redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SETUSER "$REDIS_USER" on ">${REDIS_USER_PASSWORD}" ~* +@all 2>&1)
if [ "$acl_result" = "OK" ] || echo "$acl_result" | grep -q "OK"; then
print_status "Redis user created successfully"
redis-cli -h 127.0.0.1 -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SAVE > /dev/null 2>&1
# Create instance-specific Redis user (only if using ACL)
if [ -n "$REDIS_USER" ]; then
print_info "Creating Redis user: $REDIS_USER"
local acl_result=""
if [ -n "$REDIS_PASSWORD" ]; then
# Try to create user with ACL
acl_result=$(redis-cli -h localhost -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SETUSER "$REDIS_USER" on ">${REDIS_USER_PASSWORD}" ~* +@all 2>&1)
else
# Try without authentication (for legacy setups)
acl_result=$(redis-cli -h localhost -p 6379 ACL SETUSER "$REDIS_USER" on ">${REDIS_USER_PASSWORD}" ~* +@all 2>&1)
fi
if echo "$acl_result" | grep -q "OK"; then
print_status "Redis user created successfully"
# Save ACL users
if [ -n "$REDIS_PASSWORD" ]; then
redis-cli -h localhost -p 6379 --user admin --pass "$REDIS_PASSWORD" --no-auth-warning ACL SAVE >/dev/null 2>&1
else
redis-cli -h localhost -p 6379 ACL SAVE >/dev/null 2>&1
fi
print_status "Redis ACL saved"
# Verify user can connect
if redis-cli -h localhost -p 6379 --user "$REDIS_USER" --pass "$REDIS_USER_PASSWORD" --no-auth-warning -n "$REDIS_DB" ping >/dev/null 2>&1; then
print_status "Redis user verified and working"
else
print_warning "Redis user created but verification failed"
fi
else
print_error "Failed to create Redis user: $acl_result"
print_warning "Will use requirepass mode instead of per-user ACL"
REDIS_USER=""
REDIS_USER_PASSWORD="$REDIS_PASSWORD"
fi
else
print_warning "Could not create Redis user with ACL, trying without authentication..."
# Fallback for systems without ACL configured
redis-cli -h 127.0.0.1 -p 6379 CONFIG SET requirepass "$REDIS_USER_PASSWORD" > /dev/null 2>&1 || true
print_info "Using requirepass authentication (single password, no user-specific ACL)"
REDIS_USER_PASSWORD="$REDIS_PASSWORD"
fi
# Backup existing .env
@@ -2128,18 +2602,27 @@ update_redis_configuration() {
# Add Redis configuration to .env
print_info "Adding Redis configuration to .env..."
# Use correct password variable
local redis_pass_for_env="${REDIS_USER_PASSWORD:-$REDIS_PASSWORD}"
cat >> "$instance_dir/backend/.env" << EOF
# Redis Configuration (added during update)
# Redis Configuration (added during update on $(date))
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_USER=$REDIS_USER
REDIS_PASSWORD=$REDIS_USER_PASSWORD
REDIS_PASSWORD=$redis_pass_for_env
REDIS_DB=$REDIS_DB
EOF
print_status "Redis configuration added to .env"
print_info "Redis User: $REDIS_USER"
if [ -n "$REDIS_USER" ]; then
print_info "Redis Mode: ACL with user '$REDIS_USER'"
else
print_info "Redis Mode: requirepass (legacy single-password auth)"
fi
print_info "Redis Database: $REDIS_DB"
return 0
@@ -2543,11 +3026,81 @@ update_installation() {
print_info "Building frontend..."
npm run build
# Run database migrations and generate Prisma client
# Run database migrations with self-healing
print_info "Running database migrations..."
cd "$instance_dir/backend"
# Generate Prisma client first
npx prisma generate
npx prisma migrate deploy
local max_attempts=3
local attempt=1
local migration_success=false
while [ $attempt -le $max_attempts ]; do
print_info "Migration attempt $attempt of $max_attempts..."
# Try to run migrations
local migrate_output
migrate_output=$(npx prisma migrate deploy 2>&1 || echo "MIGRATION_FAILED")
# Check if migration succeeded
if ! echo "$migrate_output" | grep -q "MIGRATION_FAILED\|Error:\|P3009"; then
print_status "Migrations completed successfully"
migration_success=true
break
fi
# Check specifically for P3009 (failed migrations found)
if echo "$migrate_output" | grep -q "P3009\|migrate found failed migrations"; then
print_warning "Detected failed migrations (P3009 error)"
# Extract the failed migration name if possible
local failed_migration
failed_migration=$(echo "$migrate_output" | grep -oP "The \`\K[^\`]+" | head -1 || echo "")
if [ -n "$failed_migration" ]; then
print_info "Failed migration identified: $failed_migration"
fi
# Attempt to fix failed migrations
print_info "Attempting to self-heal migration issues..."
if fix_failed_migrations "$DB_NAME" "$DB_USER" "$DB_PASS" "$DB_HOST"; then
print_status "Migration issues resolved, retrying..."
attempt=$((attempt + 1))
sleep 2
continue
else
print_error "Failed to resolve migration issues"
print_warning "Attempting alternative resolution method..."
# Alternative: Mark migration as completed if tables exist
print_info "Checking if migration changes are already applied..."
PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c \
"UPDATE _prisma_migrations SET finished_at = NOW(), logs = 'Manually resolved by update script' WHERE migration_name = '$failed_migration' AND finished_at IS NULL;" >/dev/null 2>&1
attempt=$((attempt + 1))
sleep 2
continue
fi
else
# Other migration error
print_error "Migration failed with error:"
echo "$migrate_output" | grep -A 10 "Error:"
# Show helpful information
print_info "Migration status:"
npx prisma migrate status 2>&1 || true
break
fi
done
if [ "$migration_success" = false ]; then
print_error "Migrations failed after $max_attempts attempts"
print_warning "The update will continue, but you may need to manually resolve migration issues"
print_info "Check migrations: cd $instance_dir/backend && npx prisma migrate status"
print_info "View failed migrations: PGPASSWORD=\"$DB_PASS\" psql -h \"$DB_HOST\" -U \"$DB_USER\" -d \"$DB_NAME\" -c \"SELECT * FROM _prisma_migrations WHERE finished_at IS NULL;\""
fi
# Check and update Redis configuration if needed (for legacy installations)
update_redis_configuration
@@ -2563,7 +3116,7 @@ update_installation() {
systemctl start "$service_name"
# Wait a moment and check status
sleep 3
sleep 5
if systemctl is-active --quiet "$service_name"; then
print_success "✅ Update completed successfully!"
@@ -2582,6 +3135,43 @@ update_installation() {
echo ""
else
print_error "Service failed to start after update"
echo ""
# Show last 25 lines of service logs for debugging
print_warning "=== Last 25 lines of service logs ==="
journalctl -u "$service_name" -n 25 --no-pager || true
print_warning "==================================="
echo ""
# Check for specific error patterns
local logs=$(journalctl -u "$service_name" -n 50 --no-pager 2>/dev/null || echo "")
if echo "$logs" | grep -q "WRONGPASS\|NOAUTH"; then
print_error "❌ Detected Redis authentication error!"
print_info "The service cannot authenticate with Redis."
echo ""
print_info "Current Redis configuration in .env:"
grep "^REDIS_" "$instance_dir/backend/.env" || true
echo ""
print_info "Quick fix - Try reconfiguring Redis:"
print_info " 1. Check Redis ACL users:"
print_info " redis-cli ACL LIST"
echo ""
print_info " 2. Test Redis connection with credentials from .env:"
local test_user=$(grep "^REDIS_USER=" "$instance_dir/backend/.env" | cut -d'=' -f2)
local test_pass=$(grep "^REDIS_PASSWORD=" "$instance_dir/backend/.env" | cut -d'=' -f2)
local test_db=$(grep "^REDIS_DB=" "$instance_dir/backend/.env" | cut -d'=' -f2)
print_info " redis-cli --user $test_user --pass $test_pass -n ${test_db:-0} ping"
echo ""
elif echo "$logs" | grep -q "ECONNREFUSED"; then
print_error "❌ Detected connection refused error!"
print_info "Check if required services are running:"
print_info " systemctl status postgresql"
print_info " systemctl status redis-server"
elif echo "$logs" | grep -q "Error:"; then
print_error "❌ Application error detected in logs"
fi
echo ""
print_warning "ROLLBACK INSTRUCTIONS:"
print_info "1. Restore code:"
@@ -2594,7 +3184,7 @@ update_installation() {
print_info "3. Restart service:"
print_info " sudo systemctl start $service_name"
echo ""
print_info "Check logs: journalctl -u $service_name -f"
print_info "View full logs: journalctl -u $service_name -f"
exit 1
fi
}

715
tools/diagnostics.sh Executable file
View File

@@ -0,0 +1,715 @@
#!/bin/bash
# PatchMon Diagnostics Collection Script
# Collects system information, logs, and configuration for troubleshooting
# Usage: sudo bash diagnostics.sh [instance-name]
# Note: Not using 'set -e' because we want to continue even if some commands fail
set -o pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Print functions
print_status() {
echo -e "${GREEN}$1${NC}"
}
print_info() {
echo -e "${BLUE} $1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
print_success() {
echo -e "${GREEN}🎉 $1${NC}"
}
# Check if running as root
if [[ $EUID -ne 0 ]]; then
print_error "This script must be run as root"
print_info "Please run: sudo bash $0"
exit 1
fi
# Function to sanitize sensitive information
sanitize_sensitive() {
local input="$1"
# Replace passwords, secrets, and tokens with [REDACTED]
echo "$input" | \
sed -E 's/(PASSWORD|SECRET|TOKEN|KEY|PASS)=[^"]*$/\1=[REDACTED]/gi' | \
sed -E 's/(PASSWORD|SECRET|TOKEN|KEY|PASS)="[^"]*"/\1="[REDACTED]"/gi' | \
sed -E 's/(password|secret|token|key|pass)": *"[^"]*"/\1": "[REDACTED]"/gi' | \
sed -E 's/(>)[a-zA-Z0-9+\/=]{20,}/\1[REDACTED]/g' | \
sed -E 's|postgresql://([^:]+):([^@]+)@|postgresql://\1:[REDACTED]@|g' | \
sed -E 's|mysql://([^:]+):([^@]+)@|mysql://\1:[REDACTED]@|g' | \
sed -E 's|mongodb://([^:]+):([^@]+)@|mongodb://\1:[REDACTED]@|g'
}
# Function to detect PatchMon installations
detect_installations() {
local installations=()
if [ ! -d "/opt" ]; then
print_error "/opt directory does not exist"
return 1
fi
for dir in /opt/*/; do
# Skip if no directories found
[ -d "$dir" ] || continue
local dirname=$(basename "$dir")
# Skip backup directories
if [[ "$dirname" =~ \.backup\. ]]; then
continue
fi
# Check if it's a PatchMon installation
if [ -f "$dir/backend/package.json" ]; then
if grep -q "patchmon" "$dir/backend/package.json" 2>/dev/null; then
installations+=("$dirname")
fi
fi
done
echo "${installations[@]}"
}
# Function to select installation
select_installation() {
local installations=($(detect_installations))
if [ ${#installations[@]} -eq 0 ]; then
print_error "No PatchMon installations found in /opt" >&2
exit 1
fi
if [ -n "$1" ]; then
# Use provided instance name
if [[ " ${installations[@]} " =~ " $1 " ]]; then
echo "$1"
return 0
else
print_error "Instance '$1' not found" >&2
exit 1
fi
fi
# Send status messages to stderr so they don't contaminate the return value
print_info "Found ${#installations[@]} installation(s):" >&2
echo "" >&2
local i=1
declare -A install_map
for install in "${installations[@]}"; do
# Get service status
local status="unknown"
if systemctl is-active --quiet "$install" 2>/dev/null; then
status="${GREEN}running${NC}"
elif systemctl is-enabled --quiet "$install" 2>/dev/null; then
status="${RED}stopped${NC}"
fi
printf "%2d. %-30s (%b)\n" "$i" "$install" "$status" >&2
install_map[$i]="$install"
i=$((i + 1))
done
echo "" >&2
# If only one installation, select it automatically
if [ ${#installations[@]} -eq 1 ]; then
print_info "Only one installation found, selecting automatically: ${installations[0]}" >&2
echo "${installations[0]}"
return 0
fi
# Multiple installations - prompt user
printf "${BLUE}Select installation number [1]: ${NC}" >&2
read -r selection </dev/tty
selection=${selection:-1}
if [[ "$selection" =~ ^[0-9]+$ ]] && [ -n "${install_map[$selection]}" ]; then
echo "${install_map[$selection]}"
return 0
else
print_error "Invalid selection" >&2
exit 1
fi
}
# Main script
main() {
# Capture the directory where script is run from at the very start
ORIGINAL_DIR=$(pwd)
echo -e "${BLUE}====================================================${NC}"
echo -e "${BLUE} PatchMon Diagnostics Collection${NC}"
echo -e "${BLUE}====================================================${NC}"
echo ""
# Select instance
instance_name=$(select_installation "$1")
instance_dir="/opt/$instance_name"
print_info "Selected instance: $instance_name"
print_info "Directory: $instance_dir"
echo ""
# Create single diagnostics file in the original directory
timestamp=$(date +%Y%m%d_%H%M%S)
diag_file="${ORIGINAL_DIR}/patchmon_diagnostics_${instance_name}_${timestamp}.txt"
print_info "Collecting diagnostics to: $diag_file"
echo ""
# Initialize the diagnostics file with header
cat > "$diag_file" << EOF
===================================================
PatchMon Diagnostics Report
===================================================
Instance: $instance_name
Generated: $(date)
Hostname: $(hostname)
Generated from: ${ORIGINAL_DIR}
===================================================
EOF
# ========================================
# 1. System Information
# ========================================
print_info "Collecting system information..."
cat >> "$diag_file" << EOF
=== System Information ===
OS: $(cat /etc/os-release 2>/dev/null | grep PRETTY_NAME | cut -d'"' -f2 || echo "Unknown")
Kernel: $(uname -r)
Uptime: $(uptime)
=== CPU Information ===
$(lscpu | grep -E "Model name|CPU\(s\)|Thread|Core" || echo "Not available")
=== Memory Information ===
$(free -h)
=== Disk Usage ===
$(df -h | grep -E "Filesystem|/dev/|/opt")
=== Network Interfaces ===
$(ip -br addr)
===================================================
EOF
# ========================================
# 2. PatchMon Instance Information
# ========================================
print_info "Collecting instance information..."
cat >> "$diag_file" << EOF
=== PatchMon Instance Information ===
=== Directory Structure ===
$(ls -lah "$instance_dir" 2>/dev/null || echo "Cannot access directory")
=== Backend Package Info ===
$(cat "$instance_dir/backend/package.json" 2>/dev/null | grep -E "name|version" || echo "Not found")
=== Frontend Package Info ===
$(cat "$instance_dir/frontend/package.json" 2>/dev/null | grep -E "name|version" || echo "Not found")
=== Deployment Info ===
$(cat "$instance_dir/deployment-info.txt" 2>/dev/null || echo "No deployment-info.txt found")
===================================================
EOF
# ========================================
# 3. Environment Configuration (Sanitized)
# ========================================
print_info "Collecting environment configuration (sanitized)..."
echo "" >> "$diag_file"
echo "=== Backend Environment Configuration (Sanitized) ===" >> "$diag_file"
if [ -f "$instance_dir/backend/.env" ]; then
sanitize_sensitive "$(cat "$instance_dir/backend/.env")" >> "$diag_file"
else
echo "Backend .env file not found" >> "$diag_file"
fi
echo "" >> "$diag_file"
# ========================================
# 4. Service Status and Configuration
# ========================================
print_info "Collecting service information..."
cat >> "$diag_file" << EOF
=== Service Status and Configuration ===
=== Service Status ===
$(systemctl status "$instance_name" 2>/dev/null || echo "Service not found")
=== Service File ===
$(cat "/etc/systemd/system/${instance_name}.service" 2>/dev/null || echo "Service file not found")
=== Service is-enabled ===
$(systemctl is-enabled "$instance_name" 2>/dev/null || echo "unknown")
=== Service is-active ===
$(systemctl is-active "$instance_name" 2>/dev/null || echo "unknown")
===================================================
EOF
# ========================================
# 5. Service Logs
# ========================================
print_info "Collecting service logs..."
echo "" >> "$diag_file"
echo "=== Service Logs (last 500 lines) ===" >> "$diag_file"
journalctl -u "$instance_name" -n 500 --no-pager >> "$diag_file" 2>&1 || \
echo "Could not retrieve service logs" >> "$diag_file"
echo "" >> "$diag_file"
# ========================================
# 6. Nginx Configuration
# ========================================
print_info "Collecting nginx configuration..."
cat >> "$diag_file" << EOF
=== Nginx Configuration ===
=== Nginx Status ===
$(systemctl status nginx 2>/dev/null | head -20 || echo "Nginx not found")
=== Site Configuration ===
$(cat "/etc/nginx/sites-available/$instance_name" 2>/dev/null || echo "Nginx config not found")
=== Nginx Error Log (last 100 lines) ===
$(tail -100 /var/log/nginx/error.log 2>/dev/null || echo "Error log not accessible")
=== Nginx Access Log (last 50 lines) ===
$(tail -50 /var/log/nginx/access.log 2>/dev/null || echo "Access log not accessible")
=== Nginx Test ===
$(nginx -t 2>&1 || echo "Nginx test failed")
===================================================
EOF
# ========================================
# 7. Database Connection Test
# ========================================
print_info "Testing database connection..."
echo "" >> "$diag_file"
echo "=== Database Information ===" >> "$diag_file"
echo "" >> "$diag_file"
if [ -f "$instance_dir/backend/.env" ]; then
# Load .env
set -a
source "$instance_dir/backend/.env"
set +a
# Parse DATABASE_URL
if [ -n "$DATABASE_URL" ]; then
DB_USER=$(echo "$DATABASE_URL" | sed -n 's|postgresql://\([^:]*\):.*|\1|p')
DB_PASS=$(echo "$DATABASE_URL" | sed -n 's|postgresql://[^:]*:\([^@]*\)@.*|\1|p')
DB_HOST=$(echo "$DATABASE_URL" | sed -n 's|.*@\([^:]*\):.*|\1|p')
DB_PORT=$(echo "$DATABASE_URL" | sed -n 's|.*:\([0-9]*\)/.*|\1|p')
DB_NAME=$(echo "$DATABASE_URL" | sed -n 's|.*/\([^?]*\).*|\1|p')
cat >> "$diag_file" << EOF
=== Database Connection Details ===
Host: $DB_HOST
Port: $DB_PORT
Database: $DB_NAME
User: $DB_USER
=== PostgreSQL Status ===
$(systemctl status postgresql 2>/dev/null | head -20 || echo "PostgreSQL status not available")
=== Connection Test ===
EOF
if PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c "SELECT version();" >> "$diag_file" 2>&1; then
echo "✅ Database connection: SUCCESSFUL" >> "$diag_file"
else
echo "❌ Database connection: FAILED" >> "$diag_file"
fi
echo "" >> "$diag_file"
echo "=== Database Size ===" >> "$diag_file"
PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c "
SELECT
pg_size_pretty(pg_database_size('$DB_NAME')) as database_size;
" >> "$diag_file" 2>&1 || echo "Could not get database size" >> "$diag_file"
echo "" >> "$diag_file"
echo "=== Table Sizes ===" >> "$diag_file"
PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c "
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
LIMIT 10;
" >> "$diag_file" 2>&1 || echo "Could not get table sizes" >> "$diag_file"
echo "" >> "$diag_file"
echo "=== Migration Status ===" >> "$diag_file"
cd "$instance_dir/backend"
npx prisma migrate status >> "$diag_file" 2>&1 || echo "Could not get migration status" >> "$diag_file"
echo "===================================================" >> "$diag_file"
else
echo "DATABASE_URL not found in .env" >> "$diag_file"
fi
else
echo ".env file not found" >> "$diag_file"
fi
# ========================================
# 8. Redis Connection Test
# ========================================
print_info "Testing Redis connection..."
if [ -f "$instance_dir/backend/.env" ]; then
# Load .env
set -a
source "$instance_dir/backend/.env"
set +a
cat >> "$diag_file" << EOF
===================================================
Redis Information
===================================================
=== Redis Connection Details ===
Host: ${REDIS_HOST:-localhost}
Port: ${REDIS_PORT:-6379}
User: ${REDIS_USER:-(none)}
Database: ${REDIS_DB:-0}
=== Redis Status ===
$(systemctl status redis-server 2>/dev/null | head -20 || echo "Redis status not available")
=== Connection Test ===
EOF
# Test connection
if [ -n "$REDIS_USER" ] && [ -n "$REDIS_PASSWORD" ]; then
if redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" ping >> "$diag_file" 2>&1; then
echo "✅ Redis connection (with user): SUCCESSFUL" >> "$diag_file"
echo "" >> "$diag_file"
echo "=== Redis INFO ===" >> "$diag_file"
redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" INFO >> "$diag_file" 2>&1
echo "" >> "$diag_file"
echo "=== Redis Database Size ===" >> "$diag_file"
redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" DBSIZE >> "$diag_file" 2>&1
else
echo "❌ Redis connection (with user): FAILED" >> "$diag_file"
fi
elif [ -n "$REDIS_PASSWORD" ]; then
if redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" ping >> "$diag_file" 2>&1; then
echo "✅ Redis connection (requirepass): SUCCESSFUL" >> "$diag_file"
echo "" >> "$diag_file"
echo "=== Redis INFO ===" >> "$diag_file"
redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" INFO >> "$diag_file" 2>&1
echo "" >> "$diag_file"
echo "=== Redis Database Size ===" >> "$diag_file"
redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" DBSIZE >> "$diag_file" 2>&1
else
echo "❌ Redis connection (requirepass): FAILED" >> "$diag_file"
fi
else
if redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -n "${REDIS_DB:-0}" ping >> "$diag_file" 2>&1; then
echo "✅ Redis connection (no auth): SUCCESSFUL" >> "$diag_file"
else
echo "❌ Redis connection: FAILED" >> "$diag_file"
fi
fi
echo "" >> "$diag_file"
echo "=== Redis ACL Users ===" >> "$diag_file"
if [ -n "$REDIS_USER" ] && [ -n "$REDIS_PASSWORD" ]; then
redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning ACL LIST >> "$diag_file"
elif [ -n "$REDIS_PASSWORD" ]; then
redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning ACL LIST >> "$diag_file"
fi
echo "===================================================" >> "$diag_file"
else
echo ".env file not found" >> "$diag_file"
fi
# ========================================
# 9. Network and Port Information
# ========================================
print_info "Collecting network information..."
# Get backend port from .env
local backend_port=$(grep '^PORT=' "$instance_dir/backend/.env" 2>/dev/null | cut -d'=' -f2 | tr -d ' ' || echo "3000")
cat >> "$diag_file" << EOF
===================================================
Network and Port Information
===================================================
=== Listening Ports ===
$(ss -tlnp | grep -E "LISTEN|nginx|node|postgres|redis" || netstat -tlnp | grep -E "LISTEN|nginx|node|postgres|redis" || echo "Could not get port information")
=== Active Connections ===
$(ss -tn state established | head -20 || echo "Could not get connection information")
=== Backend Port Connections (Port $backend_port) ===
Total connections to backend: $(ss -tn | grep ":$backend_port" | wc -l || echo "0")
$(ss -tn | grep ":$backend_port" | head -10 || echo "No connections found")
=== PostgreSQL Connections ===
EOF
# Get PostgreSQL connection count
if [ -n "$DB_PASS" ] && [ -n "$DB_USER" ] && [ -n "$DB_NAME" ]; then
PGPASSWORD="$DB_PASS" psql -h "${DB_HOST:-localhost}" -U "$DB_USER" -d "$DB_NAME" -c "
SELECT
count(*) as total_connections,
count(*) FILTER (WHERE state = 'active') as active_connections,
count(*) FILTER (WHERE state = 'idle') as idle_connections
FROM pg_stat_activity
WHERE datname = '$DB_NAME';
" >> "$diag_file" 2>&1 || echo "Could not get PostgreSQL connection stats" >> "$diag_file"
echo "" >> "$diag_file"
echo "=== PostgreSQL Connection Details ===" >> "$diag_file"
PGPASSWORD="$DB_PASS" psql -h "${DB_HOST:-localhost}" -U "$DB_USER" -d "$DB_NAME" -c "
SELECT
pid,
usename,
application_name,
client_addr,
state,
query_start,
state_change
FROM pg_stat_activity
WHERE datname = '$DB_NAME'
ORDER BY query_start DESC
LIMIT 20;
" >> "$diag_file" 2>&1 || echo "Could not get connection details" >> "$diag_file"
else
echo "Database credentials not available" >> "$diag_file"
fi
echo "" >> "$diag_file"
echo "=== Redis Connections ===" >> "$diag_file"
# Get Redis connection count
if [ -n "$REDIS_USER" ] && [ -n "$REDIS_PASSWORD" ]; then
redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" INFO clients >> "$diag_file" 2>&1 || echo "Could not get Redis connection info" >> "$diag_file"
elif [ -n "$REDIS_PASSWORD" ]; then
redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning -n "${REDIS_DB:-0}" INFO clients >> "$diag_file" 2>&1 || echo "Could not get Redis connection info" >> "$diag_file"
fi
cat >> "$diag_file" << EOF
=== Firewall Status (UFW) ===
$(ufw status 2>/dev/null || echo "UFW not available")
=== Firewall Status (iptables) ===
$(iptables -L -n | head -50 2>/dev/null || echo "iptables not available")
===================================================
EOF
# ========================================
# 10. Process Information
# ========================================
print_info "Collecting process information..."
cat >> "$diag_file" << EOF
===================================================
Process Information
===================================================
=== PatchMon Node Processes ===
$(ps aux | grep -E "node.*$instance_dir|PID" | grep -v grep || echo "No processes found")
=== Top Processes (CPU) ===
$(ps aux --sort=-%cpu | head -15)
=== Top Processes (Memory) ===
$(ps aux --sort=-%mem | head -15)
===================================================
EOF
# ========================================
# 11. SSL Certificate Information
# ========================================
print_info "Collecting SSL certificate information..."
cat >> "$diag_file" << EOF
===================================================
SSL Certificate Information
===================================================
=== Certbot Certificates ===
$(certbot certificates 2>/dev/null || echo "Certbot not available or no certificates")
=== SSL Certificate Files ===
$(ls -lh /etc/letsencrypt/live/$instance_name/ 2>/dev/null || echo "No SSL certificates found for $instance_name")
===================================================
EOF
# ========================================
# 12. Recent System Logs
# ========================================
print_info "Collecting recent system logs..."
journalctl -n 200 --no-pager >> "$diag_file" 2>&1 || \
echo "Could not retrieve system logs" >> "$diag_file"
# ========================================
# 13. Installation Log (if exists)
# ========================================
print_info "Collecting installation log..."
echo "" >> "$diag_file"
echo "=== Installation Log (last 200 lines) ===" >> "$diag_file"
if [ -f "$instance_dir/patchmon-install.log" ]; then
tail -200 "$instance_dir/patchmon-install.log" >> "$diag_file" 2>&1
else
echo "No installation log found" >> "$diag_file"
fi
echo "" >> "$diag_file"
# ========================================
# 14. Node.js and npm Information
# ========================================
print_info "Collecting Node.js information..."
cat >> "$diag_file" << EOF
===================================================
Node.js and npm Information
===================================================
=== Node.js Version ===
$(node --version 2>/dev/null || echo "Node.js not found")
=== npm Version ===
$(npm --version 2>/dev/null || echo "npm not found")
=== Backend Dependencies ===
$(cd "$instance_dir/backend" && npm list --depth=0 2>/dev/null || echo "Could not list backend dependencies")
===================================================
EOF
# ========================================
# Finalize diagnostics file
# ========================================
print_info "Finalizing diagnostics file..."
echo "" >> "$diag_file"
echo "====================================================" >> "$diag_file"
echo "END OF DIAGNOSTICS REPORT" >> "$diag_file"
echo "====================================================" >> "$diag_file"
echo "" >> "$diag_file"
echo "IMPORTANT: Sensitive Information" >> "$diag_file"
echo "Passwords, secrets, and tokens have been sanitized" >> "$diag_file"
echo "and replaced with [REDACTED]. However, please review" >> "$diag_file"
echo "before sharing to ensure no sensitive data is included." >> "$diag_file"
echo "====================================================" >> "$diag_file"
print_status "Diagnostics file created: $diag_file"
# ========================================
# Display summary
# ========================================
echo ""
echo -e "${GREEN}====================================================${NC}"
echo -e "${GREEN} Diagnostics Collection Complete!${NC}"
echo -e "${GREEN}====================================================${NC}"
echo ""
# Get service statuses and file size
local service_status=$(systemctl is-active "$instance_name" 2>/dev/null || echo "unknown")
local nginx_status=$(systemctl is-active nginx 2>/dev/null || echo "unknown")
local postgres_status=$(systemctl is-active postgresql 2>/dev/null || echo "unknown")
local redis_status=$(systemctl is-active redis-server 2>/dev/null || echo "unknown")
local file_size=$(du -h "$diag_file" 2>/dev/null | cut -f1 || echo "unknown")
local line_count=$(wc -l < "$diag_file" 2>/dev/null || echo "unknown")
# Get connection counts for summary
local backend_port=$(grep '^PORT=' "$instance_dir/backend/.env" 2>/dev/null | cut -d'=' -f2 | tr -d ' ' || echo "3000")
local backend_conn_count=$(ss -tn 2>/dev/null | grep ":$backend_port" | wc -l || echo "0")
local db_conn_count="N/A"
if [ -n "$DB_PASS" ] && [ -n "$DB_USER" ] && [ -n "$DB_NAME" ]; then
db_conn_count=$(PGPASSWORD="$DB_PASS" psql -h "${DB_HOST:-localhost}" -U "$DB_USER" -d "$DB_NAME" -t -A -c "SELECT count(*) FROM pg_stat_activity WHERE datname = '$DB_NAME';" 2>/dev/null || echo "N/A")
fi
local redis_conn_count="N/A"
if [ -n "$REDIS_USER" ] && [ -n "$REDIS_PASSWORD" ]; then
redis_conn_count=$(redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" --user "$REDIS_USER" --pass "$REDIS_PASSWORD" --no-auth-warning INFO clients 2>/dev/null | grep "connected_clients:" | cut -d':' -f2 | tr -d '\r' || echo "N/A")
elif [ -n "$REDIS_PASSWORD" ]; then
redis_conn_count=$(redis-cli -h "${REDIS_HOST:-localhost}" -p "${REDIS_PORT:-6379}" -a "$REDIS_PASSWORD" --no-auth-warning INFO clients 2>/dev/null | grep "connected_clients:" | cut -d':' -f2 | tr -d '\r' || echo "N/A")
fi
# Compact, copyable summary
echo -e "${BLUE}═══════════════════════════════════════════════════${NC}"
echo -e "${BLUE}DIAGNOSTICS SUMMARY (copy-paste friendly)${NC}"
echo -e "${BLUE}═══════════════════════════════════════════════════${NC}"
echo "Instance: $instance_name"
echo "File: $diag_file"
echo "Size: $file_size ($line_count lines)"
echo "Generated: $(date '+%Y-%m-%d %H:%M:%S')"
echo "---"
echo "Service Status: $service_status"
echo "Nginx Status: $nginx_status"
echo "PostgreSQL: $postgres_status"
echo "Redis: $redis_status"
echo "---"
echo "Backend Port: $backend_port (Active Connections: $backend_conn_count)"
echo "Database Connections: $db_conn_count"
echo "Redis Connections: $redis_conn_count"
echo "---"
echo "View: cat $(basename "$diag_file")"
echo "Or: less $(basename "$diag_file")"
echo "Share: Send $(basename "$diag_file") to support"
echo -e "${BLUE}═══════════════════════════════════════════════════${NC}"
echo ""
print_warning "Review file before sharing - sensitive data has been sanitized"
echo ""
print_success "Done!"
}
# Run main function
main "$@"

286
tools/fix-migrations.sh Executable file
View File

@@ -0,0 +1,286 @@
#!/bin/bash
# PatchMon Migration Fixer
# Standalone script to detect and fix failed Prisma migrations
# Usage: sudo bash fix-migrations.sh [instance-name]
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Print functions
print_status() {
echo -e "${GREEN}$1${NC}"
}
print_info() {
echo -e "${BLUE} $1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
# Check if running as root
if [[ $EUID -ne 0 ]]; then
print_error "This script must be run as root"
print_info "Please run: sudo bash $0"
exit 1
fi
# Function to detect PatchMon installations
detect_installations() {
local installations=()
if [ -d "/opt" ]; then
for dir in /opt/*/; do
local dirname=$(basename "$dir")
# Skip backup directories
if [[ "$dirname" =~ \.backup\. ]]; then
continue
fi
# Check if it's a PatchMon installation
if [ -f "$dir/backend/package.json" ] && grep -q "patchmon" "$dir/backend/package.json" 2>/dev/null; then
installations+=("$dirname")
fi
done
fi
echo "${installations[@]}"
}
# Function to select installation
select_installation() {
local installations=($(detect_installations))
if [ ${#installations[@]} -eq 0 ]; then
print_error "No PatchMon installations found in /opt"
exit 1
fi
if [ -n "$1" ]; then
# Use provided instance name
if [[ " ${installations[@]} " =~ " $1 " ]]; then
echo "$1"
return 0
else
print_error "Instance '$1' not found"
exit 1
fi
fi
print_info "Found ${#installations[@]} installation(s):"
echo ""
local i=1
declare -A install_map
for install in "${installations[@]}"; do
printf "%2d. %s\n" "$i" "$install"
install_map[$i]="$install"
i=$((i + 1))
done
echo ""
echo -n -e "${BLUE}Select installation number [1]: ${NC}"
read -r selection
selection=${selection:-1}
if [[ "$selection" =~ ^[0-9]+$ ]] && [ -n "${install_map[$selection]}" ]; then
echo "${install_map[$selection]}"
return 0
else
print_error "Invalid selection"
exit 1
fi
}
# Function to check and fix failed migrations
fix_failed_migrations() {
local db_name="$1"
local db_user="$2"
local db_pass="$3"
local db_host="${4:-localhost}"
print_info "Checking for failed migrations in database..."
# Query for failed migrations
local failed_migrations
failed_migrations=$(PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -t -A -c \
"SELECT migration_name FROM _prisma_migrations WHERE finished_at IS NULL AND started_at IS NOT NULL;" 2>/dev/null || echo "")
if [ -z "$failed_migrations" ]; then
print_status "No failed migrations found"
return 0
fi
print_warning "Found failed migration(s):"
echo "$failed_migrations" | while read -r migration; do
[ -n "$migration" ] && print_warning " - $migration"
done
echo ""
print_info "What would you like to do?"
echo " 1. Clean and retry (delete failed records and re-run migration)"
echo " 2. Mark as completed (if schema changes are already applied)"
echo " 3. Show migration details only"
echo " 4. Cancel"
echo ""
echo -n -e "${BLUE}Select option [1]: ${NC}"
read -r option
option=${option:-1}
case $option in
1)
print_info "Cleaning failed migrations and preparing for retry..."
echo "$failed_migrations" | while read -r migration; do
if [ -n "$migration" ]; then
print_info "Processing: $migration"
# Mark as rolled back
PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c \
"UPDATE _prisma_migrations SET rolled_back_at = NOW() WHERE migration_name = '$migration' AND finished_at IS NULL;" >/dev/null 2>&1
# Delete the failed record
PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c \
"DELETE FROM _prisma_migrations WHERE migration_name = '$migration' AND finished_at IS NULL;" >/dev/null 2>&1
print_status "Cleared: $migration"
fi
done
print_status "Failed migrations cleared - ready to retry"
return 0
;;
2)
print_info "Marking migrations as completed..."
echo "$failed_migrations" | while read -r migration; do
if [ -n "$migration" ]; then
print_info "Marking as complete: $migration"
PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c \
"UPDATE _prisma_migrations SET finished_at = NOW(), logs = 'Manually resolved by fix-migrations.sh' WHERE migration_name = '$migration' AND finished_at IS NULL;" >/dev/null 2>&1
print_status "Marked complete: $migration"
fi
done
print_status "All migrations marked as completed"
return 0
;;
3)
print_info "Migration details:"
PGPASSWORD="$db_pass" psql -h "$db_host" -U "$db_user" -d "$db_name" -c \
"SELECT migration_name, started_at, finished_at, rolled_back_at, logs FROM _prisma_migrations WHERE finished_at IS NULL AND started_at IS NOT NULL;"
return 0
;;
4)
print_info "Cancelled"
return 1
;;
*)
print_error "Invalid option"
return 1
;;
esac
}
# Main script
main() {
echo -e "${BLUE}====================================================${NC}"
echo -e "${BLUE} PatchMon Migration Fixer${NC}"
echo -e "${BLUE}====================================================${NC}"
echo ""
# Select instance
instance_name=$(select_installation "$1")
instance_dir="/opt/$instance_name"
print_info "Selected instance: $instance_name"
print_info "Directory: $instance_dir"
echo ""
# Load .env to get database credentials
if [ ! -f "$instance_dir/backend/.env" ]; then
print_error "Cannot find .env file at $instance_dir/backend/.env"
exit 1
fi
# Source .env
set -a
source "$instance_dir/backend/.env"
set +a
# Parse DATABASE_URL
if [ -z "$DATABASE_URL" ]; then
print_error "DATABASE_URL not found in .env file"
exit 1
fi
DB_USER=$(echo "$DATABASE_URL" | sed -n 's|postgresql://\([^:]*\):.*|\1|p')
DB_PASS=$(echo "$DATABASE_URL" | sed -n 's|postgresql://[^:]*:\([^@]*\)@.*|\1|p')
DB_HOST=$(echo "$DATABASE_URL" | sed -n 's|.*@\([^:]*\):.*|\1|p')
DB_NAME=$(echo "$DATABASE_URL" | sed -n 's|.*/\([^?]*\).*|\1|p')
print_info "Database: $DB_NAME"
print_info "User: $DB_USER"
print_info "Host: $DB_HOST"
echo ""
# Test database connection
print_info "Testing database connection..."
if ! PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" -c "SELECT 1;" >/dev/null 2>&1; then
print_error "Cannot connect to database"
exit 1
fi
print_status "Database connection successful"
echo ""
# Check Prisma migration status
print_info "Checking Prisma migration status..."
cd "$instance_dir/backend"
echo ""
echo -e "${YELLOW}=== Prisma Migration Status ===${NC}"
npx prisma migrate status 2>&1 || true
echo -e "${YELLOW}==============================${NC}"
echo ""
# Check for failed migrations
fix_failed_migrations "$DB_NAME" "$DB_USER" "$DB_PASS" "$DB_HOST"
# Ask if user wants to run migrations now
echo ""
echo -n -e "${BLUE}Do you want to run 'npx prisma migrate deploy' now? [y/N]: ${NC}"
read -r run_migrate
if [[ "$run_migrate" =~ ^[Yy] ]]; then
print_info "Running migrations..."
cd "$instance_dir/backend"
if npx prisma migrate deploy; then
print_status "Migrations completed successfully!"
else
print_error "Migration failed"
print_info "You may need to run this script again or investigate further"
exit 1
fi
else
print_info "Skipped migration deployment"
print_info "Run manually: cd $instance_dir/backend && npx prisma migrate deploy"
fi
echo ""
print_status "Done!"
}
# Run main function
main "$@"

41
tools/fixconnlimit.sh Normal file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
# Script to update hardcoded connection pool values in prisma.js
# Usage: ./update_pool_values.sh [connection_limit] [pool_timeout] [connect_timeout] [idle_timeout] [max_lifetime]
set -e
FILE="${1:-backend/src/config/prisma.js}"
# Get values from arguments or use defaults
NEW_CONN_LIMIT="${2:-30}"
NEW_POOL_TIMEOUT="${3:-20}"
NEW_CONNECT_TIMEOUT="${4:-10}"
NEW_IDLE_TIMEOUT="${5:-300}"
NEW_MAX_LIFETIME="${6:-1800}"
if [ ! -f "$FILE" ]; then
echo "Error: File not found: $FILE"
exit 1
fi
# Create backup
BACKUP_FILE="${FILE}.backup.$(date +%Y%m%d_%H%M%S)"
cp "$FILE" "$BACKUP_FILE"
echo "Backup created: $BACKUP_FILE"
# Replace the hardcoded values
sed -i "s|url\.searchParams\.set(\"connection_limit\", \".*\");|url.searchParams.set(\"connection_limit\", \"$NEW_CONN_LIMIT\");|g" "$FILE"
sed -i "s|url\.searchParams\.set(\"pool_timeout\", \".*\");|url.searchParams.set(\"pool_timeout\", \"$NEW_POOL_TIMEOUT\");|g" "$FILE"
sed -i "s|url\.searchParams\.set(\"connect_timeout\", \".*\");|url.searchParams.set(\"connect_timeout\", \"$NEW_CONNECT_TIMEOUT\");|g" "$FILE"
sed -i "s|url\.searchParams\.set(\"idle_timeout\", \".*\");|url.searchParams.set(\"idle_timeout\", \"$NEW_IDLE_TIMEOUT\");|g" "$FILE"
sed -i "s|url\.searchParams\.set(\"max_lifetime\", \".*\");|url.searchParams.set(\"max_lifetime\", \"$NEW_MAX_LIFETIME\");|g" "$FILE"
echo "Updated values:"
echo " connection_limit: $NEW_CONN_LIMIT"
echo " pool_timeout: $NEW_POOL_TIMEOUT"
echo " connect_timeout: $NEW_CONNECT_TIMEOUT"
echo " idle_timeout: $NEW_IDLE_TIMEOUT"
echo " max_lifetime: $NEW_MAX_LIFETIME"
echo ""
echo "Changes applied to $FILE"

128
tools/fixconnstrings.sh Normal file
View File

@@ -0,0 +1,128 @@
#!/bin/bash
# Script to fix HTTP connection limit issue for hosts page
# This adds a bulk status endpoint and updates the frontend to use it
set -e
echo "🔧 Fixing HTTP connection limit issue..."
# Backup files first
echo "📦 Creating backups..."
cp backend/src/routes/wsRoutes.js backend/src/routes/wsRoutes.js.bak
cp frontend/src/pages/Hosts.jsx frontend/src/pages/Hosts.jsx.bak
# Add bulk status endpoint to wsRoutes.js
echo " Adding bulk status endpoint to backend..."
cat > /tmp/ws_routes_addition.txt << 'EOF'
// Get WebSocket connection status for multiple hosts at once
router.get("/status", authenticateToken, async (req, res) => {
try {
const { apiIds } = req.query; // Comma-separated list of api_ids
const idArray = apiIds ? apiIds.split(',').filter(id => id.trim()) : [];
const statusMap = {};
idArray.forEach(apiId => {
statusMap[apiId] = getConnectionInfo(apiId);
});
res.json({
success: true,
data: statusMap,
});
} catch (error) {
console.error("Error fetching bulk WebSocket status:", error);
res.status(500).json({
success: false,
error: "Failed to fetch WebSocket status",
});
}
});
EOF
# Find the line number of the first router.get and insert after it
LINENUM=$(grep -n "router.get.*status.*apiId" backend/src/routes/wsRoutes.js | head -1 | cut -d: -f1)
sed -i "${LINENUM}r /tmp/ws_routes_addition.txt" backend/src/routes/wsRoutes.js
# Now update the frontend to use the bulk endpoint
echo "🔄 Updating frontend to use bulk endpoint..."
# Create a sed script to replace the fetchInitialStatus function
cat > /tmp/hosts_fix.sed << 'EOF'
/const fetchInitialStatus = async/,\}/c\
const fetchInitialStatus = async () => {\
const apiIds = hosts\
.filter((host) => host.api_id)\
.map(host => host.api_id);\
\
if (apiIds.length === 0) return;\
\
try {\
const response = await fetch(`/api/v1/ws/status?apiIds=${apiIds.join(',')}`, {\
headers: {\
Authorization: `Bearer ${token}`,\
},\
});\
if (response.ok) {\
const result = await response.json();\
setWsStatusMap(result.data);\
}\
} catch (_error) {\
// Silently handle errors\
}\
};
EOF
# Apply the sed script (multiline replacement is tricky with sed, so we'll use a different approach)
echo "✨ Using awk for multi-line replacement..."
# Create a temporary awk script
cat > /tmp/update_hosts.awk << 'AWK_EOF'
BEGIN { in_function=0; brace_count=0 }
/store.fetchInitialStatus/ { printing=1 }
/const fetchInitialStatus = async/ {
print " // Fetch initial WebSocket status for all hosts";
print " const fetchInitialStatus = async () => {";
print " const apiIds = hosts";
print " .filter((host) => host.api_id)";
print " .map(host => host.api_id);";
print "";
print " if (apiIds.length === 0) return;";
print "";
print " try {";
print " const response = await fetch(`/api/v1/ws/status?apiIds=${apiIds.join(',')}`, {";
print " headers: {";
print " Authorization: `Bearer ${token}`,";
print " },";
print " });";
print " if (response.ok) {";
print " const result = await response.json();";
print " setWsStatusMap(result.data);";
print " }";
print " } catch (_error) {";
print " // Silently handle errors";
print " }";
print " };";
skipping=1;
next
}
skipping && /^\t\t\}/ { skipping=0; next }
skipping { next }
{ print }
AWK_EOF
awk -f /tmp/update_hosts.awk frontend/src/pages/Hosts.jsx.bak > frontend/src/pages/Hosts.jsx
# Clean up temp files
rm /tmp/ws_routes_addition.txt /tmp/hosts_fix.sed /tmp/update_hosts.awk
echo "✅ Done! Files have been modified."
echo ""
echo "📝 Changes made:"
echo " - backend/src/routes/wsRoutes.js: Added bulk status endpoint"
echo " - frontend/src/pages/Hosts.jsx: Updated to use bulk endpoint"
echo ""
echo "💾 Backups saved as:"
echo " - backend/src/routes/wsRoutes.js.bak"
echo " - frontend/src/pages/Hosts.jsx.bak"