Compare commits

...

74 Commits

Author SHA1 Message Date
9 Technology Group LTD
39fbafe01f Merge pull request #261 from PatchMon/release/1-3-1
Added migration file for the theme preferences
2025-10-31 18:18:47 +00:00
Muhammad Ibrahim
f296cf2003 Added migration file for the theme preferences 2025-10-31 18:17:48 +00:00
9 Technology Group LTD
052a77dce8 Merge pull request #260 from PatchMon/release/1-3-2
Release/1 3 2
2025-10-31 17:46:58 +00:00
Muhammad Ibrahim
94bfffd882 Theme settings per user 2025-10-31 17:33:47 +00:00
Muhammad Ibrahim
37462f4831 New 1.3.2 Agent 2025-10-31 15:41:01 +00:00
Muhammad Ibrahim
5457a1e9bc Docker implementation
Profile fixes
Hostgroup fixes
TFA fixes
2025-10-31 15:24:53 +00:00
9 Technology Group LTD
f3bca4a6d5 Merge pull request #245 from alan7000/alan7000-patch-1
Enable listen IPv6 for port 3000
2025-10-29 20:39:29 +00:00
Flambard alan
ca4d34c230 Enable listen IPv6 for port 3000 2025-10-29 17:07:50 +01:00
9 Technology Group LTD
1e75f2b1fe Merge pull request #242 from PatchMon/release/1-3-1
Agent update initiation from server
2025-10-28 21:55:57 +00:00
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
9 Technology Group LTD
276d910e83 Merge pull request #238 from PatchMon/release/1-3-1
fix: handle non-git installations in update mode
2025-10-28 19:33:22 +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
9 Technology Group LTD
8361caabe8 Merge pull request #236 from PatchMon/release/1-3-1
Release/1 3 1
2025-10-28 19:03:34 +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
9 Technology Group LTD
1f6f58360f Merge pull request #235 from PatchMon/release/1-3-1
fix: Remove --ignore-scripts to allow trianglify/canvas to install
2025-10-28 18:12:04 +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
9 Technology Group LTD
203a065479 Merge pull request #234 from PatchMon/release/1-3-1
fix: Add npm fetch retries to handle transient network errors
2025-10-28 17:18:12 +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
9 Technology Group LTD
96aedbe761 Merge pull request #233 from PatchMon/release/1-3-1
Release/1 3 1
2025-10-28 17:06:02 +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
9 Technology Group LTD
8d372411be Merge pull request #208 from PatchMon/post1-3-0
Fixed some ratelimits that were hardcoded and ammended docker compose…
2025-10-22 15:37:50 +01:00
9 Technology Group LTD
cd03f0e66a Merge pull request #206 from PatchMon/post1-3-0
Made the setup.sh regenerate the .env variables
2025-10-22 14:33:18 +01:00
9 Technology Group LTD
deb6bed1a6 Merge pull request #204 from PatchMon/post1-3-0
Improving the setup.sh script to handle the nginx configuration changes on bare-metal type instances.

Also amended the env.example files to suit.
2025-10-22 13:47:03 +01:00
9 Technology Group LTD
0189a307ef Merge pull request #194 from PatchMon/feature/go-agent
I think we are ready now for 1.3.0 :)
2025-10-21 21:40:15 +01:00
9 Technology Group LTD
00abbc8c62 Merge pull request #191 from PatchMon/feature/go-agent
Feature/go agent
2025-10-20 23:06:35 +01:00
9 Technology Group LTD
c9aef78912 Merge pull request #190 from PatchMon/feature/go-agent
Remove /bullboard from caching
2025-10-20 20:26:58 +01:00
9 Technology Group LTD
fd2df0729e Merge pull request #189 from PatchMon/feature/go-agent
added bullboard url for docker nginx template
2025-10-20 19:46:50 +01:00
9 Technology Group LTD
d7f7b24f8f Merge pull request #188 from PatchMon/feature/go-agent
Added axios in package.json
2025-10-20 19:21:07 +01:00
9 Technology Group LTD
1ef2308d56 Agent version detection and added nginx template 2025-10-20 18:55:43 +01:00
9 Technology Group LTD
fcd1b52e0e Merge pull request #186 from PatchMon/feature/go-agent
Bull Board
2025-10-19 20:58:03 +01:00
9 Technology Group LTD
5be8e01aa3 Merge pull request #185 from PatchMon/feature/go-agent
Modified the proxmox_auto-enroll.sh script to suit the new method
2025-10-19 19:03:17 +01:00
9 Technology Group LTD
293733dc0b Merge pull request #183 from PatchMon/feature/go-agent
Improved detection logic and upgrade mechanism using intermeditary sc…
2025-10-19 18:01:34 +01:00
9 Technology Group LTD
c7ab40e4a2 Merge pull request #182 from PatchMon/feature/go-agent
Fixed upgrade detection logic
2025-10-18 21:59:48 +01:00
83 changed files with 9596 additions and 3325 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" credentials_file: "/etc/patchmon/credentials.yml"
log_file: "/etc/patchmon/logs/patchmon-agent.log" log_file: "/etc/patchmon/logs/patchmon-agent.log"
log_level: "info" log_level: "info"
skip_ssl_verify: ${SKIP_SSL_VERIFY:-false}
EOF EOF
# Create credentials file # Create credentials file

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "patchmon-backend", "name": "patchmon-backend",
"version": "1.3.0", "version": "1.3.2",
"description": "Backend API for Linux Patch Monitoring System", "description": "Backend API for Linux Patch Monitoring System",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"main": "src/server.js", "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

@@ -0,0 +1,74 @@
-- CreateTable
CREATE TABLE "docker_volumes" (
"id" TEXT NOT NULL,
"host_id" TEXT NOT NULL,
"volume_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"driver" TEXT NOT NULL,
"mountpoint" TEXT,
"renderer" TEXT,
"scope" TEXT NOT NULL DEFAULT 'local',
"labels" JSONB,
"options" JSONB,
"size_bytes" BIGINT,
"ref_count" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3) NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL,
"last_checked" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "docker_volumes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "docker_networks" (
"id" TEXT NOT NULL,
"host_id" TEXT NOT NULL,
"network_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"driver" TEXT NOT NULL,
"scope" TEXT NOT NULL DEFAULT 'local',
"ipv6_enabled" BOOLEAN NOT NULL DEFAULT false,
"internal" BOOLEAN NOT NULL DEFAULT false,
"attachable" BOOLEAN NOT NULL DEFAULT true,
"ingress" BOOLEAN NOT NULL DEFAULT false,
"config_only" BOOLEAN NOT NULL DEFAULT false,
"labels" JSONB,
"ipam" JSONB,
"container_count" INTEGER NOT NULL DEFAULT 0,
"created_at" TIMESTAMP(3),
"updated_at" TIMESTAMP(3) NOT NULL,
"last_checked" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "docker_networks_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "docker_volumes_host_id_idx" ON "docker_volumes"("host_id");
-- CreateIndex
CREATE INDEX "docker_volumes_name_idx" ON "docker_volumes"("name");
-- CreateIndex
CREATE INDEX "docker_volumes_driver_idx" ON "docker_volumes"("driver");
-- CreateIndex
CREATE UNIQUE INDEX "docker_volumes_host_id_volume_id_key" ON "docker_volumes"("host_id", "volume_id");
-- CreateIndex
CREATE INDEX "docker_networks_host_id_idx" ON "docker_networks"("host_id");
-- CreateIndex
CREATE INDEX "docker_networks_name_idx" ON "docker_networks"("name");
-- CreateIndex
CREATE INDEX "docker_networks_driver_idx" ON "docker_networks"("driver");
-- CreateIndex
CREATE UNIQUE INDEX "docker_networks_host_id_network_id_key" ON "docker_networks"("host_id", "network_id");
-- AddForeignKey
ALTER TABLE "docker_volumes" ADD CONSTRAINT "docker_volumes_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "docker_networks" ADD CONSTRAINT "docker_networks_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,6 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "theme_preference" VARCHAR(10) DEFAULT 'dark';
-- AlterTable
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "color_theme" VARCHAR(50) DEFAULT 'cyber_blue';

View File

@@ -114,6 +114,8 @@ model hosts {
host_group_memberships host_group_memberships[] host_group_memberships host_group_memberships[]
update_history update_history[] update_history update_history[]
job_history job_history[] job_history job_history[]
docker_volumes docker_volumes[]
docker_networks docker_networks[]
@@index([machine_id]) @@index([machine_id])
@@index([friendly_name]) @@index([friendly_name])
@@ -170,27 +172,30 @@ model role_permissions {
} }
model settings { model settings {
id String @id id String @id
server_url String @default("http://localhost:3001") server_url String @default("http://localhost:3001")
server_protocol String @default("http") server_protocol String @default("http")
server_host String @default("localhost") server_host String @default("localhost")
server_port Int @default(3001) server_port Int @default(3001)
created_at DateTime @default(now()) created_at DateTime @default(now())
updated_at DateTime updated_at DateTime
update_interval Int @default(60) update_interval Int @default(60)
auto_update Boolean @default(false) auto_update Boolean @default(false)
github_repo_url String @default("https://github.com/PatchMon/PatchMon.git") github_repo_url String @default("https://github.com/PatchMon/PatchMon.git")
ssh_key_path String? ssh_key_path String?
repository_type String @default("public") repository_type String @default("public")
last_update_check DateTime? last_update_check DateTime?
latest_version String? latest_version String?
update_available Boolean @default(false) update_available Boolean @default(false)
signup_enabled Boolean @default(false) signup_enabled Boolean @default(false)
default_user_role String @default("user") default_user_role String @default("user")
ignore_ssl_self_signed Boolean @default(false) ignore_ssl_self_signed Boolean @default(false)
logo_dark String? @default("/assets/logo_dark.png") logo_dark String? @default("/assets/logo_dark.png")
logo_light String? @default("/assets/logo_light.png") logo_light String? @default("/assets/logo_light.png")
favicon String? @default("/assets/logo_square.svg") favicon String? @default("/assets/logo_square.svg")
metrics_enabled Boolean @default(true)
metrics_anonymous_id String?
metrics_last_sent DateTime?
} }
model update_history { model update_history {
@@ -222,6 +227,8 @@ model users {
tfa_secret String? tfa_secret String?
first_name String? first_name String?
last_name String? last_name String?
theme_preference String? @default("dark")
color_theme String? @default("cyber_blue")
dashboard_preferences dashboard_preferences[] dashboard_preferences dashboard_preferences[]
user_sessions user_sessions[] user_sessions user_sessions[]
auto_enrollment_tokens auto_enrollment_tokens[] auto_enrollment_tokens auto_enrollment_tokens[]
@@ -338,6 +345,56 @@ model docker_image_updates {
@@index([is_security_update]) @@index([is_security_update])
} }
model docker_volumes {
id String @id
host_id String
volume_id String
name String
driver String
mountpoint String?
renderer String?
scope String @default("local")
labels Json?
options Json?
size_bytes BigInt?
ref_count Int @default(0)
created_at DateTime
updated_at DateTime
last_checked DateTime @default(now())
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
@@unique([host_id, volume_id])
@@index([host_id])
@@index([name])
@@index([driver])
}
model docker_networks {
id String @id
host_id String
network_id String
name String
driver String
scope String @default("local")
ipv6_enabled Boolean @default(false)
internal Boolean @default(false)
attachable Boolean @default(true)
ingress Boolean @default(false)
config_only Boolean @default(false)
labels Json?
ipam Json? // IPAM configuration (driver, config, options)
container_count Int @default(0)
created_at DateTime?
updated_at DateTime
last_checked DateTime @default(now())
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
@@unique([host_id, network_id])
@@index([host_id])
@@index([name])
@@index([driver])
}
model job_history { model job_history {
id String @id id String @id
job_id String job_id String

View File

@@ -16,12 +16,28 @@ function getOptimizedDatabaseUrl() {
// Parse the URL // Parse the URL
const url = new URL(originalUrl); const url = new URL(originalUrl);
// Add connection pooling parameters for multiple instances // Add connection pooling parameters - configurable via environment variables
url.searchParams.set("connection_limit", "5"); // Reduced from default 10 const connectionLimit = process.env.DB_CONNECTION_LIMIT || "30";
url.searchParams.set("pool_timeout", "10"); // 10 seconds const poolTimeout = process.env.DB_POOL_TIMEOUT || "20";
url.searchParams.set("connect_timeout", "10"); // 10 seconds const connectTimeout = process.env.DB_CONNECT_TIMEOUT || "10";
url.searchParams.set("idle_timeout", "300"); // 5 minutes const idleTimeout = process.env.DB_IDLE_TIMEOUT || "300";
url.searchParams.set("max_lifetime", "1800"); // 30 minutes 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(); return url.toString();
} }

View File

@@ -17,6 +17,7 @@ const {
refresh_access_token, refresh_access_token,
revoke_session, revoke_session,
revoke_all_user_sessions, revoke_all_user_sessions,
generate_device_fingerprint,
} = require("../utils/session_manager"); } = require("../utils/session_manager");
const router = express.Router(); const router = express.Router();
@@ -788,11 +789,39 @@ router.post(
// Check if TFA is enabled // Check if TFA is enabled
if (user.tfa_enabled) { if (user.tfa_enabled) {
return res.status(200).json({ // Get device fingerprint from X-Device-ID header
message: "TFA verification required", const device_fingerprint = generate_device_fingerprint(req);
requiresTfa: true,
username: user.username, // Check if this device has a valid TFA bypass
}); if (device_fingerprint) {
const remembered_session = await prisma.user_sessions.findFirst({
where: {
user_id: user.id,
device_fingerprint: device_fingerprint,
tfa_remember_me: true,
tfa_bypass_until: { gt: new Date() }, // Bypass still valid
},
});
if (remembered_session) {
// Device is remembered and bypass is still valid - skip TFA
// Continue with login below
} else {
// No valid bypass for this device - require TFA
return res.status(200).json({
message: "TFA verification required",
requiresTfa: true,
username: user.username,
});
}
} else {
// No device ID provided - require TFA
return res.status(200).json({
message: "TFA verification required",
requiresTfa: true,
username: user.username,
});
}
} }
// Update last login // Update last login
@@ -807,7 +836,13 @@ router.post(
// Create session with access and refresh tokens // Create session with access and refresh tokens
const ip_address = req.ip || req.connection.remoteAddress; const ip_address = req.ip || req.connection.remoteAddress;
const user_agent = req.get("user-agent"); const user_agent = req.get("user-agent");
const session = await create_session(user.id, ip_address, user_agent); const session = await create_session(
user.id,
ip_address,
user_agent,
false,
req,
);
res.json({ res.json({
message: "Login successful", message: "Login successful",
@@ -841,8 +876,10 @@ router.post(
body("username").notEmpty().withMessage("Username is required"), body("username").notEmpty().withMessage("Username is required"),
body("token") body("token")
.isLength({ min: 6, max: 6 }) .isLength({ min: 6, max: 6 })
.withMessage("Token must be 6 digits"), .withMessage("Token must be 6 characters"),
body("token").isNumeric().withMessage("Token must contain only numbers"), body("token")
.matches(/^[A-Z0-9]{6}$/)
.withMessage("Token must be 6 alphanumeric characters"),
body("remember_me") body("remember_me")
.optional() .optional()
.isBoolean() .isBoolean()

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 // Get queue health status
router.get("/health", authenticateToken, async (_req, res) => { router.get("/health", authenticateToken, async (_req, res) => {
try { try {
@@ -274,6 +298,7 @@ router.get("/overview", authenticateToken, async (_req, res) => {
queueManager.getRecentJobs(QUEUE_NAMES.SESSION_CLEANUP, 1), queueManager.getRecentJobs(QUEUE_NAMES.SESSION_CLEANUP, 1),
queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_REPO_CLEANUP, 1), queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_REPO_CLEANUP, 1),
queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_PACKAGE_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), 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.GITHUB_UPDATE_CHECK].delayed +
stats[QUEUE_NAMES.SESSION_CLEANUP].delayed + stats[QUEUE_NAMES.SESSION_CLEANUP].delayed +
stats[QUEUE_NAMES.ORPHANED_REPO_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: runningTasks:
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].active + stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].active +
stats[QUEUE_NAMES.SESSION_CLEANUP].active + stats[QUEUE_NAMES.SESSION_CLEANUP].active +
stats[QUEUE_NAMES.ORPHANED_REPO_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: failedTasks:
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].failed + stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].failed +
stats[QUEUE_NAMES.SESSION_CLEANUP].failed + stats[QUEUE_NAMES.SESSION_CLEANUP].failed +
stats[QUEUE_NAMES.ORPHANED_REPO_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) => { totalAutomations: Object.values(stats).reduce((sum, queueStats) => {
return ( return (
@@ -375,10 +403,11 @@ router.get("/overview", authenticateToken, async (_req, res) => {
stats: stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP], stats: stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP],
}, },
{ {
name: "Collect Host Statistics", name: "Docker Inventory Cleanup",
queue: QUEUE_NAMES.AGENT_COMMANDS, queue: QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP,
description: "Collects package statistics from connected agents only", description:
schedule: `Every ${settings.update_interval} minutes (Agent-driven)`, "Removes Docker containers and images for non-existent hosts",
schedule: "Daily at 4 AM",
lastRun: recentJobs[4][0]?.finishedOn lastRun: recentJobs[4][0]?.finishedOn
? new Date(recentJobs[4][0].finishedOn).toLocaleString() ? new Date(recentJobs[4][0].finishedOn).toLocaleString()
: "Never", : "Never",
@@ -388,6 +417,22 @@ router.get("/overview", authenticateToken, async (_req, res) => {
: recentJobs[4][0] : recentJobs[4][0]
? "Success" ? "Success"
: "Never run", : "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], stats: stats[QUEUE_NAMES.AGENT_COMMANDS],
}, },
].sort((a, b) => { ].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) => { router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
try { 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({ const hosts = await prisma.hosts.findMany({
// Show all hosts regardless of status
select: { select: {
id: true, id: true,
machine_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" }, orderBy: { last_update: "desc" },
}); });
// Get update counts for each host separately // OPTIMIZATION: Get all package counts in 2 batch queries instead of N*2 queries
const hostsWithUpdateInfo = await Promise.all( const hostIds = hosts.map((h) => h.id);
hosts.map(async (host) => {
const updatesCount = await prisma.host_packages.count({
where: {
host_id: host.id,
needs_update: true,
},
});
// Get total packages count for this host const [updateCounts, totalCounts] = await Promise.all([
const totalPackagesCount = await prisma.host_packages.count({ // Get update counts for all hosts at once
where: { prisma.host_packages.groupBy({
host_id: host.id, by: ["host_id"],
}, where: {
}); host_id: { in: hostIds },
needs_update: true,
// Get the agent update interval setting for stale calculation },
const settings = await prisma.settings.findFirst(); _count: { id: true },
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,
};
}), }),
// 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); res.json(hostsWithUpdateInfo);
} catch (error) { } 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) => { router.post("/collect", async (req, res) => {
try { try {
const { apiId, apiKey, containers, images, updates } = req.body; const { apiId, apiKey, containers, images, updates } = req.body;
@@ -572,6 +573,7 @@ router.post("/collect", async (req, res) => {
image_id: containerData.image_id || "unknown", image_id: containerData.image_id || "unknown",
source: containerData.image_source || "docker-hub", source: containerData.image_source || "docker-hub",
created_at: parseDate(containerData.created_at), created_at: parseDate(containerData.created_at),
last_checked: now,
updated_at: now, updated_at: now,
}, },
}); });
@@ -745,6 +747,497 @@ 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),
last_checked: now,
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) {
// If image has no digest, it's likely locally built - override source to "local"
const imageSource =
!imageData.digest || imageData.digest.trim() === ""
? "local"
: imageData.source || "docker-hub";
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,
source: imageSource, // Update source in case it changed
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: imageSource,
created_at: parseDate(imageData.created_at),
last_checked: now,
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/volumes - Get all volumes with filters
router.get("/volumes", authenticateToken, async (req, res) => {
try {
const { driver, search, page = 1, limit = 50 } = req.query;
const where = {};
if (driver) where.driver = driver;
if (search) {
where.OR = [{ name: { contains: search, mode: "insensitive" } }];
}
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
const take = parseInt(limit, 10);
const [volumes, total] = await Promise.all([
prisma.docker_volumes.findMany({
where,
include: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
},
},
},
orderBy: { updated_at: "desc" },
skip,
take,
}),
prisma.docker_volumes.count({ where }),
]);
res.json(
convertBigIntToString({
volumes,
pagination: {
page: parseInt(page, 10),
limit: parseInt(limit, 10),
total,
totalPages: Math.ceil(total / parseInt(limit, 10)),
},
}),
);
} catch (error) {
console.error("Error fetching volumes:", error);
res.status(500).json({ error: "Failed to fetch volumes" });
}
});
// GET /api/v1/docker/volumes/:id - Get volume detail
router.get("/volumes/:id", authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const volume = await prisma.docker_volumes.findUnique({
where: { id },
include: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
os_type: true,
os_version: true,
},
},
},
});
if (!volume) {
return res.status(404).json({ error: "Volume not found" });
}
res.json(convertBigIntToString({ volume }));
} catch (error) {
console.error("Error fetching volume detail:", error);
res.status(500).json({ error: "Failed to fetch volume detail" });
}
});
// GET /api/v1/docker/networks - Get all networks with filters
router.get("/networks", authenticateToken, async (req, res) => {
try {
const { driver, search, page = 1, limit = 50 } = req.query;
const where = {};
if (driver) where.driver = driver;
if (search) {
where.OR = [{ name: { contains: search, mode: "insensitive" } }];
}
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
const take = parseInt(limit, 10);
const [networks, total] = await Promise.all([
prisma.docker_networks.findMany({
where,
include: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
},
},
},
orderBy: { updated_at: "desc" },
skip,
take,
}),
prisma.docker_networks.count({ where }),
]);
res.json(
convertBigIntToString({
networks,
pagination: {
page: parseInt(page, 10),
limit: parseInt(limit, 10),
total,
totalPages: Math.ceil(total / parseInt(limit, 10)),
},
}),
);
} catch (error) {
console.error("Error fetching networks:", error);
res.status(500).json({ error: "Failed to fetch networks" });
}
});
// GET /api/v1/docker/networks/:id - Get network detail
router.get("/networks/:id", authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const network = await prisma.docker_networks.findUnique({
where: { id },
include: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
os_type: true,
os_version: true,
},
},
},
});
if (!network) {
return res.status(404).json({ error: "Network not found" });
}
res.json(convertBigIntToString({ network }));
} catch (error) {
console.error("Error fetching network detail:", error);
res.status(500).json({ error: "Failed to fetch network detail" });
}
});
// GET /api/v1/docker/agent - Serve the Docker agent installation script // GET /api/v1/docker/agent - Serve the Docker agent installation script
router.get("/agent", async (_req, res) => { router.get("/agent", async (_req, res) => {
try { try {
@@ -776,4 +1269,66 @@ router.get("/agent", async (_req, res) => {
} }
}); });
// DELETE /api/v1/docker/volumes/:id - Delete a volume
router.delete("/volumes/:id", authenticateToken, async (req, res) => {
try {
const { id } = req.params;
// Check if volume exists
const volume = await prisma.docker_volumes.findUnique({
where: { id },
});
if (!volume) {
return res.status(404).json({ error: "Volume not found" });
}
// Delete the volume
await prisma.docker_volumes.delete({
where: { id },
});
console.log(`🗑️ Deleted volume: ${volume.name} (${id})`);
res.json({
success: true,
message: `Volume ${volume.name} deleted successfully`,
});
} catch (error) {
console.error("Error deleting volume:", error);
res.status(500).json({ error: "Failed to delete volume" });
}
});
// DELETE /api/v1/docker/networks/:id - Delete a network
router.delete("/networks/:id", authenticateToken, async (req, res) => {
try {
const { id } = req.params;
// Check if network exists
const network = await prisma.docker_networks.findUnique({
where: { id },
});
if (!network) {
return res.status(404).json({ error: "Network not found" });
}
// Delete the network
await prisma.docker_networks.delete({
where: { id },
});
console.log(`🗑️ Deleted network: ${network.name} (${id})`);
res.json({
success: true,
message: `Network ${network.name} deleted successfully`,
});
} catch (error) {
console.error("Error deleting network:", error);
res.status(500).json({ error: "Failed to delete network" });
}
});
module.exports = router; module.exports = router;

View File

@@ -24,7 +24,15 @@ router.get("/", authenticateToken, async (_req, res) => {
}, },
}); });
res.json(hostGroups); // Transform the count field to match frontend expectations
const transformedGroups = hostGroups.map((group) => ({
...group,
_count: {
hosts: group._count.host_group_memberships,
},
}));
res.json(transformedGroups);
} catch (error) { } catch (error) {
console.error("Error fetching host groups:", error); console.error("Error fetching host groups:", error);
res.status(500).json({ error: "Failed to fetch host groups" }); res.status(500).json({ error: "Failed to fetch host groups" });

View File

@@ -10,6 +10,7 @@ const {
requireManageHosts, requireManageHosts,
requireManageSettings, requireManageSettings,
} = require("../middleware/permissions"); } = require("../middleware/permissions");
const { queueManager, QUEUE_NAMES } = require("../services/automation");
const router = express.Router(); const router = express.Router();
const prisma = getPrismaClient(); const prisma = getPrismaClient();
@@ -356,6 +357,26 @@ router.post(
}); });
} catch (error) { } catch (error) {
console.error("Host creation error:", 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" }); res.status(500).json({ error: "Failed to create host" });
} }
}, },
@@ -786,19 +807,41 @@ router.get("/info", validateApiCredentials, async (req, res) => {
// Ping endpoint for health checks (now uses API credentials) // Ping endpoint for health checks (now uses API credentials)
router.post("/ping", validateApiCredentials, async (req, res) => { router.post("/ping", validateApiCredentials, async (req, res) => {
try { 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({ await prisma.hosts.update({
where: { id: req.hostRecord.id }, where: { id: req.hostRecord.id },
data: { data: {
last_update: new Date(), last_update: now,
updated_at: new Date(), updated_at: now,
status: "active",
}, },
}); });
const response = { const response = {
message: "Ping successful", message: "Ping successful",
timestamp: new Date().toISOString(), timestamp: now.toISOString(),
friendlyName: req.hostRecord.friendly_name, friendlyName: req.hostRecord.friendly_name,
agentStartup: isStartup,
}; };
// Check if this is a crontab update trigger // Check if this is a crontab update trigger
@@ -1345,6 +1388,66 @@ router.delete(
}, },
); );
// Force immediate report from agent
router.post(
"/:hostId/fetch-report",
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 the agent-commands queue
const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS];
if (!queue) {
return res.status(500).json({
error: "Queue not available",
});
}
// Add job to queue
const job = await queue.add(
"report_now",
{
api_id: host.api_id,
type: "report_now",
},
{
attempts: 3,
backoff: {
type: "exponential",
delay: 2000,
},
},
);
res.json({
success: true,
message: "Report fetch queued successfully",
jobId: job.id,
host: {
id: host.id,
friendlyName: host.friendly_name,
apiId: host.api_id,
},
});
} catch (error) {
console.error("Force fetch report error:", error);
res.status(500).json({ error: "Failed to fetch report" });
}
},
);
// Toggle agent auto-update setting // Toggle agent auto-update setting
router.patch( router.patch(
"/:hostId/auto-update", "/:hostId/auto-update",
@@ -1388,6 +1491,66 @@ 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 the agent-commands queue
const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS];
if (!queue) {
return res.status(500).json({
error: "Queue not available",
});
}
// Add job to queue
const job = 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",
jobId: job.id,
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) // Serve the installation script (requires API authentication)
router.get("/install", async (req, res) => { router.get("/install", async (req, res) => {
try { try {
@@ -1441,10 +1604,12 @@ router.get("/install", async (req, res) => {
// Determine curl flags dynamically from settings (ignore self-signed) // Determine curl flags dynamically from settings (ignore self-signed)
let curlFlags = "-s"; let curlFlags = "-s";
let skipSSLVerify = "false";
try { try {
const settings = await prisma.settings.findFirst(); const settings = await prisma.settings.findFirst();
if (settings && settings.ignore_ssl_self_signed === true) { if (settings && settings.ignore_ssl_self_signed === true) {
curlFlags = "-sk"; curlFlags = "-sk";
skipSSLVerify = "true";
} }
} catch (_) {} } catch (_) {}
@@ -1454,12 +1619,13 @@ router.get("/install", async (req, res) => {
// Get architecture parameter (default to amd64) // Get architecture parameter (default to amd64)
const architecture = req.query.arch || "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 const envVars = `#!/bin/bash
export PATCHMON_URL="${serverUrl}" export PATCHMON_URL="${serverUrl}"
export API_ID="${host.api_id}" export API_ID="${host.api_id}"
export API_KEY="${host.api_key}" export API_KEY="${host.api_key}"
export CURL_FLAGS="${curlFlags}" export CURL_FLAGS="${curlFlags}"
export SKIP_SSL_VERIFY="${skipSSLVerify}"
export FORCE_INSTALL="${forceInstall ? "true" : "false"}" export FORCE_INSTALL="${forceInstall ? "true" : "false"}"
export ARCHITECTURE="${architecture}" export ARCHITECTURE="${architecture}"

View File

@@ -0,0 +1,356 @@
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,
volumes,
networks,
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 volumesProcessed = 0;
let networksProcessed = 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 volumes
if (volumes && Array.isArray(volumes)) {
console.log(`[Docker Integration] Processing ${volumes.length} volumes`);
for (const volumeData of volumes) {
await prisma.docker_volumes.upsert({
where: {
host_id_volume_id: {
host_id: host.id,
volume_id: volumeData.volume_id,
},
},
update: {
name: volumeData.name,
driver: volumeData.driver || "local",
mountpoint: volumeData.mountpoint || null,
renderer: volumeData.renderer || null,
scope: volumeData.scope || "local",
labels: volumeData.labels || null,
options: volumeData.options || null,
size_bytes: volumeData.size_bytes
? BigInt(volumeData.size_bytes)
: null,
ref_count: volumeData.ref_count || 0,
updated_at: now,
last_checked: now,
},
create: {
id: uuidv4(),
host_id: host.id,
volume_id: volumeData.volume_id,
name: volumeData.name,
driver: volumeData.driver || "local",
mountpoint: volumeData.mountpoint || null,
renderer: volumeData.renderer || null,
scope: volumeData.scope || "local",
labels: volumeData.labels || null,
options: volumeData.options || null,
size_bytes: volumeData.size_bytes
? BigInt(volumeData.size_bytes)
: null,
ref_count: volumeData.ref_count || 0,
created_at: parseDate(volumeData.created_at),
updated_at: now,
},
});
volumesProcessed++;
}
}
// Process networks
if (networks && Array.isArray(networks)) {
console.log(
`[Docker Integration] Processing ${networks.length} networks`,
);
for (const networkData of networks) {
await prisma.docker_networks.upsert({
where: {
host_id_network_id: {
host_id: host.id,
network_id: networkData.network_id,
},
},
update: {
name: networkData.name,
driver: networkData.driver,
scope: networkData.scope || "local",
ipv6_enabled: networkData.ipv6_enabled || false,
internal: networkData.internal || false,
attachable:
networkData.attachable !== undefined
? networkData.attachable
: true,
ingress: networkData.ingress || false,
config_only: networkData.config_only || false,
labels: networkData.labels || null,
ipam: networkData.ipam || null,
container_count: networkData.container_count || 0,
updated_at: now,
last_checked: now,
},
create: {
id: uuidv4(),
host_id: host.id,
network_id: networkData.network_id,
name: networkData.name,
driver: networkData.driver,
scope: networkData.scope || "local",
ipv6_enabled: networkData.ipv6_enabled || false,
internal: networkData.internal || false,
attachable:
networkData.attachable !== undefined
? networkData.attachable
: true,
ingress: networkData.ingress || false,
config_only: networkData.config_only || false,
labels: networkData.labels || null,
ipam: networkData.ipam || null,
container_count: networkData.container_count || 0,
created_at: networkData.created_at
? parseDate(networkData.created_at)
: null,
updated_at: now,
},
});
networksProcessed++;
}
}
// 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, ${volumesProcessed} volumes, ${networksProcessed} networks, ${updatesProcessed} updates`,
);
res.json({
message: "Docker data collected successfully",
containers_received: containersProcessed,
images_received: imagesProcessed,
volumes_received: volumesProcessed,
networks_received: networksProcessed,
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 }), prisma.packages.count({ where }),
]); ]);
// Get additional stats for each package // OPTIMIZATION: Batch query all stats instead of N individual queries
const packagesWithStats = await Promise.all( const packageIds = packages.map((pkg) => pkg.id);
packages.map(async (pkg) => {
// Build base where clause for this package
const baseWhere = { package_id: pkg.id };
// If host filter is specified, add host filter to all queries // Get all counts and host data in 3 batch queries instead of N*3 queries
const hostWhere = host ? { ...baseWhere, host_id: host } : baseWhere; const [allUpdatesCounts, allSecurityCounts, allPackageHostsData] =
await Promise.all([
const [updatesCount, securityCount, packageHosts] = await Promise.all([ // Batch count all packages that need updates
prisma.host_packages.count({ prisma.host_packages.groupBy({
where: { by: ["package_id"],
...hostWhere, where: {
needs_update: true, package_id: { in: packageIds },
}, needs_update: true,
}), ...(host ? { host_id: host } : {}),
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,
}, },
}; _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({ res.json({
packages: packagesWithStats, packages: packagesWithStats,

View File

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

View File

@@ -261,8 +261,10 @@ router.post(
body("username").notEmpty().withMessage("Username is required"), body("username").notEmpty().withMessage("Username is required"),
body("token") body("token")
.isLength({ min: 6, max: 6 }) .isLength({ min: 6, max: 6 })
.withMessage("Token must be 6 digits"), .withMessage("Token must be 6 characters"),
body("token").isNumeric().withMessage("Token must contain only numbers"), body("token")
.matches(/^[A-Z0-9]{6}$/)
.withMessage("Token must be 6 alphanumeric characters"),
], ],
async (req, res) => { async (req, res) => {
try { try {

View File

@@ -0,0 +1,105 @@
const express = require("express");
const { getPrismaClient } = require("../config/prisma");
const { authenticateToken } = require("../middleware/auth");
const router = express.Router();
const prisma = getPrismaClient();
/**
* GET /api/v1/user/preferences
* Get current user's preferences (theme and color theme)
*/
router.get("/", authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const user = await prisma.users.findUnique({
where: { id: userId },
select: {
theme_preference: true,
color_theme: true,
},
});
if (!user) {
return res.status(404).json({ error: "User not found" });
}
res.json({
theme_preference: user.theme_preference || "dark",
color_theme: user.color_theme || "cyber_blue",
});
} catch (error) {
console.error("Error fetching user preferences:", error);
res.status(500).json({ error: "Failed to fetch user preferences" });
}
});
/**
* PATCH /api/v1/user/preferences
* Update current user's preferences
*/
router.patch("/", authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const { theme_preference, color_theme } = req.body;
// Validate inputs
const updateData = {};
if (theme_preference !== undefined) {
if (!["light", "dark"].includes(theme_preference)) {
return res.status(400).json({
error: "Invalid theme preference. Must be 'light' or 'dark'",
});
}
updateData.theme_preference = theme_preference;
}
if (color_theme !== undefined) {
const validColorThemes = [
"default",
"cyber_blue",
"neon_purple",
"matrix_green",
"ocean_blue",
"sunset_gradient",
];
if (!validColorThemes.includes(color_theme)) {
return res.status(400).json({
error: `Invalid color theme. Must be one of: ${validColorThemes.join(", ")}`,
});
}
updateData.color_theme = color_theme;
}
if (Object.keys(updateData).length === 0) {
return res
.status(400)
.json({ error: "No preferences provided to update" });
}
updateData.updated_at = new Date();
const updatedUser = await prisma.users.update({
where: { id: userId },
data: updateData,
select: {
theme_preference: true,
color_theme: true,
},
});
res.json({
message: "Preferences updated successfully",
preferences: {
theme_preference: updatedUser.theme_preference,
color_theme: updatedUser.color_theme,
},
});
} catch (error) {
console.error("Error updating user preferences:", error);
res.status(500).json({ error: "Failed to update user preferences" });
}
});
module.exports = router;

View File

@@ -14,13 +14,16 @@ const router = express.Router();
function getCurrentVersion() { function getCurrentVersion() {
try { try {
const packageJson = require("../../package.json"); 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) { } catch (packageError) {
console.warn( console.error(
"Could not read version from package.json, using fallback:", "Could not read version from package.json:",
packageError.message, packageError.message,
); );
return "1.3.0"; return "unknown";
} }
} }

View File

@@ -11,7 +11,31 @@ const {
const router = express.Router(); 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) => { router.get("/status/:apiId", authenticateToken, async (req, res) => {
try { try {
const { apiId } = req.params; const { apiId } = req.params;

View File

@@ -66,8 +66,11 @@ const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes");
const gethomepageRoutes = require("./routes/gethomepageRoutes"); const gethomepageRoutes = require("./routes/gethomepageRoutes");
const automationRoutes = require("./routes/automationRoutes"); const automationRoutes = require("./routes/automationRoutes");
const dockerRoutes = require("./routes/dockerRoutes"); const dockerRoutes = require("./routes/dockerRoutes");
const integrationRoutes = require("./routes/integrationRoutes");
const wsRoutes = require("./routes/wsRoutes"); const wsRoutes = require("./routes/wsRoutes");
const agentVersionRoutes = require("./routes/agentVersionRoutes"); const agentVersionRoutes = require("./routes/agentVersionRoutes");
const metricsRoutes = require("./routes/metricsRoutes");
const userPreferencesRoutes = require("./routes/userPreferencesRoutes");
const { initSettings } = require("./services/settingsService"); const { initSettings } = require("./services/settingsService");
const { queueManager } = require("./services/automation"); const { queueManager } = require("./services/automation");
const { authenticateToken, requireAdmin } = require("./middleware/auth"); const { authenticateToken, requireAdmin } = require("./middleware/auth");
@@ -471,8 +474,11 @@ app.use(
app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes); app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes);
app.use(`/api/${apiVersion}/automation`, automationRoutes); app.use(`/api/${apiVersion}/automation`, automationRoutes);
app.use(`/api/${apiVersion}/docker`, dockerRoutes); app.use(`/api/${apiVersion}/docker`, dockerRoutes);
app.use(`/api/${apiVersion}/integrations`, integrationRoutes);
app.use(`/api/${apiVersion}/ws`, wsRoutes); app.use(`/api/${apiVersion}/ws`, wsRoutes);
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes); app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
app.use(`/api/${apiVersion}/user/preferences`, userPreferencesRoutes);
// Bull Board - will be populated after queue manager initializes // Bull Board - will be populated after queue manager initializes
let bullBoardRouter = null; let bullBoardRouter = null;
@@ -1198,6 +1204,15 @@ async function startServer() {
initAgentWs(server, prisma); initAgentWs(server, prisma);
await agentVersionService.initialize(); 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, () => { server.listen(PORT, () => {
if (process.env.ENABLE_LOGGING === "true") { if (process.env.ENABLE_LOGGING === "true") {
logger.info(`Server running on port ${PORT}`); logger.info(`Server running on port ${PORT}`);

View File

@@ -428,26 +428,29 @@ class AgentVersionService {
async getVersionInfo() { async getVersionInfo() {
let hasUpdate = false; let hasUpdate = false;
let updateStatus = "unknown"; 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 // Latest version should ALWAYS come from GitHub, not from local binaries
if (this.currentVersion) { // currentVersion = what's installed locally
effectiveLatestVersion = this.currentVersion; // latestVersion = what's available on GitHub
if (this.latestVersion) {
console.log(`📦 Latest version from GitHub: ${this.latestVersion}`);
} else {
console.log( console.log(
`🔄 Using local agent version ${this.currentVersion} as latest`, `⚠️ No GitHub release version available (API may be unavailable)`,
);
} 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}`,
); );
} }
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( const comparison = compareVersions(
this.currentVersion, this.currentVersion,
effectiveLatestVersion, this.latestVersion,
); );
if (comparison < 0) { if (comparison < 0) {
hasUpdate = true; hasUpdate = true;
@@ -459,25 +462,25 @@ class AgentVersionService {
hasUpdate = false; hasUpdate = false;
updateStatus = "up-to-date"; updateStatus = "up-to-date";
} }
} else if (effectiveLatestVersion && !this.currentVersion) { } else if (this.latestVersion && !this.currentVersion) {
hasUpdate = true; hasUpdate = true;
updateStatus = "no-agent"; 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) // We have a current version but no latest version (GitHub API unavailable)
hasUpdate = false; hasUpdate = false;
updateStatus = "github-unavailable"; updateStatus = "github-unavailable";
} else if (!this.currentVersion && !effectiveLatestVersion) { } else if (!this.currentVersion && !this.latestVersion) {
updateStatus = "no-data"; updateStatus = "no-data";
} }
return { return {
currentVersion: this.currentVersion, currentVersion: this.currentVersion,
latestVersion: effectiveLatestVersion, latestVersion: this.latestVersion, // Always return GitHub version, not local
hasUpdate: hasUpdate, hasUpdate: hasUpdate,
updateStatus: updateStatus, updateStatus: updateStatus,
lastChecked: this.lastChecked, lastChecked: this.lastChecked,
supportedArchitectures: this.supportedArchitectures, 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 // Notify subscribers of connection
notifyConnectionChange(apiId, true); notifyConnectionChange(apiId, true);
ws.on("message", () => { ws.on("message", async (data) => {
// Currently we don't need to handle agent->server messages // 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", () => { 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) { function pushUpdateNotification(apiId, updateInfo) {
const ws = apiIdToSocket.get(apiId); const ws = apiIdToSocket.get(apiId);
if (ws && ws.readyState === WebSocket.OPEN) { 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 = { module.exports = {
init, init,
broadcastSettingsUpdate, broadcastSettingsUpdate,
pushReportNow, pushReportNow,
pushSettingsUpdate, pushSettingsUpdate,
pushUpdateAgent,
pushUpdateNotification, pushUpdateNotification,
pushUpdateNotificationToAll, pushUpdateNotificationToAll,
// Expose read-only view of connected agents // Expose read-only view of connected agents
getConnectedApiIds: () => Array.from(apiIdToSocket.keys()), getConnectedApiIds: () => Array.from(apiIdToSocket.keys()),
getConnectionByApiId,
isConnected: (apiId) => { isConnected: (apiId) => {
const ws = apiIdToSocket.get(apiId); const ws = apiIdToSocket.get(apiId);
return !!ws && ws.readyState === WebSocket.OPEN; return !!ws && ws.readyState === WebSocket.OPEN;

View File

@@ -0,0 +1,343 @@
const { prisma } = require("./shared/prisma");
const https = require("node:https");
const http = require("node:http");
const { v4: uuidv4 } = require("uuid");
/**
* Docker Image Update Check Automation
* Checks for Docker image updates by comparing local digests with remote registry digests
*/
class DockerImageUpdateCheck {
constructor(queueManager) {
this.queueManager = queueManager;
this.queueName = "docker-image-update-check";
}
/**
* Get remote digest from Docker registry using HEAD request
* Supports Docker Hub, GHCR, and other OCI-compliant registries
*/
async getRemoteDigest(imageName, tag = "latest") {
return new Promise((resolve, reject) => {
// Parse image name to determine registry
const registryInfo = this.parseImageName(imageName);
// Construct manifest URL
const manifestPath = `/v2/${registryInfo.repository}/manifests/${tag}`;
const options = {
hostname: registryInfo.registry,
path: manifestPath,
method: "HEAD",
headers: {
Accept:
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.oci.image.index.v1+json",
"User-Agent": "PatchMon/1.0",
},
};
// Add authentication token for Docker Hub if needed
if (
registryInfo.registry === "registry-1.docker.io" &&
registryInfo.isPublic
) {
// For anonymous public images, we may need to get an auth token first
// For now, try without auth (works for public images)
}
// Choose HTTP or HTTPS
const client = registryInfo.isSecure ? https : http;
const req = client.request(options, (res) => {
if (res.statusCode === 401 || res.statusCode === 403) {
// Authentication required - skip for now (would need to implement auth)
return reject(
new Error(`Authentication required for ${imageName}:${tag}`),
);
}
if (res.statusCode !== 200) {
return reject(
new Error(
`Registry returned status ${res.statusCode} for ${imageName}:${tag}`,
),
);
}
// Get digest from Docker-Content-Digest header
const digest = res.headers["docker-content-digest"];
if (!digest) {
return reject(
new Error(
`No Docker-Content-Digest header for ${imageName}:${tag}`,
),
);
}
// Clean up digest (remove sha256: prefix if present)
const cleanDigest = digest.startsWith("sha256:")
? digest.substring(7)
: digest;
resolve(cleanDigest);
});
req.on("error", (error) => {
reject(error);
});
req.setTimeout(10000, () => {
req.destroy();
reject(new Error(`Timeout getting digest for ${imageName}:${tag}`));
});
req.end();
});
}
/**
* Parse image name to extract registry, repository, and determine if secure
*/
parseImageName(imageName) {
let registry = "registry-1.docker.io";
let repository = imageName;
const isSecure = true;
let isPublic = true;
// Handle explicit registries (ghcr.io, quay.io, etc.)
if (imageName.includes("/")) {
const parts = imageName.split("/");
const firstPart = parts[0];
// Check for known registries
if (firstPart.includes(".") || firstPart === "localhost") {
registry = firstPart;
repository = parts.slice(1).join("/");
isPublic = false; // Assume private registries need auth for now
} else {
// Docker Hub - registry-1.docker.io
repository = imageName;
}
}
// Docker Hub official images (no namespace)
if (!repository.includes("/")) {
repository = `library/${repository}`;
}
return {
registry,
repository,
isSecure,
isPublic,
};
}
/**
* Process Docker image update check job
*/
async process(_job) {
const startTime = Date.now();
console.log("🐳 Starting Docker image update check...");
try {
// Get all Docker images that have a digest and repository
const images = await prisma.docker_images.findMany({
where: {
digest: {
not: null,
},
repository: {
not: null,
},
},
include: {
docker_image_updates: true,
},
});
console.log(`📦 Found ${images.length} images to check for updates`);
let checkedCount = 0;
let updateCount = 0;
let errorCount = 0;
const errors = [];
// Process images in batches to avoid overwhelming the API
const batchSize = 10;
for (let i = 0; i < images.length; i += batchSize) {
const batch = images.slice(i, i + batchSize);
// Process batch concurrently with Promise.allSettled for error tolerance
const _results = await Promise.allSettled(
batch.map(async (image) => {
try {
checkedCount++;
// Skip local images (no digest means they're local)
if (!image.digest || image.digest.trim() === "") {
return { image, skipped: true, reason: "No digest" };
}
// Get clean digest (remove sha256: prefix if present)
const localDigest = image.digest.startsWith("sha256:")
? image.digest.substring(7)
: image.digest;
// Get remote digest from registry
const remoteDigest = await this.getRemoteDigest(
image.repository,
image.tag || "latest",
);
// Compare digests
if (localDigest !== remoteDigest) {
console.log(
`🔄 Update found: ${image.repository}:${image.tag} (local: ${localDigest.substring(0, 12)}..., remote: ${remoteDigest.substring(0, 12)}...)`,
);
// Store digest info in changelog_url field as JSON
const digestInfo = JSON.stringify({
method: "digest_comparison",
current_digest: localDigest,
available_digest: remoteDigest,
checked_at: new Date().toISOString(),
});
// Upsert the update record
await prisma.docker_image_updates.upsert({
where: {
image_id_available_tag: {
image_id: image.id,
available_tag: image.tag || "latest",
},
},
update: {
updated_at: new Date(),
changelog_url: digestInfo,
severity: "digest_changed",
},
create: {
id: uuidv4(),
image_id: image.id,
current_tag: image.tag || "latest",
available_tag: image.tag || "latest",
severity: "digest_changed",
changelog_url: digestInfo,
updated_at: new Date(),
},
});
// Update last_checked timestamp on image
await prisma.docker_images.update({
where: { id: image.id },
data: { last_checked: new Date() },
});
updateCount++;
return { image, updated: true };
} else {
// No update - still update last_checked
await prisma.docker_images.update({
where: { id: image.id },
data: { last_checked: new Date() },
});
// Remove existing update record if digest matches now
const existingUpdate = image.docker_image_updates?.find(
(u) => u.available_tag === (image.tag || "latest"),
);
if (existingUpdate) {
await prisma.docker_image_updates.delete({
where: { id: existingUpdate.id },
});
}
return { image, updated: false };
}
} catch (error) {
errorCount++;
const errorMsg = `Error checking ${image.repository}:${image.tag}: ${error.message}`;
errors.push(errorMsg);
console.error(`${errorMsg}`);
// Still update last_checked even on error
try {
await prisma.docker_images.update({
where: { id: image.id },
data: { last_checked: new Date() },
});
} catch (_updateError) {
// Ignore update errors
}
return { image, error: error.message };
}
}),
);
// Log batch progress
if (i + batchSize < images.length) {
console.log(
`⏳ Processed ${Math.min(i + batchSize, images.length)}/${images.length} images...`,
);
}
// Small delay between batches to be respectful to registries
if (i + batchSize < images.length) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
const executionTime = Date.now() - startTime;
console.log(
`✅ Docker image update check completed in ${executionTime}ms - Checked: ${checkedCount}, Updates: ${updateCount}, Errors: ${errorCount}`,
);
return {
success: true,
checked: checkedCount,
updates: updateCount,
errors: errorCount,
executionTime,
errorDetails: errors,
};
} catch (error) {
const executionTime = Date.now() - startTime;
console.error(
`❌ Docker image update check failed after ${executionTime}ms:`,
error.message,
);
throw error;
}
}
/**
* Schedule recurring Docker image update check (daily at 2 AM)
*/
async schedule() {
const job = await this.queueManager.queues[this.queueName].add(
"docker-image-update-check",
{},
{
repeat: { cron: "0 2 * * *" }, // Daily at 2 AM
jobId: "docker-image-update-check-recurring",
},
);
console.log("✅ Docker image update check scheduled");
return job;
}
/**
* Trigger manual Docker image update check
*/
async triggerManual() {
const job = await this.queueManager.queues[this.queueName].add(
"docker-image-update-check-manual",
{},
{ priority: 1 },
);
console.log("✅ Manual Docker image update check triggered");
return job;
}
}
module.exports = DockerImageUpdateCheck;

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 // Read version from package.json
let currentVersion = "1.3.0"; // fallback let currentVersion = null;
try { try {
const packageJson = require("../../../package.json"); const packageJson = require("../../../package.json");
if (packageJson?.version) { if (packageJson?.version) {
currentVersion = packageJson.version; currentVersion = packageJson.version;
} }
} catch (packageError) { } catch (packageError) {
console.warn( console.error(
"Could not read version from package.json:", "Could not read version from package.json:",
packageError.message, 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 = const isUpdateAvailable =

View File

@@ -2,12 +2,16 @@ const { Queue, Worker } = require("bullmq");
const { redis, redisConnection } = require("./shared/redis"); const { redis, redisConnection } = require("./shared/redis");
const { prisma } = require("./shared/prisma"); const { prisma } = require("./shared/prisma");
const agentWs = require("../agentWs"); const agentWs = require("../agentWs");
const { v4: uuidv4 } = require("uuid");
// Import automation classes // Import automation classes
const GitHubUpdateCheck = require("./githubUpdateCheck"); const GitHubUpdateCheck = require("./githubUpdateCheck");
const SessionCleanup = require("./sessionCleanup"); const SessionCleanup = require("./sessionCleanup");
const OrphanedRepoCleanup = require("./orphanedRepoCleanup"); const OrphanedRepoCleanup = require("./orphanedRepoCleanup");
const OrphanedPackageCleanup = require("./orphanedPackageCleanup"); const OrphanedPackageCleanup = require("./orphanedPackageCleanup");
const DockerInventoryCleanup = require("./dockerInventoryCleanup");
const DockerImageUpdateCheck = require("./dockerImageUpdateCheck");
const MetricsReporting = require("./metricsReporting");
// Queue names // Queue names
const QUEUE_NAMES = { const QUEUE_NAMES = {
@@ -15,6 +19,9 @@ const QUEUE_NAMES = {
SESSION_CLEANUP: "session-cleanup", SESSION_CLEANUP: "session-cleanup",
ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup", ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup",
ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup", ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup",
DOCKER_INVENTORY_CLEANUP: "docker-inventory-cleanup",
DOCKER_IMAGE_UPDATE_CHECK: "docker-image-update-check",
METRICS_REPORTING: "metrics-reporting",
AGENT_COMMANDS: "agent-commands", AGENT_COMMANDS: "agent-commands",
}; };
@@ -91,6 +98,13 @@ class QueueManager {
new OrphanedRepoCleanup(this); new OrphanedRepoCleanup(this);
this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP] = this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP] =
new OrphanedPackageCleanup(this); new OrphanedPackageCleanup(this);
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] =
new DockerInventoryCleanup(this);
this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK] =
new DockerImageUpdateCheck(this);
this.automations[QUEUE_NAMES.METRICS_REPORTING] = new MetricsReporting(
this,
);
console.log("✅ All automation classes initialized"); console.log("✅ All automation classes initialized");
} }
@@ -149,6 +163,33 @@ class QueueManager {
workerOptions, 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,
);
// Docker Image Update Check Worker
this.workers[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK] = new Worker(
QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK,
this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK].process.bind(
this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK],
),
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 // Agent Commands Worker
this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker( this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker(
QUEUE_NAMES.AGENT_COMMANDS, QUEUE_NAMES.AGENT_COMMANDS,
@@ -156,15 +197,87 @@ class QueueManager {
const { api_id, type } = job.data; const { api_id, type } = job.data;
console.log(`Processing agent command: ${type} for ${api_id}`); console.log(`Processing agent command: ${type} for ${api_id}`);
// Send command via WebSocket based on type // Log job to job_history
if (type === "report_now") { let historyRecord = null;
agentWs.pushReportNow(api_id); try {
} else if (type === "settings_update") { const host = await prisma.hosts.findUnique({
// For settings update, we need additional data where: { api_id },
const { update_interval } = job.data; select: { id: true },
agentWs.pushSettingsUpdate(api_id, update_interval); });
} else {
console.error(`Unknown agent command type: ${type}`); if (host) {
historyRecord = await prisma.job_history.create({
data: {
id: uuidv4(),
job_id: job.id,
queue_name: QUEUE_NAMES.AGENT_COMMANDS,
job_name: type,
host_id: host.id,
api_id: api_id,
status: "active",
attempt_number: job.attemptsMade + 1,
created_at: new Date(),
updated_at: new Date(),
},
});
console.log(`📝 Logged job to job_history: ${job.id} (${type})`);
}
} catch (error) {
console.error("Failed to log job to job_history:", error);
}
try {
// Send command via WebSocket based on type
if (type === "report_now") {
agentWs.pushReportNow(api_id);
} else if (type === "settings_update") {
// 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}`);
}
// Update job history to completed
if (historyRecord) {
await prisma.job_history.updateMany({
where: { job_id: job.id },
data: {
status: "completed",
completed_at: new Date(),
updated_at: new Date(),
},
});
console.log(`✅ Marked job as completed in job_history: ${job.id}`);
}
} catch (error) {
// Update job history to failed
if (historyRecord) {
await prisma.job_history.updateMany({
where: { job_id: job.id },
data: {
status: "failed",
error_message: error.message,
completed_at: new Date(),
updated_at: new Date(),
},
});
console.log(`❌ Marked job as failed in job_history: ${job.id}`);
}
throw error;
} }
}, },
workerOptions, workerOptions,
@@ -194,6 +307,7 @@ class QueueManager {
console.log(`✅ Job '${job.id}' in queue '${queueName}' completed.`); console.log(`✅ Job '${job.id}' in queue '${queueName}' completed.`);
}); });
} }
console.log("✅ Queue events initialized"); console.log("✅ Queue events initialized");
} }
@@ -205,6 +319,9 @@ class QueueManager {
await this.automations[QUEUE_NAMES.SESSION_CLEANUP].schedule(); await this.automations[QUEUE_NAMES.SESSION_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.ORPHANED_REPO_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.ORPHANED_PACKAGE_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK].schedule();
await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule();
} }
/** /**
@@ -228,6 +345,22 @@ class QueueManager {
].triggerManual(); ].triggerManual();
} }
async triggerDockerInventoryCleanup() {
return this.automations[
QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP
].triggerManual();
}
async triggerDockerImageUpdateCheck() {
return this.automations[
QUEUE_NAMES.DOCKER_IMAGE_UPDATE_CHECK
].triggerManual();
}
async triggerMetricsReporting() {
return this.automations[QUEUE_NAMES.METRICS_REPORTING].triggerManual();
}
/** /**
* Get queue statistics * 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 { try {
const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; 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 { try {
const packageJson = require("../../../package.json"); const packageJson = require("../../../package.json");
if (packageJson?.version) { if (packageJson?.version) {
@@ -41,7 +42,7 @@ async function checkPublicRepo(owner, repo) {
} }
} catch (packageError) { } catch (packageError) {
console.warn( 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, packageError.message,
); );
} }

179
backend/src/utils/docker.js Normal file
View File

@@ -0,0 +1,179 @@
/**
* Docker-related utility functions
*/
/**
* Generate a registry link for a Docker image based on its repository and source
* Inspired by diun's registry link generation
* @param {string} repository - The full repository name (e.g., "ghcr.io/owner/repo")
* @param {string} source - The detected source (github, gitlab, docker-hub, etc.)
* @returns {string|null} - The URL to the registry page, or null if unknown
*/
function generateRegistryLink(repository, source) {
if (!repository) {
return null;
}
// Parse the domain and path from the repository
const parts = repository.split("/");
let domain = "";
let path = "";
// Check if repository has a domain (contains a dot)
if (parts[0].includes(".") || parts[0].includes(":")) {
domain = parts[0];
path = parts.slice(1).join("/");
} else {
// No domain means Docker Hub
domain = "docker.io";
path = repository;
}
switch (source) {
case "docker-hub":
case "docker.io": {
// Docker Hub: https://hub.docker.com/r/{path} or https://hub.docker.com/_/{path} for official images
// Official images are those without a namespace (e.g., "postgres" not "user/postgres")
// or explicitly prefixed with "library/"
if (path.startsWith("library/")) {
const cleanPath = path.replace("library/", "");
return `https://hub.docker.com/_/${cleanPath}`;
}
// Check if it's an official image (single part, no slash after removing library/)
if (!path.includes("/")) {
return `https://hub.docker.com/_/${path}`;
}
// Regular user/org image
return `https://hub.docker.com/r/${path}`;
}
case "github":
case "ghcr.io": {
// GitHub Container Registry
// Format: ghcr.io/{owner}/{package} or ghcr.io/{owner}/{repo}/{package}
// URL format: https://github.com/{owner}/{repo}/pkgs/container/{package}
if (domain === "ghcr.io" && path) {
const pathParts = path.split("/");
if (pathParts.length === 2) {
// Simple case: ghcr.io/owner/package -> github.com/owner/owner/pkgs/container/package
// OR: ghcr.io/owner/repo -> github.com/owner/repo/pkgs/container/{package}
// Actually, for 2 parts it's owner/package, and repo is same as owner typically
const owner = pathParts[0];
const packageName = pathParts[1];
return `https://github.com/${owner}/${owner}/pkgs/container/${packageName}`;
} else if (pathParts.length >= 3) {
// Extended case: ghcr.io/owner/repo/package -> github.com/owner/repo/pkgs/container/package
const owner = pathParts[0];
const repo = pathParts[1];
const packageName = pathParts.slice(2).join("/");
return `https://github.com/${owner}/${repo}/pkgs/container/${packageName}`;
}
}
// Legacy GitHub Packages
if (domain === "docker.pkg.github.com" && path) {
const pathParts = path.split("/");
if (pathParts.length >= 1) {
return `https://github.com/${pathParts[0]}/packages`;
}
}
return null;
}
case "gitlab":
case "registry.gitlab.com": {
// GitLab Container Registry: https://gitlab.com/{path}/container_registry
if (path) {
return `https://gitlab.com/${path}/container_registry`;
}
return null;
}
case "google":
case "gcr.io": {
// Google Container Registry: https://gcr.io/{path}
if (domain.includes("gcr.io") || domain.includes("pkg.dev")) {
return `https://console.cloud.google.com/gcr/images/${path}`;
}
return null;
}
case "quay":
case "quay.io": {
// Quay.io: https://quay.io/repository/{path}
if (path) {
return `https://quay.io/repository/${path}`;
}
return null;
}
case "redhat":
case "registry.access.redhat.com": {
// Red Hat: https://access.redhat.com/containers/#/registry.access.redhat.com/{path}
if (path) {
return `https://access.redhat.com/containers/#/registry.access.redhat.com/${path}`;
}
return null;
}
case "azure":
case "azurecr.io": {
// Azure Container Registry - link to portal
// Format: {registry}.azurecr.io/{repository}
if (domain.includes("azurecr.io")) {
const registryName = domain.split(".")[0];
return `https://portal.azure.com/#view/Microsoft_Azure_ContainerRegistries/RepositoryBlade/registryName/${registryName}/repositoryName/${path}`;
}
return null;
}
case "aws":
case "amazonaws.com": {
// AWS ECR - link to console
// Format: {account}.dkr.ecr.{region}.amazonaws.com/{repository}
if (domain.includes("amazonaws.com")) {
const domainParts = domain.split(".");
const region = domainParts[3]; // Extract region
return `https://${region}.console.aws.amazon.com/ecr/repositories/private/${path}`;
}
return null;
}
case "private":
// For private registries, try to construct a basic URL
if (domain) {
return `https://${domain}`;
}
return null;
default:
return null;
}
}
/**
* Get a user-friendly display name for a registry source
* @param {string} source - The source identifier
* @returns {string} - Human-readable source name
*/
function getSourceDisplayName(source) {
const sourceNames = {
"docker-hub": "Docker Hub",
github: "GitHub",
gitlab: "GitLab",
google: "Google",
quay: "Quay.io",
redhat: "Red Hat",
azure: "Azure",
aws: "AWS ECR",
private: "Private Registry",
local: "Local",
unknown: "Unknown",
};
return sourceNames[source] || source;
}
module.exports = {
generateRegistryLink,
getSourceDisplayName,
};

View File

@@ -84,21 +84,20 @@ function parse_expiration(expiration_string) {
* Generate device fingerprint from request data * Generate device fingerprint from request data
*/ */
function generate_device_fingerprint(req) { function generate_device_fingerprint(req) {
const components = [ // Use the X-Device-ID header from frontend (unique per browser profile/localStorage)
req.get("user-agent") || "", const deviceId = req.get("x-device-id");
req.get("accept-language") || "",
req.get("accept-encoding") || "",
req.ip || "",
];
// Create a simple hash of device characteristics if (deviceId) {
const fingerprint = crypto // Hash the device ID for consistent storage format
.createHash("sha256") return crypto
.update(components.join("|")) .createHash("sha256")
.digest("hex") .update(deviceId)
.substring(0, 32); // Use first 32 chars for storage efficiency .digest("hex")
.substring(0, 32);
}
return fingerprint; // No device ID - return null (user needs to provide device ID for remember-me)
return null;
} }
/** /**

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": { "vcs": {
"enabled": true, "enabled": true,
"clientKind": "git", "clientKind": "git",
"useIgnoreFile": true "useIgnoreFile": true
}, },
"files": {
"includes": ["**", "!**/*.css"]
},
"formatter": { "formatter": {
"enabled": true "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_MAX_ATTEMPTS` | Maximum database connection attempts | `30` |
| `PM_DB_CONN_WAIT_INTERVAL` | Wait interval between connection attempts in seconds | `2` | | `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 ##### Redis Configuration
| Variable | Description | Default | | Variable | Description | Default |

View File

@@ -46,8 +46,10 @@ COPY --chown=node:node backend/ ./backend/
WORKDIR /app/backend WORKDIR /app/backend
RUN npm ci --ignore-scripts &&\ RUN npm cache clean --force &&\
npx prisma generate &&\ 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 prune --omit=dev &&\
npm cache clean --force npm cache clean --force

View File

@@ -50,6 +50,12 @@ services:
SERVER_HOST: localhost SERVER_HOST: localhost
SERVER_PORT: 3000 SERVER_PORT: 3000
CORS_ORIGIN: http://localhost: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 Limiting (times in milliseconds)
RATE_LIMIT_WINDOW_MS: 900000 RATE_LIMIT_WINDOW_MS: 900000
RATE_LIMIT_MAX: 5000 RATE_LIMIT_MAX: 5000

View File

@@ -56,6 +56,12 @@ services:
SERVER_HOST: localhost SERVER_HOST: localhost
SERVER_PORT: 3000 SERVER_PORT: 3000
CORS_ORIGIN: http://localhost: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 Limiting (times in milliseconds)
RATE_LIMIT_WINDOW_MS: 900000 RATE_LIMIT_WINDOW_MS: 900000
RATE_LIMIT_MAX: 5000 RATE_LIMIT_MAX: 5000

View File

@@ -17,16 +17,21 @@ CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
# Builder stage for production # Builder stage for production
FROM node:lts-alpine AS builder FROM node:lts-alpine AS builder
WORKDIR /app WORKDIR /app/frontend
COPY package*.json ./ COPY frontend/package*.json ./
COPY frontend/package*.json ./frontend/
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 # Production stage
FROM nginxinc/nginx-unprivileged:alpine FROM nginxinc/nginx-unprivileged:alpine

View File

@@ -1,5 +1,6 @@
server { server {
listen 3000; listen 3000;
listen [::]:3000;
server_name localhost; server_name localhost;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;

View File

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

View File

@@ -1,7 +1,7 @@
{ {
"name": "patchmon-frontend", "name": "patchmon-frontend",
"private": true, "private": true,
"version": "1.3.0", "version": "1.3.2",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -7,6 +7,8 @@ import ProtectedRoute from "./components/ProtectedRoute";
import SettingsLayout from "./components/SettingsLayout"; import SettingsLayout from "./components/SettingsLayout";
import { isAuthPhase } from "./constants/authPhases"; import { isAuthPhase } from "./constants/authPhases";
import { AuthProvider, useAuth } from "./contexts/AuthContext"; import { AuthProvider, useAuth } from "./contexts/AuthContext";
import { ColorThemeProvider } from "./contexts/ColorThemeContext";
import { SettingsProvider } from "./contexts/SettingsContext";
import { ThemeProvider } from "./contexts/ThemeContext"; import { ThemeProvider } from "./contexts/ThemeContext";
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext"; import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
@@ -27,6 +29,8 @@ const DockerContainerDetail = lazy(
); );
const DockerImageDetail = lazy(() => import("./pages/docker/ImageDetail")); const DockerImageDetail = lazy(() => import("./pages/docker/ImageDetail"));
const DockerHostDetail = lazy(() => import("./pages/docker/HostDetail")); const DockerHostDetail = lazy(() => import("./pages/docker/HostDetail"));
const DockerVolumeDetail = lazy(() => import("./pages/docker/VolumeDetail"));
const DockerNetworkDetail = lazy(() => import("./pages/docker/NetworkDetail"));
const AlertChannels = lazy(() => import("./pages/settings/AlertChannels")); const AlertChannels = lazy(() => import("./pages/settings/AlertChannels"));
const Integrations = lazy(() => import("./pages/settings/Integrations")); const Integrations = lazy(() => import("./pages/settings/Integrations"));
const Notifications = lazy(() => import("./pages/settings/Notifications")); const Notifications = lazy(() => import("./pages/settings/Notifications"));
@@ -41,6 +45,7 @@ const SettingsServerConfig = lazy(
() => import("./pages/settings/SettingsServerConfig"), () => import("./pages/settings/SettingsServerConfig"),
); );
const SettingsUsers = lazy(() => import("./pages/settings/SettingsUsers")); const SettingsUsers = lazy(() => import("./pages/settings/SettingsUsers"));
const SettingsMetrics = lazy(() => import("./pages/settings/SettingsMetrics"));
// Loading fallback component // Loading fallback component
const LoadingFallback = () => ( const LoadingFallback = () => (
@@ -192,6 +197,26 @@ function AppRoutes() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/docker/volumes/:id"
element={
<ProtectedRoute requirePermission="can_view_reports">
<Layout>
<DockerVolumeDetail />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/docker/networks/:id"
element={
<ProtectedRoute requirePermission="can_view_reports">
<Layout>
<DockerNetworkDetail />
</Layout>
</ProtectedRoute>
}
/>
<Route <Route
path="/users" path="/users"
element={ element={
@@ -388,6 +413,16 @@ function AppRoutes() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/settings/metrics"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsMetrics />
</Layout>
</ProtectedRoute>
}
/>
<Route <Route
path="/options" path="/options"
element={ element={
@@ -417,11 +452,15 @@ function App() {
return ( return (
<ThemeProvider> <ThemeProvider>
<AuthProvider> <AuthProvider>
<UpdateNotificationProvider> <SettingsProvider>
<LogoProvider> <ColorThemeProvider>
<AppRoutes /> <UpdateNotificationProvider>
</LogoProvider> <LogoProvider>
</UpdateNotificationProvider> <AppRoutes />
</LogoProvider>
</UpdateNotificationProvider>
</ColorThemeProvider>
</SettingsProvider>
</AuthProvider> </AuthProvider>
</ThemeProvider> </ThemeProvider>
); );

View File

@@ -26,9 +26,10 @@ import {
Zap, Zap,
} from "lucide-react"; } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "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 { Link, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { useColorTheme } from "../contexts/ColorThemeContext";
import { useUpdateNotification } from "../contexts/UpdateNotificationContext"; import { useUpdateNotification } from "../contexts/UpdateNotificationContext";
import { dashboardAPI, versionAPI } from "../utils/api"; import { dashboardAPI, versionAPI } from "../utils/api";
import DiscordIcon from "./DiscordIcon"; import DiscordIcon from "./DiscordIcon";
@@ -61,7 +62,9 @@ const Layout = ({ children }) => {
canManageSettings, canManageSettings,
} = useAuth(); } = useAuth();
const { updateAvailable } = useUpdateNotification(); const { updateAvailable } = useUpdateNotification();
const { themeConfig } = useColorTheme();
const userMenuRef = useRef(null); const userMenuRef = useRef(null);
const bgCanvasRef = useRef(null);
// Fetch dashboard stats for the "Last updated" info // Fetch dashboard stats for the "Last updated" info
const { const {
@@ -233,27 +236,165 @@ const Layout = ({ children }) => {
navigate("/hosts?action=add"); 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 // Fetch GitHub stars count
const fetchGitHubStars = useCallback(async () => { 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 lastFetch = localStorage.getItem("githubStarsFetchTime");
const now = Date.now(); const now = Date.now();
if (lastFetch && now - parseInt(lastFetch, 15) < 600000) { if (lastFetch && now - parseInt(lastFetch, 10) < 600000) {
// 15 minute cache // 10 minute cache
return; return;
} }
try { try {
const response = await fetch( const response = await fetch(
"https://api.github.com/repos/9technologygroup/patchmon.net", "https://api.github.com/repos/9technologygroup/patchmon.net",
{
headers: {
Accept: "application/vnd.github.v3+json",
},
},
); );
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
setGithubStars(data.stargazers_count); setGithubStars(data.stargazers_count);
localStorage.setItem(
"githubStarsCount",
data.stargazers_count.toString(),
);
localStorage.setItem("githubStarsFetchTime", now.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) { } catch (error) {
console.error("Failed to fetch GitHub stars:", error); console.error("Failed to fetch GitHub stars:", error);
// Keep using cached value if available
} }
}, []); }, []);
@@ -303,11 +444,76 @@ const Layout = ({ children }) => {
fetchGitHubStars(); fetchGitHubStars();
}, [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 ( 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 */} {/* Mobile sidebar */}
<div <div
className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? "block" : "hidden"}`} className={`fixed inset-0 z-[60] lg:hidden ${sidebarOpen ? "block" : "hidden"}`}
> >
<button <button
type="button" type="button"
@@ -315,7 +521,14 @@ const Layout = ({ children }) => {
onClick={() => setSidebarOpen(false)} onClick={() => setSidebarOpen(false)}
aria-label="Close sidebar" 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"> <div className="absolute right-0 top-0 -mr-12 pt-2">
<button <button
type="button" type="button"
@@ -534,17 +747,43 @@ const Layout = ({ children }) => {
{/* Desktop sidebar */} {/* Desktop sidebar */}
<div <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" 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 <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" 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 <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" sidebarCollapsed ? "justify-center" : "justify-center"
}`} }`}
> >
@@ -562,19 +801,6 @@ const Layout = ({ children }) => {
</Link> </Link>
)} )}
</div> </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"> <nav className="flex flex-1 flex-col">
<ul className="flex flex-1 flex-col gap-y-6"> <ul className="flex flex-1 flex-col gap-y-6">
{/* Show message for users with very limited permissions */} {/* Show message for users with very limited permissions */}
@@ -930,12 +1156,19 @@ const Layout = ({ children }) => {
{/* Main content */} {/* Main content */}
<div <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" sidebarCollapsed ? "lg:pl-16" : "lg:pl-56"
}`} }`}
> >
{/* Top bar */} {/* 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 <button
type="button" type="button"
className="-m-2.5 p-2.5 text-secondary-700 dark:text-white lg:hidden" 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" /> <Github className="h-5 w-5 flex-shrink-0" />
{githubStars !== null && ( {githubStars !== null && (
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-1">
<Star className="h-3 w-3 fill-current text-yellow-500" /> <Star className="h-4 w-4 fill-current text-yellow-500" />
<span className="text-sm font-medium">{githubStars}</span> <span className="text-sm font-medium">{githubStars}</span>
</div> </div>
)} )}
@@ -1059,7 +1292,17 @@ const Layout = ({ children }) => {
> >
<FaYoutube className="h-5 w-5" /> <FaYoutube className="h-5 w-5" />
</a> </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 <a
href="https://patchmon.net" href="https://patchmon.net"
target="_blank" target="_blank"
@@ -1074,7 +1317,7 @@ const Layout = ({ children }) => {
</div> </div>
</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> <div className="px-4 sm:px-6 lg:px-8">{children}</div>
</main> </main>
</div> </div>

View File

@@ -1,17 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react"; import { useEffect } from "react";
import { isAuthReady } from "../constants/authPhases"; import { useSettings } from "../contexts/SettingsContext";
import { useAuth } from "../contexts/AuthContext";
import { settingsAPI } from "../utils/api";
const LogoProvider = ({ children }) => { const LogoProvider = ({ children }) => {
const { authPhase, isAuthenticated } = useAuth(); const { settings } = useSettings();
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
enabled: isAuthReady(authPhase, isAuthenticated()),
});
useEffect(() => { useEffect(() => {
// Use custom favicon or fallback to default // Use custom favicon or fallback to default

View File

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

View File

@@ -102,17 +102,28 @@ const BrandingTab = () => {
} }
return ( return (
<div className="space-y-6"> <div className="space-y-8">
<div className="flex items-center mb-6"> {/* Header */}
<Image className="h-6 w-6 text-primary-600 mr-3" /> <div>
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white"> <div className="flex items-center mb-6">
Logo & Branding <Image className="h-6 w-6 text-primary-600 mr-3" />
</h2> <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 and favicon.
These will be displayed throughout the application.
</p>
</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> </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"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Dark Logo */} {/* Dark Logo */}

View File

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

View File

@@ -91,10 +91,29 @@ export const AuthProvider = ({ children }) => {
const login = async (username, password) => { const login = async (username, password) => {
try { try {
// Get or generate device ID for TFA remember-me
let deviceId = localStorage.getItem("device_id");
if (!deviceId) {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
deviceId = crypto.randomUUID();
} else {
deviceId = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
/[xy]/g,
(c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
},
);
}
localStorage.setItem("device_id", deviceId);
}
const response = await fetch("/api/v1/auth/login", { const response = await fetch("/api/v1/auth/login", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Device-ID": deviceId,
}, },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
}); });

View File

@@ -0,0 +1,182 @@
import { useQuery } from "@tanstack/react-query";
import { createContext, useContext, useEffect, useState } from "react";
import { userPreferencesAPI } from "../utils/api";
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(() => {
// Initialize from localStorage for immediate render
return localStorage.getItem("colorTheme") || "cyber_blue";
});
const [isLoading, setIsLoading] = useState(true);
// Fetch user preferences from backend
const { data: userPreferences } = useQuery({
queryKey: ["userPreferences"],
queryFn: () => userPreferencesAPI.get().then((res) => res.data),
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Update theme when preferences are loaded
useEffect(() => {
if (userPreferences?.color_theme) {
setColorTheme(userPreferences.color_theme);
localStorage.setItem("colorTheme", userPreferences.color_theme);
}
setIsLoading(false);
}, [userPreferences]);
const updateColorTheme = async (theme) => {
setColorTheme(theme);
localStorage.setItem("colorTheme", theme);
// Save to backend
try {
await userPreferencesAPI.update({ color_theme: theme });
} catch (error) {
console.error("Failed to save color theme preference:", error);
// Theme is already set locally, so user still sees the change
}
};
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

@@ -0,0 +1,45 @@
import { useQuery } from "@tanstack/react-query";
import { createContext, useContext } from "react";
import { isAuthReady } from "../constants/authPhases";
import { settingsAPI } from "../utils/api";
import { useAuth } from "./AuthContext";
const SettingsContext = createContext();
export const useSettings = () => {
const context = useContext(SettingsContext);
if (!context) {
throw new Error("useSettings must be used within a SettingsProvider");
}
return context;
};
export const SettingsProvider = ({ children }) => {
const { authPhase, isAuthenticated } = useAuth();
const {
data: settings,
isLoading,
error,
refetch,
} = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes
refetchOnWindowFocus: false,
enabled: isAuthReady(authPhase, isAuthenticated()),
});
const value = {
settings,
isLoading,
error,
refetch,
};
return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
};

View File

@@ -1,4 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext, useEffect, useState } from "react";
import { userPreferencesAPI } from "../utils/api";
const ThemeContext = createContext(); const ThemeContext = createContext();
@@ -12,7 +14,7 @@ export const useTheme = () => {
export const ThemeProvider = ({ children }) => { export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState(() => { const [theme, setTheme] = useState(() => {
// Check localStorage first, then system preference // Check localStorage first for immediate render
const savedTheme = localStorage.getItem("theme"); const savedTheme = localStorage.getItem("theme");
if (savedTheme) { if (savedTheme) {
return savedTheme; return savedTheme;
@@ -24,6 +26,22 @@ export const ThemeProvider = ({ children }) => {
return "light"; return "light";
}); });
// Fetch user preferences from backend
const { data: userPreferences } = useQuery({
queryKey: ["userPreferences"],
queryFn: () => userPreferencesAPI.get().then((res) => res.data),
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
});
// Sync with user preferences from backend
useEffect(() => {
if (userPreferences?.theme_preference) {
setTheme(userPreferences.theme_preference);
localStorage.setItem("theme", userPreferences.theme_preference);
}
}, [userPreferences]);
useEffect(() => { useEffect(() => {
// Apply theme to document // Apply theme to document
if (theme === "dark") { if (theme === "dark") {
@@ -36,8 +54,17 @@ export const ThemeProvider = ({ children }) => {
localStorage.setItem("theme", theme); localStorage.setItem("theme", theme);
}, [theme]); }, [theme]);
const toggleTheme = () => { const toggleTheme = async () => {
setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light")); const newTheme = theme === "light" ? "dark" : "light";
setTheme(newTheme);
// Save to backend
try {
await userPreferencesAPI.update({ theme_preference: newTheme });
} catch (error) {
console.error("Failed to save theme preference:", error);
// Theme is already set locally, so user still sees the change
}
}; };
const value = { const value = {

View File

@@ -1,8 +1,5 @@
import { useQuery } from "@tanstack/react-query";
import { createContext, useContext, useState } from "react"; import { createContext, useContext, useState } from "react";
import { isAuthReady } from "../constants/authPhases"; import { useSettings } from "./SettingsContext";
import { settingsAPI } from "../utils/api";
import { useAuth } from "./AuthContext";
const UpdateNotificationContext = createContext(); const UpdateNotificationContext = createContext();
@@ -18,17 +15,7 @@ export const useUpdateNotification = () => {
export const UpdateNotificationProvider = ({ children }) => { export const UpdateNotificationProvider = ({ children }) => {
const [dismissed, setDismissed] = useState(false); const [dismissed, setDismissed] = useState(false);
const { authPhase, isAuthenticated } = useAuth(); const { settings, isLoading: settingsLoading } = useSettings();
// Ensure settings are loaded - but only after auth is fully ready
// This reads cached update info from backend (updated by scheduler)
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes
refetchOnWindowFocus: false,
enabled: isAuthReady(authPhase, isAuthenticated()),
});
// Read cached update information from settings (no GitHub API calls) // Read cached update information from settings (no GitHub API calls)
// The backend scheduler updates this data periodically // The backend scheduler updates this data periodically

View File

@@ -9,7 +9,7 @@
} }
body { 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 { .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 { .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 { .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 { .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 { .label {
@@ -84,6 +111,27 @@
} }
@layer utilities { @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 {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
} }

View File

@@ -169,6 +169,20 @@ const Automation = () => {
year: "numeric", 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") { if (schedule === "Every hour") {
const now = new Date(); const now = new Date();
const nextHour = new Date(now); const nextHour = new Date(now);
@@ -209,6 +223,13 @@ const Automation = () => {
tomorrow.setHours(3, 0, 0, 0); tomorrow.setHours(3, 0, 0, 0);
return tomorrow.getTime(); 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") { if (schedule === "Every hour") {
const now = new Date(); const now = new Date();
const nextHour = new Date(now); const nextHour = new Date(now);
@@ -269,6 +290,8 @@ const Automation = () => {
endpoint = "/automation/trigger/orphaned-repo-cleanup"; endpoint = "/automation/trigger/orphaned-repo-cleanup";
} else if (jobType === "orphaned-packages") { } else if (jobType === "orphaned-packages") {
endpoint = "/automation/trigger/orphaned-package-cleanup"; endpoint = "/automation/trigger/orphaned-package-cleanup";
} else if (jobType === "docker-inventory") {
endpoint = "/automation/trigger/docker-inventory-cleanup";
} else if (jobType === "agent-collection") { } else if (jobType === "agent-collection") {
endpoint = "/automation/trigger/agent-collection"; endpoint = "/automation/trigger/agent-collection";
} }
@@ -584,6 +607,10 @@ const Automation = () => {
automation.queue.includes("orphaned-package") automation.queue.includes("orphaned-package")
) { ) {
triggerManualJob("orphaned-packages"); triggerManualJob("orphaned-packages");
} else if (
automation.queue.includes("docker-inventory")
) {
triggerManualJob("docker-inventory");
} else if ( } else if (
automation.queue.includes("agent-commands") automation.queue.includes("agent-commands")
) { ) {

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@ import {
Copy, Copy,
Cpu, Cpu,
Database, Database,
Download,
Eye, Eye,
EyeOff, EyeOff,
HardDrive, HardDrive,
@@ -53,6 +54,8 @@ const HostDetail = () => {
const [historyLimit] = useState(10); const [historyLimit] = useState(10);
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [notesMessage, setNotesMessage] = useState({ text: "", type: "" }); const [notesMessage, setNotesMessage] = useState({ text: "", type: "" });
const [updateMessage, setUpdateMessage] = useState({ text: "", jobId: "" });
const [reportMessage, setReportMessage] = useState({ text: "", jobId: "" });
const { const {
data: host, data: host,
@@ -187,6 +190,57 @@ const HostDetail = () => {
}, },
}); });
// Force agent update mutation
const forceAgentUpdateMutation = useMutation({
mutationFn: () =>
adminHostsAPI.forceAgentUpdate(hostId).then((res) => res.data),
onSuccess: (data) => {
queryClient.invalidateQueries(["host", hostId]);
queryClient.invalidateQueries(["hosts"]);
// Show success message with job ID
if (data?.jobId) {
setUpdateMessage({
text: "Update queued successfully",
jobId: data.jobId,
});
// Clear message after 5 seconds
setTimeout(() => setUpdateMessage({ text: "", jobId: "" }), 5000);
}
},
onError: (error) => {
setUpdateMessage({
text: error.response?.data?.error || "Failed to queue update",
jobId: "",
});
setTimeout(() => setUpdateMessage({ text: "", jobId: "" }), 5000);
},
});
// Fetch report mutation
const fetchReportMutation = useMutation({
mutationFn: () => adminHostsAPI.fetchReport(hostId).then((res) => res.data),
onSuccess: (data) => {
queryClient.invalidateQueries(["host", hostId]);
queryClient.invalidateQueries(["hosts"]);
// Show success message with job ID
if (data?.jobId) {
setReportMessage({
text: "Report fetch queued successfully",
jobId: data.jobId,
});
// Clear message after 5 seconds
setTimeout(() => setReportMessage({ text: "", jobId: "" }), 5000);
}
},
onError: (error) => {
setReportMessage({
text: error.response?.data?.error || "Failed to fetch report",
jobId: "",
});
setTimeout(() => setReportMessage({ text: "", jobId: "" }), 5000);
},
});
const updateFriendlyNameMutation = useMutation({ const updateFriendlyNameMutation = useMutation({
mutationFn: (friendlyName) => mutationFn: (friendlyName) =>
adminHostsAPI adminHostsAPI
@@ -399,20 +453,53 @@ const HostDetail = () => {
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div>
<button
type="button"
onClick={() => fetchReportMutation.mutate()}
disabled={fetchReportMutation.isPending || !wsStatus?.connected}
className="btn-outline flex items-center gap-2 text-sm"
title={
!wsStatus?.connected
? "Agent is not connected"
: "Fetch package data from agent"
}
>
<Download
className={`h-4 w-4 ${
fetchReportMutation.isPending ? "animate-spin" : ""
}`}
/>
Fetch Report
</button>
{reportMessage.text && (
<p className="text-xs mt-1.5 text-secondary-600 dark:text-secondary-400">
{reportMessage.text}
{reportMessage.jobId && (
<span className="ml-1 font-mono text-secondary-500">
(Job #{reportMessage.jobId})
</span>
)}
</p>
)}
</div>
<button <button
type="button" type="button"
onClick={() => setShowCredentialsModal(true)} onClick={() => setShowCredentialsModal(true)}
className="btn-outline flex items-center gap-2 text-sm" className={`btn-outline flex items-center text-sm ${
host?.machine_id ? "justify-center p-2" : "gap-2"
}`}
title="View credentials"
> >
<Key className="h-4 w-4" /> <Key className="h-4 w-4" />
Deploy Agent {!host?.machine_id && <span>Deploy Agent</span>}
</button> </button>
<button <button
type="button" type="button"
onClick={() => refetch()} onClick={() => refetch()}
disabled={isFetching} disabled={isFetching}
className="btn-outline flex items-center justify-center p-2 text-sm" className="btn-outline flex items-center justify-center p-2 text-sm"
title="Refresh host data" title="Refresh dashboard"
> >
<RefreshCw <RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`} className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
@@ -703,6 +790,49 @@ const HostDetail = () => {
/> />
</button> </button>
</div> </div>
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1.5">
Force Agent Version Upgrade
</p>
<button
type="button"
onClick={() => forceAgentUpdateMutation.mutate()}
disabled={
forceAgentUpdateMutation.isPending ||
!wsStatus?.connected
}
title={
!wsStatus?.connected
? "Agent is not connected"
: "Force agent to update now"
}
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..."
: wsStatus?.connected
? "Update Now"
: "Offline"}
</button>
{updateMessage.text && (
<p className="text-xs mt-1.5 text-secondary-600 dark:text-secondary-400">
{updateMessage.text}
{updateMessage.jobId && (
<span className="ml-1 font-mono text-secondary-500">
(Job #{updateMessage.jobId})
</span>
)}
</p>
)}
</div>
</div> </div>
</div> </div>
)} )}

View File

@@ -470,9 +470,18 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
// Delete Confirmation Modal // Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => { const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
// Fetch hosts for this group
const { data: hostsData } = useQuery({
queryKey: ["hostGroupHosts", group?.id],
queryFn: () => hostGroupsAPI.getHosts(group.id).then((res) => res.data),
enabled: !!group && group._count?.hosts > 0,
});
const hosts = hostsData || [];
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md"> <div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center"> <div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600" /> <AlertTriangle className="h-5 w-5 text-danger-600" />
@@ -494,12 +503,30 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
</p> </p>
{group._count.hosts > 0 && ( {group._count.hosts > 0 && (
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md"> <div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
<p className="text-sm text-warning-800"> <p className="text-sm text-warning-800 mb-2">
<strong>Warning:</strong> This group contains{" "} <strong>Warning:</strong> This group contains{" "}
{group._count.hosts} host {group._count.hosts} host
{group._count.hosts !== 1 ? "s" : ""}. You must move or remove {group._count.hosts !== 1 ? "s" : ""}. You must move or remove
these hosts before deleting the group. these hosts before deleting the group.
</p> </p>
{hosts.length > 0 && (
<div className="mt-2">
<p className="text-xs font-medium text-warning-900 mb-1">
Hosts in this group:
</p>
<div className="max-h-32 overflow-y-auto bg-warning-100 rounded p-2">
{hosts.map((host) => (
<div
key={host.id}
className="text-xs text-warning-900 flex items-center gap-1"
>
<Server className="h-3 w-3" />
{host.friendly_name || host.hostname}
</div>
))}
</div>
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -402,105 +402,71 @@ const Hosts = () => {
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
// Fetch initial WebSocket status for all hosts
// Fetch initial WebSocket status for all hosts // Fetch initial WebSocket status for all hosts
const fetchInitialStatus = async () => { const fetchInitialStatus = async () => {
const statusPromises = hosts const apiIds = hosts
.filter((host) => host.api_id) .filter((host) => host.api_id)
.map(async (host) => { .map((host) => host.api_id);
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 },
};
});
const results = await Promise.all(statusPromises); if (apiIds.length === 0) return;
const initialStatusMap = {};
results.forEach(({ apiId, status }) => {
initialStatusMap[apiId] = status;
});
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(); fetchInitialStatus();
}, [hosts]); }, [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(() => { useEffect(() => {
if (!hosts || hosts.length === 0) return; if (!hosts || hosts.length === 0) return;
const token = localStorage.getItem("token"); const token = localStorage.getItem("token");
if (!token) return; if (!token) return;
const eventSources = new Map(); // Use polling instead of SSE to avoid connection pool issues
let isMounted = true; // 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 (apiIds.length === 0) return;
if (!isMounted || eventSources.has(apiId)) return;
try { fetch(`/api/v1/ws/status?apiIds=${apiIds.join(",")}`, {
const es = new EventSource( headers: {
`/api/v1/ws/status/${apiId}/stream?token=${encodeURIComponent(token)}`, Authorization: `Bearer ${token}`,
); },
})
es.onmessage = (event) => { .then((response) => response.json())
try { .then((result) => {
const data = JSON.parse(event.data); if (result.success && result.data) {
if (isMounted) { setWsStatusMap(result.data);
setWsStatusMap((prev) => {
const newMap = { ...prev, [apiId]: data };
return newMap;
});
}
} catch (_err) {
// Silently handle parse errors
} }
}; })
.catch(() => {
es.onerror = (_error) => { // Silently handle errors
console.log(`[SSE] Connection error for ${apiId}, retrying...`); });
es?.close(); }, 10000); // Poll every 10 seconds
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 {
}
}
// Cleanup function // Cleanup function
return () => { return () => {
isMounted = false; clearInterval(pollInterval);
for (const es of eventSources.values()) {
es.close();
}
eventSources.clear();
}; };
}, [hosts]); }, [hosts]);
@@ -565,12 +531,11 @@ const Hosts = () => {
"with new data:", "with new data:",
data.host, data.host,
); );
// Ensure hostGroupId is set correctly // Host already has host_group_memberships from backend
const updatedHost = { const updatedHost = {
...data.host, ...data.host,
hostGroupId: data.host.host_groups?.id || null,
}; };
console.log("Updated host with hostGroupId:", updatedHost); console.log("Updated host in cache:", updatedHost);
return updatedHost; return updatedHost;
} }
return host; return host;
@@ -688,11 +653,15 @@ const Hosts = () => {
host.os_type?.toLowerCase().includes(searchTerm.toLowerCase()) || host.os_type?.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.notes?.toLowerCase().includes(searchTerm.toLowerCase()); host.notes?.toLowerCase().includes(searchTerm.toLowerCase());
// Group filter // Group filter - handle multiple groups per host
const memberships = host.host_group_memberships || [];
const matchesGroup = const matchesGroup =
groupFilter === "all" || groupFilter === "all" ||
(groupFilter === "ungrouped" && !host.host_groups) || (groupFilter === "ungrouped" && memberships.length === 0) ||
(groupFilter !== "ungrouped" && host.host_groups?.id === groupFilter); (groupFilter !== "ungrouped" &&
memberships.some(
(membership) => membership.host_groups?.id === groupFilter,
));
// Status filter // Status filter
const matchesStatus = const matchesStatus =
@@ -745,10 +714,30 @@ const Hosts = () => {
aValue = a.ip?.toLowerCase() || "zzz_no_ip"; aValue = a.ip?.toLowerCase() || "zzz_no_ip";
bValue = b.ip?.toLowerCase() || "zzz_no_ip"; bValue = b.ip?.toLowerCase() || "zzz_no_ip";
break; break;
case "group": case "group": {
aValue = a.host_groups?.name || "zzz_ungrouped"; // Handle multiple groups per host - use first group alphabetically for sorting
bValue = b.host_groups?.name || "zzz_ungrouped"; const aGroups = a.host_group_memberships || [];
const bGroups = b.host_group_memberships || [];
if (aGroups.length === 0) {
aValue = "zzz_ungrouped";
} else {
const aGroupNames = aGroups
.map((m) => m.host_groups?.name || "")
.filter((name) => name)
.sort();
aValue = aGroupNames[0] || "zzz_ungrouped";
}
if (bGroups.length === 0) {
bValue = "zzz_ungrouped";
} else {
const bGroupNames = bGroups
.map((m) => m.host_groups?.name || "")
.filter((name) => name)
.sort();
bValue = bGroupNames[0] || "zzz_ungrouped";
}
break; break;
}
case "os": case "os":
aValue = a.os_type?.toLowerCase() || "zzz_unknown"; aValue = a.os_type?.toLowerCase() || "zzz_unknown";
bValue = b.os_type?.toLowerCase() || "zzz_unknown"; bValue = b.os_type?.toLowerCase() || "zzz_unknown";
@@ -821,27 +810,46 @@ const Hosts = () => {
const groups = {}; const groups = {};
filteredAndSortedHosts.forEach((host) => { filteredAndSortedHosts.forEach((host) => {
let groupKey; if (groupBy === "group") {
switch (groupBy) { // Handle multiple groups per host
case "group": const memberships = host.host_group_memberships || [];
groupKey = host.host_groups?.name || "Ungrouped"; if (memberships.length === 0) {
break; // Host has no groups, add to "Ungrouped"
case "status": if (!groups.Ungrouped) {
groupKey = groups.Ungrouped = [];
(host.effectiveStatus || host.status).charAt(0).toUpperCase() + }
(host.effectiveStatus || host.status).slice(1); groups.Ungrouped.push(host);
break; } else {
case "os": // Host has one or more groups, add to each group
groupKey = host.os_type || "Unknown"; memberships.forEach((membership) => {
break; const groupName = membership.host_groups?.name || "Unknown";
default: if (!groups[groupName]) {
groupKey = "All Hosts"; groups[groupName] = [];
} }
groups[groupName].push(host);
});
}
} else {
// Other grouping types (status, os, etc.)
let groupKey;
switch (groupBy) {
case "status":
groupKey =
(host.effectiveStatus || host.status).charAt(0).toUpperCase() +
(host.effectiveStatus || host.status).slice(1);
break;
case "os":
groupKey = host.os_type || "Unknown";
break;
default:
groupKey = "All Hosts";
}
if (!groups[groupKey]) { if (!groups[groupKey]) {
groups[groupKey] = []; groups[groupKey] = [];
}
groups[groupKey].push(host);
} }
groups[groupKey].push(host);
}); });
return groups; return groups;
@@ -1428,14 +1436,6 @@ const Hosts = () => {
<AlertTriangle className="h-4 w-4" /> <AlertTriangle className="h-4 w-4" />
Hide Stale Hide Stale
</button> </button>
<button
type="button"
onClick={() => setShowAddModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Add Host
</button>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -557,9 +557,18 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
// Delete Confirmation Modal // Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => { const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
// Fetch hosts for this group
const { data: hostsData } = useQuery({
queryKey: ["hostGroupHosts", group?.id],
queryFn: () => hostGroupsAPI.getHosts(group.id).then((res) => res.data),
enabled: !!group && group._count?.hosts > 0,
});
const hosts = hostsData || [];
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md"> <div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center"> <div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600" /> <AlertTriangle className="h-5 w-5 text-danger-600" />
@@ -581,12 +590,30 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
</p> </p>
{group._count.hosts > 0 && ( {group._count.hosts > 0 && (
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md"> <div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
<p className="text-sm text-warning-800"> <p className="text-sm text-warning-800 mb-2">
<strong>Warning:</strong> This group contains{" "} <strong>Warning:</strong> This group contains{" "}
{group._count.hosts} host {group._count.hosts} host
{group._count.hosts !== 1 ? "s" : ""}. You must move or remove {group._count.hosts !== 1 ? "s" : ""}. You must move or remove
these hosts before deleting the group. these hosts before deleting the group.
</p> </p>
{hosts.length > 0 && (
<div className="mt-2">
<p className="text-xs font-medium text-warning-900 mb-1">
Hosts in this group:
</p>
<div className="max-h-32 overflow-y-auto bg-warning-100 rounded p-2">
{hosts.map((host) => (
<div
key={host.id}
className="text-xs text-warning-900 flex items-center gap-1"
>
<Server className="h-3 w-3" />
{host.friendly_name || host.hostname}
</div>
))}
</div>
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -539,7 +539,7 @@ const Packages = () => {
<Package className="h-5 w-5 text-primary-600 mr-2" /> <Package className="h-5 w-5 text-primary-600 mr-2" />
<div> <div>
<p className="text-sm text-secondary-500 dark:text-white"> <p className="text-sm text-secondary-500 dark:text-white">
Total Packages Packages
</p> </p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white"> <p className="text-xl font-semibold text-secondary-900 dark:text-white">
{totalPackagesCount} {totalPackagesCount}
@@ -553,7 +553,7 @@ const Packages = () => {
<Package className="h-5 w-5 text-blue-600 mr-2" /> <Package className="h-5 w-5 text-blue-600 mr-2" />
<div> <div>
<p className="text-sm text-secondary-500 dark:text-white"> <p className="text-sm text-secondary-500 dark:text-white">
Total Installations Installations
</p> </p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white"> <p className="text-xl font-semibold text-secondary-900 dark:text-white">
{totalInstallationsCount} {totalInstallationsCount}
@@ -562,47 +562,72 @@ const Packages = () => {
</div> </div>
</div> </div>
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"> <button
type="button"
onClick={() => {
setUpdateStatusFilter("needs-updates");
setCategoryFilter("all");
setHostFilter("all");
setSearchTerm("");
}}
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
title="Click to filter packages that need updates"
>
<div className="flex items-center"> <div className="flex items-center">
<Package className="h-5 w-5 text-warning-600 mr-2" /> <Package className="h-5 w-5 text-warning-600 mr-2" />
<div> <div>
<p className="text-sm text-secondary-500 dark:text-white"> <p className="text-sm text-secondary-500 dark:text-white">
Total Outdated Packages Outdated Packages
</p> </p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white"> <p className="text-xl font-semibold text-secondary-900 dark:text-white">
{outdatedPackagesCount} {outdatedPackagesCount}
</p> </p>
</div> </div>
</div> </div>
</div> </button>
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"> <button
<div className="flex items-center"> type="button"
<Server className="h-5 w-5 text-warning-600 mr-2" /> onClick={() => {
<div> setUpdateStatusFilter("security-updates");
<p className="text-sm text-secondary-500 dark:text-white"> setCategoryFilter("all");
Hosts Pending Updates setHostFilter("all");
</p> setSearchTerm("");
<p className="text-xl font-semibold text-secondary-900 dark:text-white"> }}
{uniquePackageHostsCount} className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
</p> title="Click to filter packages with security updates"
</div> >
</div>
</div>
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200">
<div className="flex items-center"> <div className="flex items-center">
<Shield className="h-5 w-5 text-danger-600 mr-2" /> <Shield className="h-5 w-5 text-danger-600 mr-2" />
<div> <div>
<p className="text-sm text-secondary-500 dark:text-white"> <p className="text-sm text-secondary-500 dark:text-white">
Security Updates Across All Hosts Security Packages
</p> </p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white"> <p className="text-xl font-semibold text-secondary-900 dark:text-white">
{securityUpdatesCount} {securityUpdatesCount}
</p> </p>
</div> </div>
</div> </div>
</div> </button>
<button
type="button"
onClick={() => navigate("/hosts?filter=needsUpdates")}
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 text-left w-full"
title="Click to view hosts that need updates"
>
<div className="flex items-center">
<Server className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">
Outdated Hosts
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{uniquePackageHostsCount}
</p>
</div>
</div>
</button>
</div> </div>
{/* Packages List */} {/* Packages List */}

View File

@@ -25,6 +25,7 @@ import {
import { useEffect, useId, useState } from "react"; import { useEffect, useId, useState } from "react";
import { useAuth } from "../contexts/AuthContext"; import { useAuth } from "../contexts/AuthContext";
import { THEME_PRESETS, useColorTheme } from "../contexts/ColorThemeContext";
import { useTheme } from "../contexts/ThemeContext"; import { useTheme } from "../contexts/ThemeContext";
import { isCorsError, tfaAPI } from "../utils/api"; import { isCorsError, tfaAPI } from "../utils/api";
@@ -38,6 +39,7 @@ const Profile = () => {
const confirmPasswordId = useId(); const confirmPasswordId = useId();
const { user, updateProfile, changePassword } = useAuth(); const { user, updateProfile, changePassword } = useAuth();
const { toggleTheme, isDark } = useTheme(); const { toggleTheme, isDark } = useTheme();
const { colorTheme, setColorTheme } = useColorTheme();
const [activeTab, setActiveTab] = useState("profile"); const [activeTab, setActiveTab] = useState("profile");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState({ type: "", text: "" }); const [message, setMessage] = useState({ type: "", text: "" });
@@ -411,6 +413,68 @@ const Profile = () => {
</button> </button>
</div> </div>
</div> </div>
{/* Color Theme Settings */}
<div className="mt-6 pt-6 border-t border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
Color Theme
</h4>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mb-4">
Choose your preferred color scheme for the application
</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={() => setColorTheme(themeKey)}
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"
} 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>
</div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
@@ -564,6 +628,7 @@ const Profile = () => {
// TFA Tab Component // TFA Tab Component
const TfaTab = () => { const TfaTab = () => {
const verificationTokenId = useId(); const verificationTokenId = useId();
const disablePasswordId = useId();
const [setupStep, setSetupStep] = useState("status"); // 'status', 'setup', 'verify', 'backup-codes' const [setupStep, setSetupStep] = useState("status"); // 'status', 'setup', 'verify', 'backup-codes'
const [verificationToken, setVerificationToken] = useState(""); const [verificationToken, setVerificationToken] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");

View File

@@ -0,0 +1,483 @@
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeft,
CheckCircle,
Container,
Globe,
Network,
RefreshCw,
Server,
Tag,
XCircle,
} from "lucide-react";
import { Link, useParams } from "react-router-dom";
import api, { formatRelativeTime } from "../../utils/api";
const NetworkDetail = () => {
const { id } = useParams();
const { data, isLoading, error } = useQuery({
queryKey: ["docker", "network", id],
queryFn: async () => {
const response = await api.get(`/docker/networks/${id}`);
return response.data;
},
refetchInterval: 30000,
});
const network = data?.network;
const host = data?.host;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<RefreshCw className="h-8 w-8 animate-spin text-secondary-400" />
</div>
);
}
if (error || !network) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-red-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Network not found
</h3>
<p className="mt-2 text-sm text-red-700 dark:text-red-300">
The network you're looking for doesn't exist or has been
removed.
</p>
</div>
</div>
</div>
<Link
to="/docker"
className="mt-4 inline-flex items-center text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Docker
</Link>
</div>
);
}
const BooleanBadge = ({ value, trueLabel = "Yes", falseLabel = "No" }) => {
return value ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<CheckCircle className="h-3 w-3 mr-1" />
{trueLabel}
</span>
) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800 dark:bg-secondary-700 dark:text-secondary-200">
<XCircle className="h-3 w-3 mr-1" />
{falseLabel}
</span>
);
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<Link
to="/docker"
className="inline-flex items-center text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Docker
</Link>
<div className="flex items-center">
<Network className="h-8 w-8 text-secondary-400 mr-3" />
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
{network.name}
</h1>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
Network ID: {network.network_id.substring(0, 12)}
</p>
</div>
</div>
</div>
{/* Overview Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Network className="h-5 w-5 text-blue-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Driver
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{network.driver}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Globe className="h-5 w-5 text-purple-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Scope
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{network.scope}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Container className="h-5 w-5 text-green-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Containers
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{network.container_count || 0}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<RefreshCw className="h-5 w-5 text-secondary-400 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Last Checked
</p>
<p className="text-sm font-medium text-secondary-900 dark:text-white">
{formatRelativeTime(network.last_checked)}
</p>
</div>
</div>
</div>
</div>
{/* Network Information Card */}
<div className="card">
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
Network Information
</h3>
</div>
<div className="px-6 py-5">
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Network ID
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono break-all">
{network.network_id}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Name
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{network.name}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Driver
</dt>
<dd className="mt-1">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{network.driver}
</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Scope
</dt>
<dd className="mt-1">
<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">
{network.scope}
</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Containers Attached
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{network.container_count || 0}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
IPv6 Enabled
</dt>
<dd className="mt-1">
<BooleanBadge value={network.ipv6_enabled} />
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Internal
</dt>
<dd className="mt-1">
<BooleanBadge value={network.internal} />
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Attachable
</dt>
<dd className="mt-1">
<BooleanBadge value={network.attachable} />
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Ingress
</dt>
<dd className="mt-1">
<BooleanBadge value={network.ingress} />
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Config Only
</dt>
<dd className="mt-1">
<BooleanBadge value={network.config_only} />
</dd>
</div>
{network.created_at && (
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Created
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{formatRelativeTime(network.created_at)}
</dd>
</div>
)}
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Last Checked
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{formatRelativeTime(network.last_checked)}
</dd>
</div>
</dl>
</div>
</div>
{/* IPAM Configuration */}
{network.ipam && (
<div className="card">
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
IPAM Configuration
</h3>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
IP Address Management settings
</p>
</div>
<div className="px-6 py-5">
{network.ipam.driver && (
<div className="mb-4">
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-1">
Driver
</dt>
<dd>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{network.ipam.driver}
</span>
</dd>
</div>
)}
{network.ipam.config && network.ipam.config.length > 0 && (
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-3">
Subnet Configuration
</dt>
<div className="space-y-4">
{network.ipam.config.map((config, index) => (
<div
key={config.subnet || `config-${index}`}
className="bg-secondary-50 dark:bg-secondary-900/50 rounded-lg p-4"
>
<dl className="grid grid-cols-1 gap-x-4 gap-y-3 sm:grid-cols-2">
{config.subnet && (
<div>
<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400">
Subnet
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
{config.subnet}
</dd>
</div>
)}
{config.gateway && (
<div>
<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400">
Gateway
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
{config.gateway}
</dd>
</div>
)}
{config.ip_range && (
<div>
<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400">
IP Range
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
{config.ip_range}
</dd>
</div>
)}
{config.aux_addresses &&
Object.keys(config.aux_addresses).length > 0 && (
<div className="sm:col-span-2">
<dt className="text-xs font-medium text-secondary-500 dark:text-secondary-400 mb-2">
Auxiliary Addresses
</dt>
<dd className="space-y-1">
{Object.entries(config.aux_addresses).map(
([key, value]) => (
<div
key={key}
className="flex items-center text-sm"
>
<span className="text-secondary-500 dark:text-secondary-400 min-w-[120px]">
{key}:
</span>
<span className="text-secondary-900 dark:text-white font-mono">
{value}
</span>
</div>
),
)}
</dd>
</div>
)}
</dl>
</div>
))}
</div>
</div>
)}
{network.ipam.options &&
Object.keys(network.ipam.options).length > 0 && (
<div className="mt-4">
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
IPAM Options
</dt>
<dd className="space-y-1">
{Object.entries(network.ipam.options).map(
([key, value]) => (
<div
key={key}
className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
>
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
{key}
</span>
<span className="text-sm text-secondary-900 dark:text-white break-all">
{value}
</span>
</div>
),
)}
</dd>
</div>
)}
</div>
</div>
)}
{/* Host Information */}
{host && (
<div className="card">
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
<Server className="h-5 w-5 mr-2" />
Host Information
</h3>
</div>
<div className="px-6 py-5">
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Hostname
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
<Link
to={`/hosts/${host.id}`}
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
>
{host.hostname}
</Link>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Operating System
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{host.os_name} {host.os_version}
</dd>
</div>
</dl>
</div>
</div>
)}
{/* Labels */}
{network.labels && Object.keys(network.labels).length > 0 && (
<div className="card">
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
<Tag className="h-5 w-5 mr-2" />
Labels
</h3>
</div>
<div className="px-6 py-5">
<div className="space-y-2">
{Object.entries(network.labels).map(([key, value]) => (
<div
key={key}
className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
>
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
{key}
</span>
<span className="text-sm text-secondary-900 dark:text-white break-all">
{value}
</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default NetworkDetail;

View File

@@ -0,0 +1,359 @@
import { useQuery } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeft,
Database,
HardDrive,
RefreshCw,
Server,
Tag,
} from "lucide-react";
import { Link, useParams } from "react-router-dom";
import api, { formatRelativeTime } from "../../utils/api";
const VolumeDetail = () => {
const { id } = useParams();
const { data, isLoading, error } = useQuery({
queryKey: ["docker", "volume", id],
queryFn: async () => {
const response = await api.get(`/docker/volumes/${id}`);
return response.data;
},
refetchInterval: 30000,
});
const volume = data?.volume;
const host = data?.host;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<RefreshCw className="h-8 w-8 animate-spin text-secondary-400" />
</div>
);
}
if (error || !volume) {
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-red-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Volume not found
</h3>
<p className="mt-2 text-sm text-red-700 dark:text-red-300">
The volume you're looking for doesn't exist or has been removed.
</p>
</div>
</div>
</div>
<Link
to="/docker"
className="mt-4 inline-flex items-center text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Docker
</Link>
</div>
);
}
const formatBytes = (bytes) => {
if (bytes === null || bytes === undefined) return "N/A";
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 Bytes";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${Math.round((bytes / 1024 ** i) * 100) / 100} ${sizes[i]}`;
};
return (
<div className="space-y-6">
{/* Header */}
<div>
<Link
to="/docker"
className="inline-flex items-center text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Docker
</Link>
<div className="flex items-center">
<HardDrive className="h-8 w-8 text-secondary-400 mr-3" />
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
{volume.name}
</h1>
<p className="mt-1 text-sm text-secondary-600 dark:text-secondary-400">
Volume ID: {volume.volume_id}
</p>
</div>
</div>
</div>
{/* Overview Cards */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<HardDrive className="h-5 w-5 text-blue-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Driver
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{volume.driver}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Database className="h-5 w-5 text-purple-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">Size</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{formatBytes(volume.size_bytes)}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Server className="h-5 w-5 text-green-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Containers
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{volume.ref_count || 0}
</p>
</div>
</div>
</div>
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<RefreshCw className="h-5 w-5 text-secondary-400 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Last Checked
</p>
<p className="text-sm font-medium text-secondary-900 dark:text-white">
{formatRelativeTime(volume.last_checked)}
</p>
</div>
</div>
</div>
</div>
{/* Volume Information Card */}
<div className="card">
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
Volume Information
</h3>
</div>
<div className="px-6 py-5">
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Volume ID
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono">
{volume.volume_id}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Name
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{volume.name}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Driver
</dt>
<dd className="mt-1">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{volume.driver}
</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Scope
</dt>
<dd className="mt-1">
<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">
{volume.scope}
</span>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Size
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{formatBytes(volume.size_bytes)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Containers Using
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{volume.ref_count || 0}
</dd>
</div>
{volume.mountpoint && (
<div className="sm:col-span-2">
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Mount Point
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white font-mono break-all">
{volume.mountpoint}
</dd>
</div>
)}
{volume.renderer && (
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Renderer
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{volume.renderer}
</dd>
</div>
)}
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Created
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{formatRelativeTime(volume.created_at)}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Last Checked
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{formatRelativeTime(volume.last_checked)}
</dd>
</div>
</dl>
</div>
</div>
{/* Host Information */}
{host && (
<div className="card">
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
<Server className="h-5 w-5 mr-2" />
Host Information
</h3>
</div>
<div className="px-6 py-5">
<dl className="grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Hostname
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
<Link
to={`/hosts/${host.id}`}
className="text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300"
>
{host.hostname}
</Link>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Operating System
</dt>
<dd className="mt-1 text-sm text-secondary-900 dark:text-white">
{host.os_name} {host.os_version}
</dd>
</div>
</dl>
</div>
</div>
)}
{/* Labels */}
{volume.labels && Object.keys(volume.labels).length > 0 && (
<div className="card">
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white flex items-center">
<Tag className="h-5 w-5 mr-2" />
Labels
</h3>
</div>
<div className="px-6 py-5">
<div className="space-y-2">
{Object.entries(volume.labels).map(([key, value]) => (
<div
key={key}
className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
>
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
{key}
</span>
<span className="text-sm text-secondary-900 dark:text-white break-all">
{value}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Options */}
{volume.options && Object.keys(volume.options).length > 0 && (
<div className="card">
<div className="px-6 py-5 border-b border-secondary-200 dark:border-secondary-700">
<h3 className="text-lg leading-6 font-medium text-secondary-900 dark:text-white">
Volume Options
</h3>
</div>
<div className="px-6 py-5">
<div className="space-y-2">
{Object.entries(volume.options).map(([key, value]) => (
<div
key={key}
className="flex items-start py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-0"
>
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400 min-w-[200px]">
{key}
</span>
<span className="text-sm text-secondary-900 dark:text-white break-all">
{value}
</span>
</div>
))}
</div>
</div>
</div>
)}
</div>
);
};
export default VolumeDetail;

View File

@@ -746,239 +746,126 @@ const Integrations = () => {
</div> </div>
<div> <div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white"> <h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Docker Container Monitoring Docker Inventory Collection
</h3> </h3>
<p className="text-sm text-secondary-600 dark:text-secondary-400"> <p className="text-sm text-secondary-600 dark:text-secondary-400">
Monitor Docker containers and images for available updates Docker monitoring is now built into the PatchMon Go agent
</p> </p>
</div> </div>
</div> </div>
{/* Installation Instructions */} {/* Info Message */}
<div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6"> <div className="bg-primary-50 dark:bg-primary-900/20 border border-primary-200 dark:border-primary-800 rounded-lg p-6">
<h3 className="text-lg font-semibold text-primary-900 dark:text-primary-200 mb-4"> <div className="flex items-start gap-3">
Agent Installation <CheckCircle className="h-5 w-5 text-primary-600 dark:text-primary-400 flex-shrink-0 mt-0.5" />
</h3> <div>
<ol className="list-decimal list-inside space-y-3 text-sm text-primary-800 dark:text-primary-300"> <h4 className="text-md font-semibold text-primary-900 dark:text-primary-200 mb-2">
Automatic Docker Discovery
</h4>
<p className="text-sm text-primary-800 dark:text-primary-300 mb-3">
The PatchMon Go agent automatically discovers Docker
when it's available on your host and collects
comprehensive inventory information:
</p>
<ul className="list-disc list-inside space-y-2 text-sm text-primary-800 dark:text-primary-300 ml-2">
<li>
<strong>Containers</strong> - Running and stopped
containers with status, images, ports, and labels
</li>
<li>
<strong>Images</strong> - All Docker images with
repository, tags, sizes, and sources
</li>
<li>
<strong>Volumes</strong> - Named and anonymous volumes
with drivers, mountpoints, and usage
</li>
<li>
<strong>Networks</strong> - Docker networks with
drivers, IPAM configuration, and connected containers
</li>
<li>
<strong>Real-time Updates</strong> - Container status
changes are pushed instantly via WebSocket
</li>
</ul>
</div>
</div>
</div>
{/* How It Works */}
<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-4">
How It Works
</h4>
<ol className="list-decimal list-inside space-y-3 text-sm text-secondary-700 dark:text-secondary-300">
<li> <li>
Make sure you have the PatchMon credentials file set up on Install the PatchMon Go agent on your host (see the Hosts
your host ( page for installation instructions)
<code className="bg-primary-100 dark:bg-primary-900/40 px-1 py-0.5 rounded text-xs">
/etc/patchmon/credentials
</code>
)
</li> </li>
<li> <li>
SSH into your Docker host where you want to monitor The agent automatically detects if Docker is installed and
containers running on the host
</li> </li>
<li>Run the installation command below</li>
<li> <li>
The agent will automatically collect Docker container and During each collection cycle, the agent gathers Docker
image information every 5 minutes inventory data and sends it to the PatchMon server
</li>
<li>
View your complete Docker inventory (containers, images,
volumes, networks) in the{" "}
<a
href="/docker"
className="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 underline"
>
Docker page
</a>
</li>
<li>
Container status changes are pushed to the server in
real-time via WebSocket connection
</li> </li>
<li>View your Docker inventory in the Docker page</li>
</ol> </ol>
</div> </div>
{/* Installation Command */} {/* No Configuration Required */}
<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6"> <div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-3">
Quick Installation (One-Line Command)
</h4>
<div className="space-y-3">
<div>
<div className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Download and install the Docker agent:
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent" && chmod +x /usr/local/bin/patchmon-docker-agent.sh && echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -`}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
/>
<button
type="button"
onClick={() =>
copy_to_clipboard(
`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent" && chmod +x /usr/local/bin/patchmon-docker-agent.sh && echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -`,
"docker-install",
)
}
className="btn-primary flex items-center gap-1 px-3 py-2 whitespace-nowrap"
>
{copy_success["docker-install"] ? (
<>
<CheckCircle className="h-4 w-4" />
Copied
</>
) : (
<>
<Copy className="h-4 w-4" />
Copy
</>
)}
</button>
</div>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-2">
💡 This will download the agent, make it executable, and
set up a cron job to run every 5 minutes
</p>
</div>
</div>
</div>
{/* Manual Installation Steps */}
<div className="bg-white dark:bg-secondary-900 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<h4 className="text-md font-semibold text-secondary-900 dark:text-white mb-3">
Manual Installation Steps
</h4>
<div className="space-y-4">
<div>
<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
<strong>Step 1:</strong> Download the agent
</p>
<div className="flex items-center gap-2">
<input
type="text"
value={`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent"`}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
/>
<button
type="button"
onClick={() =>
copy_to_clipboard(
`curl -o /usr/local/bin/patchmon-docker-agent.sh "${server_url}/api/v1/docker/agent"`,
"docker-download",
)
}
className="btn-primary p-2"
>
{copy_success["docker-download"] ? (
<CheckCircle className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
<div>
<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
<strong>Step 2:</strong> Make it executable
</p>
<div className="flex items-center gap-2">
<input
type="text"
value="chmod +x /usr/local/bin/patchmon-docker-agent.sh"
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
/>
<button
type="button"
onClick={() =>
copy_to_clipboard(
"chmod +x /usr/local/bin/patchmon-docker-agent.sh",
"docker-chmod",
)
}
className="btn-primary p-2"
>
{copy_success["docker-chmod"] ? (
<CheckCircle className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
<div>
<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
<strong>Step 3:</strong> Test the agent
</p>
<div className="flex items-center gap-2">
<input
type="text"
value="/usr/local/bin/patchmon-docker-agent.sh collect"
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
/>
<button
type="button"
onClick={() =>
copy_to_clipboard(
"/usr/local/bin/patchmon-docker-agent.sh collect",
"docker-test",
)
}
className="btn-primary p-2"
>
{copy_success["docker-test"] ? (
<CheckCircle className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
<div>
<p className="text-sm text-secondary-700 dark:text-secondary-300 mb-2">
<strong>Step 4:</strong> Set up automatic collection
(every 5 minutes)
</p>
<div className="flex items-center gap-2">
<input
type="text"
value='echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -'
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-900 text-secondary-900 dark:text-white font-mono text-xs"
/>
<button
type="button"
onClick={() =>
copy_to_clipboard(
'echo "*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect" | crontab -',
"docker-cron",
)
}
className="btn-primary p-2"
>
{copy_success["docker-cron"] ? (
<CheckCircle className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
</div>
{/* Prerequisites */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" /> <CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-yellow-800 dark:text-yellow-200"> <div className="text-sm text-green-800 dark:text-green-200">
<p className="font-semibold mb-2">Prerequisites:</p> <p className="font-semibold mb-1">
No Additional Configuration Required
</p>
<p>
Once the Go agent is installed and Docker is running on
your host, Docker inventory collection happens
automatically. No separate Docker agent or cron jobs
needed.
</p>
</div>
</div>
</div>
{/* Requirements */}
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<div className="flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-semibold mb-2">Requirements:</p>
<ul className="list-disc list-inside space-y-1 ml-2"> <ul className="list-disc list-inside space-y-1 ml-2">
<li>PatchMon Go agent must be installed and running</li>
<li>Docker daemon must be installed and running</li>
<li> <li>
Docker must be installed and running on the host Agent must have access to the Docker socket (
</li> <code className="bg-blue-100 dark:bg-blue-900/40 px-1 py-0.5 rounded text-xs">
<li> /var/run/docker.sock
PatchMon credentials file must exist at{" "}
<code className="bg-yellow-100 dark:bg-yellow-900/40 px-1 py-0.5 rounded text-xs">
/etc/patchmon/credentials
</code> </code>
)
</li> </li>
<li> <li>
The host must have network access to your PatchMon Typically requires running the agent as root or with
server Docker group permissions
</li> </li>
<li>The agent must run as root (or with sudo)</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -215,8 +215,8 @@ const SettingsHostGroups = () => {
title={`View hosts in ${group.name}`} title={`View hosts in ${group.name}`}
> >
<Server className="h-4 w-4 mr-2" /> <Server className="h-4 w-4 mr-2" />
{group._count.hosts} host {group._count?.hosts || 0} host
{group._count.hosts !== 1 ? "s" : ""} {group._count?.hosts !== 1 ? "s" : ""}
</button> </button>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
@@ -539,9 +539,18 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
// Delete Confirmation Modal // Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => { const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
// Fetch hosts for this group
const { data: hostsData } = useQuery({
queryKey: ["hostGroupHosts", group?.id],
queryFn: () => hostGroupsAPI.getHosts(group.id).then((res) => res.data),
enabled: !!group && group._count?.hosts > 0,
});
const hosts = hostsData || [];
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md"> <div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<div className="flex items-center gap-3 mb-4"> <div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center"> <div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600" /> <AlertTriangle className="h-5 w-5 text-danger-600" />
@@ -561,14 +570,32 @@ const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
Are you sure you want to delete the host group{" "} Are you sure you want to delete the host group{" "}
<span className="font-semibold">"{group.name}"</span>? <span className="font-semibold">"{group.name}"</span>?
</p> </p>
{group._count.hosts > 0 && ( {group._count?.hosts > 0 && (
<div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md"> <div className="mt-3 p-3 bg-blue-50 border border-blue-200 rounded-md">
<p className="text-sm text-blue-800"> <p className="text-sm text-blue-800 mb-2">
<strong>Note:</strong> This group contains {group._count.hosts}{" "} <strong>Note:</strong> This group contains {group._count?.hosts}{" "}
host host
{group._count.hosts !== 1 ? "s" : ""}. These hosts will be moved {group._count?.hosts !== 1 ? "s" : ""}. These hosts will be
to "No group" after deletion. moved to "No group" after deletion.
</p> </p>
{hosts.length > 0 && (
<div className="mt-2">
<p className="text-xs font-medium text-blue-900 mb-1">
Hosts in this group:
</p>
<div className="max-h-32 overflow-y-auto bg-blue-100 rounded p-2">
{hosts.map((host) => (
<div
key={host.id}
className="text-xs text-blue-900 flex items-center gap-1"
>
<Server className="h-3 w-3" />
{host.friendly_name || host.hostname}
</div>
))}
</div>
</div>
)}
</div> </div>
)} )}
</div> </div>

View File

@@ -0,0 +1,413 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
BarChart3,
BookOpen,
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>
{/* More Information Button */}
<div className="mt-4 pt-4 border-t border-blue-200 dark:border-blue-700">
<a
href="https://docs.patchmon.net/books/patchmon-application-documentation/page/metrics-collection-information"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center px-4 py-2 text-sm font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/50 rounded-md hover:bg-blue-200 dark:hover:bg-blue-900/70 transition-colors"
>
<BookOpen className="h-4 w-4 mr-2" />
More Information
</a>
</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 // Create axios instance with default config
const api = axios.create({ const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
timeout: 10000, timeout: 10000, // 10 seconds
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
@@ -19,6 +19,30 @@ api.interceptors.request.use(
if (token) { if (token) {
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
} }
// Add device ID for TFA remember-me functionality
// This uniquely identifies the browser profile (normal vs incognito)
let deviceId = localStorage.getItem("device_id");
if (!deviceId) {
// Generate a unique device ID and store it
// Use crypto.randomUUID() if available, otherwise generate a UUID v4 manually
if (typeof crypto !== "undefined" && crypto.randomUUID) {
deviceId = crypto.randomUUID();
} else {
// Fallback: Generate UUID v4 manually
deviceId = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
/[xy]/g,
(c) => {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
},
);
}
localStorage.setItem("device_id", deviceId);
}
config.headers["X-Device-ID"] = deviceId;
return config; return config;
}, },
(error) => { (error) => {
@@ -95,6 +119,8 @@ export const adminHostsAPI = {
api.put("/hosts/bulk/groups", { hostIds, groupIds }), api.put("/hosts/bulk/groups", { hostIds, groupIds }),
toggleAutoUpdate: (hostId, autoUpdate) => toggleAutoUpdate: (hostId, autoUpdate) =>
api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }), api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
forceAgentUpdate: (hostId) => api.post(`/hosts/${hostId}/force-agent-update`),
fetchReport: (hostId) => api.post(`/hosts/${hostId}/fetch-report`),
updateFriendlyName: (hostId, friendlyName) => updateFriendlyName: (hostId, friendlyName) =>
api.patch(`/hosts/${hostId}/friendly-name`, { api.patch(`/hosts/${hostId}/friendly-name`, {
friendly_name: friendlyName, friendly_name: friendlyName,
@@ -143,6 +169,12 @@ export const settingsAPI = {
getServerUrl: () => api.get("/settings/server-url"), getServerUrl: () => api.get("/settings/server-url"),
}; };
// User Preferences API
export const userPreferencesAPI = {
get: () => api.get("/user/preferences"),
update: (preferences) => api.patch("/user/preferences", preferences),
};
// Agent File Management API // Agent File Management API
export const agentFileAPI = { export const agentFileAPI = {
getInfo: () => api.get("/hosts/agent/info"), getInfo: () => api.get("/hosts/agent/info"),

View File

@@ -0,0 +1,171 @@
/**
* Docker-related utility functions for the frontend
*/
/**
* Generate a registry link for a Docker image based on its repository and source
* @param {string} repository - The full repository name (e.g., "ghcr.io/owner/repo")
* @param {string} source - The detected source (github, gitlab, docker-hub, etc.)
* @returns {string|null} - The URL to the registry page, or null if unknown
*/
export function generateRegistryLink(repository, source) {
if (!repository) {
return null;
}
// Parse the domain and path from the repository
const parts = repository.split("/");
let domain = "";
let path = "";
// Check if repository has a domain (contains a dot)
if (parts[0].includes(".") || parts[0].includes(":")) {
domain = parts[0];
path = parts.slice(1).join("/");
} else {
// No domain means Docker Hub
domain = "docker.io";
path = repository;
}
switch (source) {
case "docker-hub":
case "docker.io": {
// Docker Hub: https://hub.docker.com/r/{path} or https://hub.docker.com/_/{path} for official images
// Official images are those without a namespace (e.g., "postgres" not "user/postgres")
// or explicitly prefixed with "library/"
if (path.startsWith("library/")) {
const cleanPath = path.replace("library/", "");
return `https://hub.docker.com/_/${cleanPath}`;
}
// Check if it's an official image (single part, no slash after removing library/)
if (!path.includes("/")) {
return `https://hub.docker.com/_/${path}`;
}
// Regular user/org image
return `https://hub.docker.com/r/${path}`;
}
case "github":
case "ghcr.io": {
// GitHub Container Registry
// Format: ghcr.io/{owner}/{package} or ghcr.io/{owner}/{repo}/{package}
// URL format: https://github.com/{owner}/{repo}/pkgs/container/{package}
if (domain === "ghcr.io" && path) {
const pathParts = path.split("/");
if (pathParts.length === 2) {
// Simple case: ghcr.io/owner/package -> github.com/owner/owner/pkgs/container/package
// OR: ghcr.io/owner/repo -> github.com/owner/repo/pkgs/container/{package}
// Actually, for 2 parts it's owner/package, and repo is same as owner typically
const owner = pathParts[0];
const packageName = pathParts[1];
return `https://github.com/${owner}/${owner}/pkgs/container/${packageName}`;
} else if (pathParts.length >= 3) {
// Extended case: ghcr.io/owner/repo/package -> github.com/owner/repo/pkgs/container/package
const owner = pathParts[0];
const repo = pathParts[1];
const packageName = pathParts.slice(2).join("/");
return `https://github.com/${owner}/${repo}/pkgs/container/${packageName}`;
}
}
// Legacy GitHub Packages
if (domain === "docker.pkg.github.com" && path) {
const pathParts = path.split("/");
if (pathParts.length >= 1) {
return `https://github.com/${pathParts[0]}/packages`;
}
}
return null;
}
case "gitlab":
case "registry.gitlab.com": {
// GitLab Container Registry
if (path) {
return `https://gitlab.com/${path}/container_registry`;
}
return null;
}
case "google":
case "gcr.io": {
// Google Container Registry
if (domain.includes("gcr.io") || domain.includes("pkg.dev")) {
return `https://console.cloud.google.com/gcr/images/${path}`;
}
return null;
}
case "quay":
case "quay.io": {
// Quay.io
if (path) {
return `https://quay.io/repository/${path}`;
}
return null;
}
case "redhat":
case "registry.access.redhat.com": {
// Red Hat
if (path) {
return `https://access.redhat.com/containers/#/registry.access.redhat.com/${path}`;
}
return null;
}
case "azure":
case "azurecr.io": {
// Azure Container Registry
if (domain.includes("azurecr.io")) {
const registryName = domain.split(".")[0];
return `https://portal.azure.com/#view/Microsoft_Azure_ContainerRegistries/RepositoryBlade/registryName/${registryName}/repositoryName/${path}`;
}
return null;
}
case "aws":
case "amazonaws.com": {
// AWS ECR
if (domain.includes("amazonaws.com")) {
const domainParts = domain.split(".");
const region = domainParts[3]; // Extract region
return `https://${region}.console.aws.amazon.com/ecr/repositories/private/${path}`;
}
return null;
}
case "private":
// For private registries, try to construct a basic URL
if (domain) {
return `https://${domain}`;
}
return null;
default:
return null;
}
}
/**
* Get a user-friendly display name for a registry source
* @param {string} source - The source identifier
* @returns {string} - Human-readable source name
*/
export function getSourceDisplayName(source) {
const sourceNames = {
"docker-hub": "Docker Hub",
github: "GitHub",
gitlab: "GitLab",
google: "Google",
quay: "Quay.io",
redhat: "Red Hat",
azure: "Azure",
aws: "AWS ECR",
private: "Private Registry",
local: "Local",
unknown: "Unknown",
};
return sourceNames[source] || source;
}

View File

@@ -1,3 +1,4 @@
import { Agent as HttpAgent } from "node:http";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
@@ -14,6 +15,15 @@ export default defineConfig({
target: `http://${process.env.BACKEND_HOST || "localhost"}:${process.env.BACKEND_PORT || "3001"}`, target: `http://${process.env.BACKEND_HOST || "localhost"}:${process.env.BACKEND_PORT || "3001"}`,
changeOrigin: true, changeOrigin: true,
secure: false, 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: configure:
process.env.VITE_ENABLE_LOGGING === "true" process.env.VITE_ENABLE_LOGGING === "true"
? (proxy, _options) => { ? (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", "name": "patchmon",
"version": "1.3.0", "version": "1.3.2",
"description": "Linux Patch Monitoring System", "description": "Linux Patch Monitoring System",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"private": true, "private": true,
@@ -25,7 +25,7 @@
"lint:fix": "biome check --write ." "lint:fix": "biome check --write ."
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.4", "@biomejs/biome": "^2.3.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"lefthook": "^1.13.4" "lefthook": "^1.13.4"
}, },

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