Compare commits

..

52 Commits

Author SHA1 Message Date
Muhammad Ibrahim
79317b0052 Added server initiated Agent update 2025-10-28 21:49:19 +00:00
Muhammad Ibrahim
77a945a5b6 fix: add Prisma connection pool variables to update_env_file function
- Added DB_CONNECTION_LIMIT, DB_POOL_TIMEOUT, DB_CONNECT_TIMEOUT, DB_IDLE_TIMEOUT, DB_MAX_LIFETIME
- Sets defaults (30, 20, 10, 300, 1800) if not already in .env
- Checks for missing variables during update mode
- Automatically adds them with comment header
- Ensures existing installations get connection pool settings on update
2025-10-28 19:55:42 +00:00
Muhammad Ibrahim
dae536e96b fix: handle non-git installations in update mode
- Added fallback to re-initialize git repository if .git directory missing
- Automatically detects current version from package.json
- Attempts to match to correct release branch
- Falls back to main branch if version detection fails
- Prevents update failures for legacy installations
2025-10-28 19:21:11 +00:00
Muhammad Ibrahim
f6d23e45b2 feat: make triangular accents more visible across background
- Increased triangle opacity from 2-5% to 5-13%
- Increased coverage from 30% to 60% of grid positions
- Slightly larger triangles (50-150px vs 40-120px)
- Smaller cell size (180px vs 200px) for better distribution
- Now clearly visible across entire background
2025-10-28 18:55:30 +00:00
Muhammad Ibrahim
aba0f5cb6b feat: add subtle triangular accents to clean gradient background
- Kept clean radial gradient base
- Added very faint triangular shapes (2-5% opacity)
- Sparse pattern - only 30% of grid positions
- Random sizes (40-120px) and positions for organic feel
- Perfect balance of clean + subtle geometric detail
2025-10-28 18:53:46 +00:00
Muhammad Ibrahim
2ec2b3992c feat: replace with clean radial gradient background
- Removed busy triangular pattern
- Clean, subtle radial gradient like modern Linux wallpapers
- Light center (top-left) flowing to darker bottom-right corner
- Uses theme colors but much more minimalist
- Daily color rotation maintained
2025-10-28 18:52:00 +00:00
Muhammad Ibrahim
f85721b292 feat: improve background with low-poly triangular pattern
- Replaced square grid with triangular mesh for more organic look
- Increased default cell size from 75px to 150px for subtlety
- Added variance to point positions for natural randomness
- Each cell now renders two triangles with gradient fills
- Maintains theme colors and daily variation
2025-10-28 18:49:39 +00:00
Muhammad Ibrahim
1d2c003830 fix: replace trianglify with pure browser gradient mesh generator
- Removed trianglify dependency (which required Node.js canvas)
- Implemented custom gradient mesh using HTML5 Canvas API
- Browser-native solution works in Docker without native dependencies
- Maintains daily variation and theme color support
- No more 'i.from is not a function' errors
2025-10-28 18:47:56 +00:00
Muhammad Ibrahim
2975da0f69 fix: use SVG-based rendering for Trianglify in browser instead of Node.js canvas 2025-10-28 18:35:52 +00:00
Muhammad Ibrahim
93760d03e1 feat: maintain nginx-unprivileged security while adding canvas runtime libraries via multi-stage build 2025-10-28 18:35:03 +00:00
Muhammad Ibrahim
43fb54a683 fix: change nginx-unprivileged to nginx:alpine for canvas runtime dependencies 2025-10-28 18:33:43 +00:00
Muhammad Ibrahim
e9368d1a95 feat: add canvas runtime dependencies to frontend Docker image for Trianglify support 2025-10-28 18:31:55 +00:00
Muhammad Ibrahim
3ce8c02a31 fix: suppress Trianglify errors in production to reduce console noise 2025-10-28 18:30:49 +00:00
Muhammad Ibrahim
ac420901a6 fix: add try-catch protection for Trianglify canvas generation to prevent runtime errors in Docker 2025-10-28 18:25:13 +00:00
Muhammad Ibrahim
eb0218bdcb fix: unset color variables before sourcing .env to prevent ANSI escape sequence errors 2025-10-28 18:21:52 +00:00
Muhammad Ibrahim
746451c296 debug: Add verbose logging to npm install for ARM64 builds
Added echo statements and --loglevel=verbose to see exactly what npm is
doing during the long ARM64 build process. This will help diagnose why
it's taking so long under QEMU emulation.
2025-10-28 18:01:02 +00:00
Muhammad Ibrahim
285e4c59ee fix: Install canvas build dependencies to make trianglify work
Canvas requires native build tools to compile:
- python3: For node-gyp
- make, g++, pkgconfig: C++ compiler and build tools
- cairo-dev, jpeg-dev, pango-dev, giflib-dev: Native libraries

By installing these dependencies before npm install, canvas can properly
build its native bindings for both AMD64 and ARM64 architectures.

Removed try-catch blocks around trianglify since it should now work properly.
2025-10-28 17:36:18 +00:00
Muhammad Ibrahim
9050595b7c fix: Make trianglify/canvas optional and handle gracefully
Canvas requires Python and native build tools which fail in Docker builds
with --ignore-scripts. Since trianglify is only used for cosmetic backgrounds
on the login and layout pages, we can make it optional.

Changes:
- Added try-catch around trianglify usage in Login and Layout
- Using --ignore-scripts in frontend Dockerfile to skip canvas native build
- Gracefully fail when trianglify is not available (background won't render)

This allows the frontend to build and run even without canvas/trianglify.
2025-10-28 17:35:15 +00:00
Muhammad Ibrahim
cc46940b0c fix: Remove --ignore-scripts to allow trianglify/canvas to install
The 'i.from is not a function' error occurs because trianglify depends
on canvas, and --ignore-scripts prevented canvas from installing properly.

Solution: Remove --ignore-scripts and allow npm to run install scripts,
but gracefully handle canvas rebuild failures since native bindings
are optional for the production build.
2025-10-28 17:31:39 +00:00
Muhammad Ibrahim
8864de6c15 fix: Add npm fetch retries to handle transient network errors
ARM64 builds under QEMU are slower and more prone to network timeouts.
Changed from --fetch-retries=0 to --fetch-retries=3 with exponential backoff:
- Min timeout: 20 seconds
- Max timeout: 120 seconds

This should handle transient ECONNRESET errors from npm registry.
2025-10-28 17:10:45 +00:00
Muhammad Ibrahim
3df2057f7e fix: Remove --omit=dev to install Vite and other build tools
Vite is in devDependencies and is required to build the frontend.
Using --omit=dev skipped it, causing 'vite: not found' error.

Build dependencies are needed during the builder stage, they won't be
included in the final production image since we copy only the built
artifacts (dist/) to the nginx image.
2025-10-28 16:53:52 +00:00
Muhammad Ibrahim
42f4e58bb4 fix: Add --ignore-scripts to prevent canvas native build in frontend
The trianglify package depends on canvas, which tries to build native binaries
requiring Python and build tools. Since canvas is not actually needed for the
browser build (trianglify uses it only server-side), we can skip all install
scripts with --ignore-scripts to avoid the build failure.

This fixes the ARM64 and AMD64 frontend builds.
2025-10-28 16:51:39 +00:00
Muhammad Ibrahim
12eef22912 fix: Use npm install instead of npm ci for frontend (no package-lock.json)
The frontend subdirectory doesn't have its own package-lock.json (only at
workspace root), so npm ci fails. Using npm install instead to generate
dependencies from package.json.
2025-10-28 16:43:25 +00:00
Muhammad Ibrahim
c2121e3995 fix: Build only frontend workspace, not root monorepo dependencies
The previous approach installed workspace root dependencies which included
backend packages like 'canvas' that require native build tools (Python, gcc).

Changes:
- Work directly in /app/frontend instead of /app root
- Copy only frontend/package*.json (not root package.json)
- Run 'npm run build' instead of 'npm run build:frontend'
- This installs only frontend dependencies (Vite, React, etc.)

This avoids attempting to build unnecessary backend dependencies in the
frontend Docker image.
2025-10-28 16:41:35 +00:00
Muhammad Ibrahim
6792f96af9 fix: Ensure rollup ARM64 native binaries are installed in frontend build
Removed --ignore-scripts flag and added cache cleanup to ensure optional
dependencies like @rollup/rollup-linux-arm64-musl are properly installed.
This mirrors the fix applied to the backend Dockerfile for ARM64 support.
2025-10-28 16:39:27 +00:00
Muhammad Ibrahim
1e617c8bb8 fix: Regenerate package-lock.json to remove corrupted npm registry URLs
The workspace package-lock.json had corrupted 'resolved' URLs for many packages
including string_decoder, causing Docker builds to fail with 404 errors.

Changes:
- Regenerated root package-lock.json which manages all workspace dependencies
- Restored npm ci in Dockerfile (now that lockfile is fixed)
- Keep PRISMA_CLI_BINARY_TYPE=binary for ARM64 compatibility

This should resolve both AMD64 and ARM64 Docker build failures.
2025-10-28 16:35:28 +00:00
Muhammad Ibrahim
a76c5b8963 fix: Use npm install to regenerate package-lock.json and bypass corruption
- Delete package-lock.json in Docker build to avoid corrupted string_decoder entry
- Use npm install instead of npm ci to regenerate package-lock.json fresh
- This avoids the 'string_decoder is a core module' error that package-lock.json had cached
2025-10-28 16:31:52 +00:00
Muhammad Ibrahim
212b24b1c8 fix: Force npm to prefer online registry and disable fetch retries
- Added --prefer-online flag to npm ci to bypass corrupted cache
- Set --fetch-retries=0 to fail fast on corrupted packages
- Removed /root/.npm directory as well to clear all caches
- Fixed typo in workflow name
2025-10-28 16:30:14 +00:00
Muhammad Ibrahim
9fc3f4f9d1 fix: Enable ARM64 builds with improved QEMU support
- Re-enabled linux/arm64 platform builds
- Added PRISMA_CLI_BINARY_TYPE=binary to use pre-compiled binaries
- This avoids native compilation issues under QEMU emulation
- Use npm script for Prisma generation instead of npx
2025-10-28 16:27:45 +00:00
Muhammad Ibrahim
3029278742 fix: Build only linux/amd64 to avoid QEMU emulation failures
ARM64 builds were failing with 'Illegal instruction' errors during npm ci
due to QEMU emulation issues. Since PatchMon is a Node.js application,
AMD64 images will run fine on ARM64 systems (like Apple Silicon Macs)
using Rosetta emulation. This simplifies the build process.
2025-10-28 16:25:58 +00:00
Muhammad Ibrahim
e4d6c1205c fix: Remove entire npm cache directory to fix corrupted tarball issue
The get-intrinsic package tarball is getting corrupted in the npm cache.
By removing ~/.npm entirely and fetching fresh packages, we avoid the
corrupted cache issue that's causing the string_decoder error.
2025-10-28 16:19:25 +00:00
Muhammad Ibrahim
0f5272d12a fix: Add legacy-peer-deps flag to npm ci to resolve string_decoder build error
The string_decoder error occurs when npm tries to install it as a separate package
when it's actually a Node.js core module. Using --legacy-peer-deps helps resolve
this peer dependency conflict during Docker builds.
2025-10-28 16:12:46 +00:00
Muhammad Ibrahim
5776d32e71 fix: Improve Docker build reliability by cleaning npm cache before npm ci
- Move npm cache clean before npm ci to prevent corrupted package issues
- This fixes the string_decoder error occurring during GitHub Actions builds
2025-10-28 16:12:31 +00:00
Muhammad Ibrahim
a11ff842eb fix: Remove unused getSettings import in metricsReporting.js 2025-10-28 16:06:36 +00:00
Muhammad Ibrahim
48ce1951de fix: Resolve all linting errors
- Remove unused imports and variables in metricsRoutes.js
- Prefix unused error variables with underscore
- Fix useEffect dependency in Login.jsx
- Add aria-label and title to all SVG elements for accessibility
2025-10-28 16:06:36 +00:00
Muhammad Ibrahim
9705e24b83 docs: Add complete Prisma connection pool variables to Docker Compose files
Added DB_IDLE_TIMEOUT and DB_MAX_LIFETIME to both production and dev Docker Compose files to complete the connection pool configuration. These variables were already documented but missing from the compose files.
2025-10-28 16:06:36 +00:00
Muhammad Ibrahim
933c7a067e perf: Optimize packages endpoint - reduce from N*3 queries to 3 batch queries
- Replace individual queries per package with batch GROUP BY queries
- Reduces from potentially hundreds of queries to just 3 queries total
- Creates lookup maps for O(1) access when assembling results
- Improves packages page loading time significantly
2025-10-28 16:06:36 +00:00
Muhammad Ibrahim
68f10c6c43 fix: Revert axios timeout to 10 seconds
Nothing should take longer than 10 seconds to respond. If it does, it's a server/query optimization issue, not a timeout issue.
2025-10-28 16:06:36 +00:00
Muhammad Ibrahim
4b6f19c28e fix: Replace SSE with polling for WebSocket status to prevent connection pool exhaustion
- Replace persistent SSE connections with lightweight polling (10s interval)
- Optimize WebSocket status fetching using bulk endpoint instead of N individual calls
- Fix N+1 query problem in /dashboard/hosts endpoint (39 queries → 4 queries)
- Increase database connection pool limit from 5 to 50 via environment variables
- Increase Axios timeout from 10s to 30s for complex operations
- Fix malformed WebSocket routes causing 404 on bulk status endpoint

Fixes timeout issues when adding hosts with multiple WebSocket agents connected.
Reduces database connections from 19 persistent SSE + retries to 1 poll every 10 seconds.
2025-10-28 16:06:36 +00:00
Muhammad Ibrahim
ae6afb0ef4 Building Docker compatibilty within the Agent 2025-10-28 16:06:36 +00:00
9 Technology Group LTD
61523c9a44 Add bulk status endpoint and update frontend
This script fixes the HTTP connection limit issue by adding a bulk status endpoint to the backend and updating the frontend to utilize this endpoint. Backups of the modified files are created before changes are applied.
2025-10-28 12:23:46 +00:00
9 Technology Group LTD
3f9a5576ac Fix file path in fixconnlimit.sh 2025-10-27 16:57:53 +00:00
9 Technology Group LTD
e2dd7acca5 Rename fixconnlimit to fixconnlimit.sh 2025-10-27 16:56:00 +00:00
9 Technology Group LTD
1c3b01f13c Add script to update connection pool values in prisma.js
This script updates hardcoded connection pool values in prisma.js, allowing for customizable connection settings through command-line arguments. It also creates a backup of the original file before applying changes.
2025-10-27 16:55:48 +00:00
Muhammad Ibrahim
2c5a35b6c2 Added Diagnostics scripts and improved setup with more redis db server handling 2025-10-24 21:25:15 +01:00
Muhammad Ibrahim
f42c53d34b Added support for allowing self-signed certificates that the new Go agent can also use 2025-10-23 20:57:31 +01:00
Muhammad Ibrahim
95800e6d76 Upgrading to version 1.3.1 2025-10-23 20:27:11 +01:00
Muhammad Ibrahim
de449c547f Fixed some ratelimits that were hardcoded and ammended docker compose to take into consideration rate limits 2025-10-22 15:22:14 +01:00
Muhammad Ibrahim
a8bd09be89 Made the setup.sh regenerate the .env variables 2025-10-22 14:15:49 +01:00
Muhammad Ibrahim
3ae8422487 modified nginx config for updates 2025-10-22 12:12:06 +01:00
Muhammad Ibrahim
c98203a997 Fixed bug on nginx configuration 2025-10-22 02:31:53 +01:00
Muhammad Ibrahim
37c8f5fa76 Modified setup.sh to handle the changes in version 1.3.0 2025-10-22 02:09:23 +01:00
59 changed files with 6632 additions and 3097 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

@@ -1,23 +1,36 @@
# Database Configuration
DATABASE_URL="postgresql://patchmon_user:p@tchm0n_p@55@localhost:5432/patchmon_db"
DATABASE_URL="postgresql://patchmon_user:your-password-here@localhost:5432/patchmon_db"
PM_DB_CONN_MAX_ATTEMPTS=30
PM_DB_CONN_WAIT_INTERVAL=2
# Redis Configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_USER=your-redis-username-here
REDIS_PASSWORD=your-redis-password-here
REDIS_DB=0
# 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
JWT_REFRESH_EXPIRES_IN=7d
# Server Configuration
PORT=3001
NODE_ENV=development
NODE_ENV=production
# API Configuration
API_VERSION=v1
# CORS Configuration
CORS_ORIGIN=http://localhost:3000
# Session Configuration
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
# User Configuration
DEFAULT_USER_ROLE=user
# Rate Limiting (times in milliseconds)
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX=5000
@@ -26,20 +39,18 @@ AUTH_RATE_LIMIT_MAX=500
AGENT_RATE_LIMIT_WINDOW_MS=60000
AGENT_RATE_LIMIT_MAX=1000
# Redis Configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_USER=your-redis-username-here
REDIS_PASSWORD=your-redis-password-here
REDIS_DB=0
# Logging
LOG_LEVEL=info
ENABLE_LOGGING=true
# User Registration
DEFAULT_USER_ROLE=user
# JWT Configuration
JWT_SECRET=your-secure-random-secret-key-change-this-in-production
JWT_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=7d
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
# TFA Configuration
# TFA Configuration (optional - used if TFA is enabled)
TFA_REMEMBER_ME_EXPIRES_IN=30d
TFA_MAX_REMEMBER_SESSIONS=5
TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=3

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
@@ -1388,6 +1430,69 @@ router.patch(
},
);
// Force agent update for specific host
router.post(
"/:hostId/force-agent-update",
authenticateToken,
requireManageHosts,
async (req, res) => {
try {
const { hostId } = req.params;
// Get host to verify it exists
const host = await prisma.hosts.findUnique({
where: { id: hostId },
});
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
// Get queue manager
const { QUEUE_NAMES } = require("../services/automation");
const queueManager = req.app.locals.queueManager;
if (!queueManager) {
return res.status(500).json({
error: "Queue manager not available",
});
}
// Get the agent-commands queue
const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS];
// Add job to queue
await queue.add(
"update_agent",
{
api_id: host.api_id,
type: "update_agent",
},
{
attempts: 3,
backoff: {
type: "exponential",
delay: 2000,
},
},
);
res.json({
success: true,
message: "Agent update queued successfully",
host: {
id: host.id,
friendlyName: host.friendly_name,
apiId: host.api_id,
},
});
} catch (error) {
console.error("Force agent update error:", error);
res.status(500).json({ error: "Failed to force agent update" });
}
},
);
// Serve the installation script (requires API authentication)
router.get("/install", async (req, res) => {
try {
@@ -1441,10 +1546,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 +1561,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");
@@ -295,7 +297,7 @@ app.disable("x-powered-by");
// Rate limiting with monitoring
const limiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000,
max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 100,
max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 5000,
message: {
error: "Too many requests from this IP, please try again later.",
retryAfter: Math.ceil(
@@ -424,7 +426,7 @@ const apiVersion = process.env.API_VERSION || "v1";
const authLimiter = rateLimit({
windowMs:
parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 10) || 10 * 60 * 1000,
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 20,
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 500,
message: {
error: "Too many authentication requests, please try again later.",
retryAfter: Math.ceil(
@@ -438,7 +440,7 @@ const authLimiter = rateLimit({
});
const agentLimiter = rateLimit({
windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS, 10) || 60 * 1000,
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX, 10) || 120,
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX, 10) || 1000,
message: {
error: "Too many agent requests, please try again later.",
retryAfter: Math.ceil(
@@ -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", () => {
@@ -162,6 +176,15 @@ function pushSettingsUpdate(apiId, newInterval) {
);
}
function pushUpdateAgent(apiId) {
const ws = apiIdToSocket.get(apiId);
safeSend(ws, JSON.stringify({ type: "update_agent" }));
}
function getConnectionByApiId(apiId) {
return apiIdToSocket.get(apiId);
}
function pushUpdateNotification(apiId, updateInfo) {
const ws = apiIdToSocket.get(apiId);
if (ws && ws.readyState === WebSocket.OPEN) {
@@ -255,15 +278,73 @@ 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,
pushReportNow,
pushSettingsUpdate,
pushUpdateAgent,
pushUpdateNotification,
pushUpdateNotificationToAll,
// Expose read-only view of connected agents
getConnectedApiIds: () => Array.from(apiIdToSocket.keys()),
getConnectionByApiId,
isConnected: (apiId) => {
const ws = apiIdToSocket.get(apiId);
return !!ws && ws.readyState === WebSocket.OPEN;

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,
@@ -163,6 +190,19 @@ class QueueManager {
// For settings update, we need additional data
const { update_interval } = job.data;
agentWs.pushSettingsUpdate(api_id, update_interval);
} else if (type === "update_agent") {
// Force agent to update by sending WebSocket command
const ws = agentWs.getConnectionByApiId(api_id);
if (ws && ws.readyState === 1) {
// WebSocket.OPEN
agentWs.pushUpdateAgent(api_id);
console.log(`✅ Update command sent to agent ${api_id}`);
} else {
console.error(`❌ Agent ${api_id} is not connected`);
throw new Error(
`Agent ${api_id} is not connected. Cannot send update command.`,
);
}
} else {
console.error(`Unknown agent command type: ${type}`);
}
@@ -205,6 +245,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 +270,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=3 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 &&\
PRISMA_CLI_BINARY_TYPE=binary npm run db:generate &&\
npm prune --omit=dev &&\
npm cache clean --force

View File

@@ -50,6 +50,19 @@ 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
AUTH_RATE_LIMIT_WINDOW_MS: 600000
AUTH_RATE_LIMIT_MAX: 500
AGENT_RATE_LIMIT_WINDOW_MS: 60000
AGENT_RATE_LIMIT_MAX: 1000
# Redis Configuration
REDIS_HOST: redis
REDIS_PORT: 6379

View File

@@ -56,6 +56,19 @@ 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
AUTH_RATE_LIMIT_WINDOW_MS: 600000
AUTH_RATE_LIMIT_MAX: 500
AGENT_RATE_LIMIT_WINDOW_MS: 60000
AGENT_RATE_LIMIT_MAX: 1000
# Redis Configuration
REDIS_HOST: redis
REDIS_PORT: 6379

View File

@@ -17,16 +17,21 @@ 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 echo "=== Starting npm install ===" &&\
npm cache clean --force &&\
rm -rf node_modules ~/.npm /root/.npm &&\
echo "=== npm install ===" &&\
npm install --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=3 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 &&\
echo "=== npm install completed ===" &&\
npm cache clean --force
COPY frontend/ ./frontend/
COPY frontend/ ./
RUN npm run build:frontend
RUN npm run build
# Production stage
FROM nginxinc/nginx-unprivileged:alpine

10
frontend/env.example Normal file
View File

@@ -0,0 +1,10 @@
# Frontend Environment Configuration
# This file is used by Vite during build and runtime
# API URL - Update this to match your backend server
VITE_API_URL=http://localhost:3001/api/v1
# Application Metadata
VITE_APP_NAME=PatchMon
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": {

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,10 @@ 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 { 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 +62,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 +236,165 @@ const Layout = ({ children }) => {
navigate("/hosts?action=add");
};
// Generate clean radial gradient background with subtle triangular accents for dark mode
useEffect(() => {
const generateBackground = () => {
if (
!bgCanvasRef.current ||
!themeConfig?.login ||
!document.documentElement.classList.contains("dark")
) {
return;
}
const canvas = bgCanvasRef.current;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const ctx = canvas.getContext("2d");
// Get theme colors - pick first color from each palette
const xColors = themeConfig.login.xColors || [
"#667eea",
"#764ba2",
"#f093fb",
"#4facfe",
];
const yColors = themeConfig.login.yColors || [
"#667eea",
"#764ba2",
"#f093fb",
"#4facfe",
];
// Use date for daily color rotation
const today = new Date();
const seed =
today.getFullYear() * 10000 + today.getMonth() * 100 + today.getDate();
const random = (s) => {
const x = Math.sin(s) * 10000;
return x - Math.floor(x);
};
const color1 = xColors[Math.floor(random(seed) * xColors.length)];
const color2 = yColors[Math.floor(random(seed + 1000) * yColors.length)];
// Create clean radial gradient from center to bottom-right corner
const gradient = ctx.createRadialGradient(
canvas.width * 0.3, // Center slightly left
canvas.height * 0.3, // Center slightly up
0,
canvas.width * 0.5, // Expand to cover screen
canvas.height * 0.5,
Math.max(canvas.width, canvas.height) * 1.2,
);
// Subtle gradient with darker corners
gradient.addColorStop(0, color1);
gradient.addColorStop(0.6, color2);
gradient.addColorStop(1, "#0a0a0a"); // Very dark edges
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Add subtle triangular shapes as accents across entire background
const cellSize = 180;
const cols = Math.ceil(canvas.width / cellSize) + 1;
const rows = Math.ceil(canvas.height / cellSize) + 1;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const idx = y * cols + x;
// Draw more triangles (less sparse)
if (random(seed + idx + 5000) > 0.4) {
const baseX =
x * cellSize + random(seed + idx * 3) * cellSize * 0.8;
const baseY =
y * cellSize + random(seed + idx * 3 + 100) * cellSize * 0.8;
const size = 50 + random(seed + idx * 4) * 100;
ctx.beginPath();
ctx.moveTo(baseX, baseY);
ctx.lineTo(baseX + size, baseY);
ctx.lineTo(baseX + size / 2, baseY - size * 0.866);
ctx.closePath();
// More visible white with slightly higher opacity
ctx.fillStyle = `rgba(255, 255, 255, ${0.05 + random(seed + idx * 5) * 0.08})`;
ctx.fill();
}
}
}
};
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 +444,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 +521,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 +747,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 +801,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 +1156,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 +1220,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 +1292,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 +1317,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

@@ -187,6 +187,16 @@ const HostDetail = () => {
},
});
// Force agent update mutation
const forceAgentUpdateMutation = useMutation({
mutationFn: () =>
adminHostsAPI.forceAgentUpdate(hostId).then((res) => res.data),
onSuccess: () => {
queryClient.invalidateQueries(["host", hostId]);
queryClient.invalidateQueries(["hosts"]);
},
});
const updateFriendlyNameMutation = useMutation({
mutationFn: (friendlyName) =>
adminHostsAPI
@@ -703,6 +713,29 @@ const HostDetail = () => {
/>
</button>
</div>
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Force Update
</p>
<button
type="button"
onClick={() => forceAgentUpdateMutation.mutate()}
disabled={forceAgentUpdateMutation.isPending}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-md hover:bg-primary-100 dark:hover:bg-primary-900/40 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw
className={`h-3 w-3 ${
forceAgentUpdateMutation.isPending
? "animate-spin"
: ""
}`}
/>
{forceAgentUpdateMutation.isPending
? "Updating..."
: "Update Now"}
</button>
</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]);

File diff suppressed because it is too large Load Diff

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",
},
@@ -95,6 +95,7 @@ export const adminHostsAPI = {
api.put("/hosts/bulk/groups", { hostIds, groupIds }),
toggleAutoUpdate: (hostId, autoUpdate) =>
api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
forceAgentUpdate: (hostId) => api.post(`/hosts/${hostId}/force-agent-update`),
updateFriendlyName: (hostId, friendlyName) =>
api.patch(`/hosts/${hostId}/friendly-name`, {
friendly_name: friendlyName,

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

1956
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"
},

1724
setup.sh

File diff suppressed because it is too large Load Diff

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"