Compare commits

..

177 Commits

Author SHA1 Message Date
renovate[bot]
d8741ba083 fix(deps): update dependency react-router-dom to v7 2025-10-29 18:54:15 +00: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
Muhammad Ibrahim
de449c547f Fixed some ratelimits that were hardcoded and ammended docker compose to take into consideration rate limits 2025-10-22 15:22:14 +01:00
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
Muhammad Ibrahim
a8bd09be89 Made the setup.sh regenerate the .env variables 2025-10-22 14:15:49 +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
Muhammad Ibrahim
3ae8422487 modified nginx config for updates 2025-10-22 12:12:06 +01:00
Muhammad Ibrahim
c98203a997 Fixed bug on nginx configuration 2025-10-22 02:31:53 +01:00
Muhammad Ibrahim
37c8f5fa76 Modified setup.sh to handle the changes in version 1.3.0 2025-10-22 02:09:23 +01:00
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
Muhammad Ibrahim
50e546ee7e Fixed Bullboard authentication via Docker
Fixed Agent checking upon entrypoint
modified entrypoint to handle both binary files as well as the shell script
2025-10-21 21:29:15 +01:00
Muhammad Ibrahim
2174abf395 Static Lib for Go Agent 2025-10-21 00:07:51 +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
Muhammad Ibrahim
1350fd4e47 Added new binaries 2025-10-20 23:01:32 +01:00
Muhammad Ibrahim
6b9a42fb0b Addded better Go agent upgradation support 2025-10-20 21:39:20 +01:00
Muhammad Ibrahim
3ee6f9aaa0 Better update handling by the Go Agent 2025-10-20 21:13:08 +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
Muhammad Ibrahim
8a5d61a7c1 Remove /bullboard from caching
Fixed entrypoint to make the binary executable
2025-10-20 20:24:12 +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
Muhammad Ibrahim
df502c676f added bullboard url for docker nginx template 2025-10-20 19:43:58 +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
Muhammad Ibrahim
54cea6b20b Added axios in package.json 2025-10-20 19:19:00 +01:00
9 Technology Group LTD
1ef2308d56 Agent version detection and added nginx template 2025-10-20 18:55:43 +01:00
Muhammad Ibrahim
af9b0d5d76 Added websocket support in the nginx template for docker 2025-10-20 18:45:16 +01:00
Muhammad Ibrahim
7b8c29860c Improved Agent version checking logic and page with ability to download the binaries from the REPO again 2025-10-20 17:46:27 +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
Muhammad Ibrahim
d78fb63c2d Modified setup.sh to handle the bullboard url 2025-10-19 20:53:43 +01:00
Muhammad Ibrahim
d3dc068c8e Simplified the docker nginx template
Modified the url for the buillboard to just /bullboard and made the nginx configuration to match
2025-10-19 20:46:09 +01:00
Muhammad Ibrahim
46e19fbfc2 Modified the auto-enrollment route to cater for the new multihostgroups variable when creating a new host 2025-10-19 19:32:51 +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
Muhammad Ibrahim
80a701cc33 Modified the proxmox_auto-enroll.sh script to suit the new way 2025-10-19 18:57:28 +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
Muhammad Ibrahim
c4d0d8bee8 Fixed repo count issue
Refactored code to remove duplicate backend api endpoints for counting
Improved connection persistence issues
Improved database connection pooling issues
Fixed redis connection efficiency
Changed version to 1.3.0
Fixed GO binary detection based on package manager rather than OS
2025-10-19 17:53:10 +01:00
Muhammad Ibrahim
30c89de134 Improved detection logic and upgrade mechanism using intermeditary script 2025-10-18 22:59:03 +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
Muhammad Ibrahim
4b35fc9ab9 Fixed upgrade detection logic 2025-10-18 21:53:35 +01:00
9 Technology Group LTD
191a1afada Enhance Redis user creation and security
Updated Redis user creation process to enhance security by generating a separate user password. Adjusted Redis CLI commands to include host and port specifications.
2025-10-18 21:05:36 +01:00
9 Technology Group LTD
175f10b8b7 Improve Redis user creation error handling
Refactor Redis user creation to capture command output and verify success.
2025-10-18 21:01:57 +01:00
9 Technology Group LTD
080bcbe22e Merge pull request #181 from PatchMon/feature/go-agent
Upgrade from <1.2.8 to 1.3.0
2025-10-18 17:38:02 +01:00
Muhammad Ibrahim
3175ed79a5 Added arm32 based agent
Added support for migrating from legacy bash script to new binary via intermediatry 1.2.9 script
2025-10-18 17:28:46 +01:00
Muhammad Ibrahim
fba6d0ede5 Added REDIS_USER variable in the generation of .env 2025-10-18 16:34:10 +01:00
Muhammad Ibrahim
54a5012012 Created tools folder
Modified setup.sh to now cater for redis installation
2025-10-18 16:26:36 +01:00
Muhammad Ibrahim
5004e062b4 Setup Redis passwords to be used in Vm installation or via Docker
Setup so that CORS_ORIGIN error appears on the frontend to help new installations
2025-10-18 16:14:09 +01:00
9 Technology Group LTD
44d52a5536 Merge pull request #180 from PatchMon/feature/go-agent
fixing redis environment issue and some UI fixes
2025-10-18 02:06:34 +01:00
Muhammad Ibrahim
52c8ba6b03 feat: implement multi-select checkbox interface for bulk host group assignment
- Add new backend endpoint PUT /api/hosts/bulk/groups for multi-group assignment
- Update BulkAssignModal to use checkbox interface instead of single select
- Replace single group selection with multi-select checkboxes
- Maintain visual consistency with existing multi-select patterns
- Add proper validation and error handling for multiple groups
- Remove unused bulkHostGroupId variable to fix linting error

This allows users to assign multiple hosts to multiple groups simultaneously,
improving the bulk assignment workflow and user experience.
2025-10-18 02:01:06 +01:00
Muhammad Ibrahim
9db563dec3 Modified docker-compose.yml for redis password
Fixed Assigning hosts to multiple groups in the ui
2025-10-18 02:00:08 +01:00
9 Technology Group LTD
c328123bd3 Merge pull request #179 from PatchMon/feature/go-agent
Major release 1.3.0 - New architecture
2025-10-17 22:43:09 +01:00
Muhammad Ibrahim
46eb797ac3 I should really commit more often instead of sending over one massive commit
Blame my ADHD brain
Sorry
- Now we have the server working properly in automation using BullMQ and Redis
- It also presents an API endpoint that is used to accept connections for websockets by agents (WS or WSS)
- Updated the docker-compose.yml and its documentation
2025-10-17 22:10:55 +01:00
Muhammad Ibrahim
c43afeb127 Added qty of connected and offline to the hosts dashboard page 2025-10-15 22:40:52 +01:00
Muhammad Ibrahim
5b77a1328d Removed js file for the update checker for github
Added real-time feature for agent status
made some ui improvements on the host details page
2025-10-15 22:15:18 +01:00
Muhammad Ibrahim
9a40d5e6ee Added support for the new agent mechanism and Binary
Added bullMQ + redis to the platform for automation and queue mechanism
Added new tabs in host details
2025-10-15 20:56:58 +01:00
Muhammad Ibrahim
fdd0cfd619 Make user_sessions migration idempotent for 1.2.7 compatibility
- Modified 20251005000000_add_user_sessions to check if table exists first
- Added existence checks for all indexes and foreign keys
- Migration now works for both fresh installs and 1.2.7 upgrades
- Prevents P3018 error by gracefully handling existing table
- Added comprehensive logging for debugging
2025-10-13 21:36:52 +01:00
Muhammad Ibrahim
de236f9ae2 Simplify migration reconciliation for 1.2.7 upgrade
- Simplified logic to focus on core issue: table exists but no migration record
- Creates migration record when user_sessions table exists from 1.2.7
- Prevents P3018 error by marking migration as already applied
- More reliable approach for production upgrades
2025-10-13 21:33:22 +01:00
Muhammad Ibrahim
4d5040e0e9 Merge feature/automation into main
- Resolve migration reconciliation conflicts
- Include updated migration that handles 1.2.7 upgrade scenario
- Merge automation features and Docker support
2025-10-13 21:25:11 +01:00
Muhammad Ibrahim
28c5310b99 Fix migration reconciliation to handle 1.2.7 upgrade scenario
- Add case for table exists but no migration record (1.2.7 upgrade)
- Creates migration record for existing user_sessions table
- Prevents P3018 error when table exists from 1.2.7 installation
- Handles all upgrade scenarios properly
2025-10-13 21:24:35 +01:00
Muhammad Ibrahim
a2e9743da6 Add migration reconciliation for user_sessions 1.2.7 to 1.2.8+ upgrade
- Creates migration 20251004999999_reconcile_user_sessions_migration
- Runs before 20251005000000_add_user_sessions migration
- Properly handles failed migrations by marking them as rolled back first
- Handles migration name conflicts from 1.2.7 (add_user_sessions) to 1.2.8+ (20251005000000_add_user_sessions)
- Fixes failed migration states from upgrade attempts
- Works naturally within Prisma migration system
- No changes to Docker entrypoint or setup scripts needed
2025-10-13 21:15:22 +01:00
Muhammad Ibrahim
3863d641fa Fix git update conflicts in setup.sh --update
- Add git clean -fd to remove untracked files before pull
- Add git reset --hard HEAD to ensure clean state
- Prevents merge conflicts from untracked files during updates
- Ensures smooth updates from any version to any version
2025-10-13 21:15:04 +01:00
Muhammad Ibrahim
cc8f77a946 deleted the js file 2025-10-13 20:59:05 +01:00
Muhammad Ibrahim
36455e2bfd Fixed Migration to be done via a prixma migraiton not js script 2025-10-13 20:58:08 +01:00
Muhammad Ibrahim
af65d38cad - Fixes P3009 error when upgrading from 1.2.7
- Reconciles 'add_user_sessions' to '20251005000000_add_user_sessions'
- Prevents duplicate migration attempts
- Handles fresh installs gracefully"
2025-10-13 20:29:42 +01:00
Muhammad Ibrahim
29266b6d77 Added longer transaction timeout on Postgresql DB 2025-10-12 21:14:52 +01:00
Muhammad Ibrahim
f96e468482 Improved patchmon-agent.sh logic to handle locked apt processes
Introduced docker Feature integration via agent
2025-10-11 22:54:49 +01:00
Muhammad Ibrahim
9f8c88badf Remove 'coming soon' indicator from Automation menu item 2025-10-11 20:55:27 +01:00
Muhammad Ibrahim
7985a225d7 Merge main into feature/automation to align git history
Resolved conflicts:
- backend/src/server.js: Kept automation routes alongside gethomepage routes
- frontend/src/pages/Queue.jsx: Kept deleted (replaced by Automation.jsx)
- setup.sh: Kept newer version date (2025-10-11)

This merge brings in all commits from main including:
- GetHomepage integration
- Version 1.2.9 updates
- Migration file renames
- Bug fixes and improvements
2025-10-11 20:45:29 +01:00
Muhammad Ibrahim
8c538bd99c Merge changes from main: Add GetHomepage integration and update to v1.2.9
- Added gethomepageRoutes.js for GetHomepage integration
- Updated all package.json files to version 1.2.9
- Updated agent script to version 1.2.9
- Updated version fallbacks in versionRoutes.js and updateScheduler.js
- Updated setup.sh with version 1.2.9
- Merged GetHomepage integration UI (Integrations.jsx)
- Updated docker-entrypoint.sh from main
- Updated VersionUpdateTab component
- Combined automation and gethomepage routes in server.js
- Maintains both BullMQ automation and GetHomepage functionality
2025-10-11 20:35:47 +01:00
9 Technology Group LTD
623bf5e2c8 Merge pull request #161 from PatchMon/feature/gethomepage
Feature/gethomepage + new version 1.2.9
2025-10-11 20:21:44 +01:00
Muhammad Ibrahim
ed8cc81b89 Changed version from 1.2.8 to 1.2.9 in preperation for next release 2025-10-11 20:14:08 +01:00
Muhammad Ibrahim
5c4353a688 Fixed linting errors with gethomepage area 2025-10-11 20:04:29 +01:00
Muhammad Ibrahim
6ebcdd57d5 Fixed Migration order issue where users were getting error of "add_user_sessions" does not exist 2025-10-11 14:47:27 +01:00
Muhammad Ibrahim
a3d0dfd665 Fixed entrypoint to handle better updating of Agent mechanism
Updated Readme to show the --update flag
2025-10-10 21:52:57 +01:00
Muhammad Ibrahim
d99ded6d65 Added Database Backup ability when doing setup.sh -- update 2025-10-10 20:16:24 +01:00
Muhammad Ibrahim
1ea96b6172 Merge branch 'main' of github.com:9technologygroup/patchmon.net 2025-10-10 19:37:46 +01:00
Muhammad Ibrahim
1e5ee66825 Fixed version update checking mechanism
Updated the setup.sh script to have the --update flag
2025-10-10 19:32:44 +01:00
Muhammad Ibrahim
88130797e4 Updated Version to 1.2.8 2025-10-10 12:39:17 +01:00
Muhammad Ibrahim
0ad1a96871 Building the start of Automation page and implemented BullMQ module 2025-10-10 12:24:23 +01:00
9 Technology Group LTD
566c415471 Merge pull request #152 from PatchMon/feature/queue
Feature/Agent
2025-10-08 18:52:02 +01:00
Muhammad Ibrahim
cfc91243eb Fixed Issues with RHEL based systems not sending their repos to PatchMon 2025-10-08 18:46:39 +01:00
Muhammad Ibrahim
84cf31869b Fixed spacing in the header for the buttons 2025-10-08 17:57:56 +01:00
Muhammad Ibrahim
18c9d241eb Fixed RockyLinux 10 Support 2025-10-08 17:53:08 +01:00
Muhammad Ibrahim
86b5da3ea0 Removed titles from the top nav bar to give space to search bar 2025-10-08 17:25:24 +01:00
9 Technology Group LTD
c9b5ee63d8 Merge pull request #151 from PatchMon/fix/agentdata
Fix/agentdata
2025-10-08 16:25:56 +01:00
Muhammad Ibrahim
ac4415e1dc Added support for Oracle Linux 9 2025-10-08 16:24:35 +01:00
9 Technology Group LTD
3737a5a935 Merge pull request #145 from Maelstromeous/patch-1
Document manual result update process for PatchMon
2025-10-08 15:50:28 +01:00
9 Technology Group LTD
bcce48948a Merge pull request #148 from PatchMon/refactor/frontend_optimisations
Various optimisations/fixes - mostly frontend
2025-10-08 15:48:10 +01:00
Muhammad Ibrahim
5e4c628110 Dashboard Card ecit 2025-10-08 09:53:03 +01:00
Muhammad Ibrahim
a8668ee3f3 Hide Dashboard text in header to give more space to search bar 2025-10-08 09:47:10 +01:00
Muhammad Ibrahim
5487206384 Fix hamburger menu icon and separator dark mode styling 2025-10-08 09:46:04 +01:00
Muhammad Ibrahim
daa31973f9 Fix mobile menu dark mode styling for Dashboard and navigation items 2025-10-08 09:45:31 +01:00
Muhammad Ibrahim
561c78fb08 Remove coming soon items from mobile menu navigation 2025-10-08 09:44:26 +01:00
Muhammad Ibrahim
6d3f2d94ba Add dark mode support and logout functionality to mobile menu 2025-10-08 09:43:41 +01:00
Muhammad Ibrahim
93534ebe52 Add dark mode support to BulkAssignModal 2025-10-08 09:40:38 +01:00
Muhammad Ibrahim
5cf2811bfd Fix BulkAssignModal: add missing bulkHostGroupId variable 2025-10-08 09:40:02 +01:00
tigattack
8fd91eae1a fix(frontend): use updateUserMutation in EditUserModal
Makes it more consistent with the other user mutations and resolves a lint error for the formerly unused `updateUserMutation`
2025-10-08 02:18:20 +01:00
tigattack
da8c661d20 refactor: fix lint errors 2025-10-08 02:12:51 +01:00
tigattack
2bf639e315 chore: update gitignore for docker dev 2025-10-08 02:10:40 +01:00
tigattack
c02ac4bd6f fix(frontend): don't query settings before auth 2025-10-08 02:10:40 +01:00
tigattack
4e0eaf7323 feat(frontend): add lazy loading for routes with Suspense fallback 2025-10-08 02:10:40 +01:00
tigattack
ef9ef58bcb feat(vite): add manual chunking for optimized build output 2025-10-08 02:08:46 +01:00
9 Technology Group LTD
29afe3da1f Merge pull request #147 from PatchMon/fix/agentdata
Add Line Chart
2025-10-08 00:47:47 +01:00
Muhammad Ibrahim
a861e4f9eb Fix linting issues: remove unused imports, add button types, fix array keys 2025-10-08 00:42:26 +01:00
9 Technology Group LTD
12ef6fd8e1 Merge pull request #146 from PatchMon/fix/agentdata
Agent improvements for Debian
Removal of 100 package limit
Modified hosts detail page with Agent history
Added Device fingerprinting for better session management (I need to improve this though)
Added Dashboard card of Package trends for all or specific hosts
Fixed filtering on the package page
2025-10-08 00:33:12 +01:00
Muhammad Ibrahim
ba9de097dc Added Dashboard card to show Package trends over time 2025-10-07 22:48:15 +01:00
Muhammad Ibrahim
8103581d17 Added Package trends over time graph XD 2025-10-07 22:46:55 +01:00
Muhammad Ibrahim
cdb24520d8 Added Total Packages in the Agent history
Added Script execution time in the Agent history tab
Added Pagination for the agent History
2025-10-07 21:46:37 +01:00
Muhammad Ibrahim
831adf3038 Fixed filtering for regular / security updates pie chart on the dashboard 2025-10-07 21:13:22 +01:00
Muhammad Ibrahim
2a1eed1354 Fixed Filtering with the OS Distribution Dashboard card 2025-10-07 21:01:44 +01:00
Muhammad Ibrahim
7819d4512e Made the coffee cup Yellow 2025-10-07 20:54:21 +01:00
Muhammad Ibrahim
a305fe23d3 Fixed issues with the agent not sending apt data properly
Added Indexing to the database for faster and efficient searching
Fixed some filtering from the hosts page relating to packages that need updating
Added buy me a coffee link (sorry and thank you <3)
2025-10-07 20:52:46 +01:00
Matt Cavanagh
2b36e88d85 Revise manual update instructions in README
Updated instructions for forcing updates after host package changes.
2025-10-07 20:25:53 +01:00
Matt Cavanagh
6624ec002d Document manual update process for PatchMon
Add instructions for manual update in README
2025-10-07 20:24:15 +01:00
Muhammad Ibrahim
840779844a Removed 100 limit 2025-10-07 18:20:41 +01:00
Muhammad Ibrahim
f91d3324ba Merge branch 'main' of github.com:9technologygroup/patchmon.net 2025-10-07 18:13:04 +01:00
Muhammad Ibrahim
8c60b5277e Update frontend: HostDetail, Hosts, and osIcons 2025-10-07 18:12:56 +01:00
9 Technology Group LTD
2ac756af84 Merge pull request #139 from stianmeyer/patch-2
Search for the absence of .sh files in the /app/agents folder to trigger copying of the agent files
2025-10-06 09:49:42 +01:00
9 Technology Group LTD
e227004d6b Merge pull request #140 from PatchMon/docs/docker
docs(docker): add description for 'edge' tag
2025-10-06 09:47:12 +01:00
Muhammad Ibrahim
d379473568 Added TFA timeout env variables
Added profile session management
Added "Remember me" to bypass TFA using device fingerprint
Fixed profile name not being persistent after logout and login
2025-10-06 00:55:23 +01:00
9 Technology Group LTD
2edc773adf Merge pull request #141 from PatchMon/ci/docker_no_push_fork 2025-10-05 23:27:44 +01:00
Stian Meyer
2db839556c Copy from agents_backup only when no .sh scripts are present 2025-10-06 00:24:07 +02:00
tigattack
811f5b5885 docs(docker): add description for 'edge' tag 2025-10-05 22:55:46 +01:00
tigattack
b43c9e94fd Merge pull request #117 from PatchMon/ci/tweaks 2025-10-05 22:38:29 +01:00
Stian Meyer
2e2a554aa3 Update backend.docker-entrypoint.sh 2025-10-05 23:36:46 +02:00
tigattack
eabcfd370c ci(docker): remove 'dev' branch from push trigger and update image tag handling
- Create 'edge' tag for pushes to main
- Create versioned & latest tags for new tags with `v` prefix (instead of on release)
2025-10-05 21:33:41 +01:00
tigattack
55cb07b3c8 ci(build): remove 'dev' branch from push trigger 2025-10-05 21:33:41 +01:00
tigattack
0e049ec3d5 ci: ignore changes to docker in build and code quality workflows 2025-10-05 21:33:41 +01:00
124 changed files with 25742 additions and 6586 deletions

34
.dockerignore Normal file
View File

@@ -0,0 +1,34 @@
# Environment files
**/.env
**/.env.*
**/env.example
# Node modules
**/node_modules
# Logs
**/logs
**/*.log
# Git
**/.git
**/.gitignore
# IDE files
**/.vscode
**/.idea
**/*.swp
**/*.swo
# OS files
**/.DS_Store
**/Thumbs.db
# Build artifacts
**/dist
**/build
**/coverage
# Temporary files
**/tmp
**/temp

View File

@@ -3,7 +3,9 @@ on:
push:
branches:
- main
- dev
paths-ignore:
- 'docker/**'
jobs:
deploy:
runs-on: self-hosted

View File

@@ -2,7 +2,11 @@ name: Code quality
on:
push:
paths-ignore:
- 'docker/**'
pull_request:
paths-ignore:
- 'docker/**'
jobs:
check:

View File

@@ -1,13 +1,14 @@
name: Build and Push Docker Images
on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- main
- dev
release:
types:
- published
workflow_dispatch:
inputs:
push:
@@ -56,7 +57,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
type=edge,branch=main
- name: Build and push ${{ matrix.image }} image
uses: docker/build-push-action@v6

3
.gitignore vendored
View File

@@ -139,6 +139,7 @@ playwright-report/
test-results.xml
test_*.sh
test-*.sh
*.code-workspace
# Package manager lock files (uncomment if you want to ignore them)
# package-lock.json
@@ -154,4 +155,4 @@ setup-installer-site.sh
install-server.*
notify-clients-upgrade.sh
debug-agent.sh
docker/compose_dev_data
docker/compose_dev_*

View File

@@ -43,7 +43,7 @@ PatchMon provides centralized patch management across diverse server environment
### API & Integrations
- REST API under `/api/v1` with JWT auth
- **Proxmox LXC Auto-Enrollment** - Automatically discover and enroll LXC containers from Proxmox hosts ([Documentation](PROXMOX_AUTO_ENROLLMENT.md))
- Proxmox LXC Auto-Enrollment - Automatically discover and enroll LXC containers from Proxmox hosts
### Security
- Rate limiting for general, auth, and agent endpoints
@@ -85,11 +85,16 @@ apt-get upgrade -y
apt install curl -y
```
#### Script
#### Install Script
```bash
curl -fsSL -o setup.sh https://raw.githubusercontent.com/PatchMon/PatchMon/refs/heads/main/setup.sh && chmod +x setup.sh && bash setup.sh
```
#### Update Script (--update flag)
```bash
curl -fsSL -o setup.sh https://raw.githubusercontent.com/PatchMon/PatchMon/refs/heads/main/setup.sh && chmod +x setup.sh && bash setup.sh --update
```
#### Minimum specs for building : #####
CPU : 2 vCPU
RAM : 2GB
@@ -113,6 +118,14 @@ After installation:
- Visit `http(s)://<your-domain>` and complete first-time admin setup
- See all useful info in `deployment-info.txt`
## Forcing updates after host package changes
Should you perform a manual package update on your host and wish to see the results reflected in PatchMon quicker than the usual scheduled update, you can trigger the process manually by running:
```bash
/usr/local/bin/patchmon-agent.sh update
```
This will send the results immediately to PatchMon.
## Communication Model
- Outbound-only agents: servers initiate communication to PatchMon

File diff suppressed because it is too large Load Diff

BIN
agents/patchmon-agent-linux-386 Executable file

Binary file not shown.

BIN
agents/patchmon-agent-linux-amd64 Executable file

Binary file not shown.

BIN
agents/patchmon-agent-linux-arm Executable file

Binary file not shown.

BIN
agents/patchmon-agent-linux-arm64 Executable file

Binary file not shown.

File diff suppressed because it is too large Load Diff

496
agents/patchmon-docker-agent.sh Executable file
View File

@@ -0,0 +1,496 @@
#!/bin/bash
# PatchMon Docker Agent Script v1.3.0
# This script collects Docker container and image information and sends it to PatchMon
# Configuration
PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}"
API_VERSION="v1"
AGENT_VERSION="1.3.0"
CONFIG_FILE="/etc/patchmon/agent.conf"
CREDENTIALS_FILE="/etc/patchmon/credentials"
LOG_FILE="/var/log/patchmon-docker-agent.log"
# Curl flags placeholder (replaced by server based on SSL settings)
CURL_FLAGS=""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Logging function
log() {
if [[ -w "$(dirname "$LOG_FILE")" ]] 2>/dev/null; then
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE" 2>/dev/null
fi
}
# Error handling
error() {
echo -e "${RED}ERROR: $1${NC}" >&2
log "ERROR: $1"
exit 1
}
# Info logging
info() {
echo -e "${BLUE} $1${NC}" >&2
log "INFO: $1"
}
# Success logging
success() {
echo -e "${GREEN}$1${NC}" >&2
log "SUCCESS: $1"
}
# Warning logging
warning() {
echo -e "${YELLOW}⚠️ $1${NC}" >&2
log "WARNING: $1"
}
# Check if Docker is installed and running
check_docker() {
if ! command -v docker &> /dev/null; then
error "Docker is not installed on this system"
fi
if ! docker info &> /dev/null; then
error "Docker daemon is not running or you don't have permission to access it. Try running with sudo."
fi
}
# Load credentials
load_credentials() {
if [[ ! -f "$CREDENTIALS_FILE" ]]; then
error "Credentials file not found at $CREDENTIALS_FILE. Please configure the main PatchMon agent first."
fi
source "$CREDENTIALS_FILE"
if [[ -z "$API_ID" ]] || [[ -z "$API_KEY" ]]; then
error "API credentials not found in $CREDENTIALS_FILE"
fi
# Use PATCHMON_URL from credentials if available, otherwise use default
if [[ -n "$PATCHMON_URL" ]]; then
PATCHMON_SERVER="$PATCHMON_URL"
fi
}
# Load configuration
load_config() {
if [[ -f "$CONFIG_FILE" ]]; then
source "$CONFIG_FILE"
if [[ -n "$SERVER_URL" ]]; then
PATCHMON_SERVER="$SERVER_URL"
fi
fi
}
# Collect Docker containers
collect_containers() {
info "Collecting Docker container information..."
local containers_json="["
local first=true
# Get all containers (running and stopped)
while IFS='|' read -r container_id name image status state created started ports; do
if [[ -z "$container_id" ]]; then
continue
fi
# Parse image name and tag
local image_name="${image%%:*}"
local image_tag="${image##*:}"
if [[ "$image_tag" == "$image_name" ]]; then
image_tag="latest"
fi
# Determine image source based on registry
local image_source="docker-hub"
if [[ "$image_name" == ghcr.io/* ]]; then
image_source="github"
elif [[ "$image_name" == registry.gitlab.com/* ]]; then
image_source="gitlab"
elif [[ "$image_name" == *"/"*"/"* ]]; then
image_source="private"
fi
# Get repository name (without registry prefix for common registries)
local image_repository="$image_name"
image_repository="${image_repository#ghcr.io/}"
image_repository="${image_repository#registry.gitlab.com/}"
# Get image ID
local full_image_id=$(docker inspect --format='{{.Image}}' "$container_id" 2>/dev/null || echo "unknown")
full_image_id="${full_image_id#sha256:}"
# Normalize status (extract just the status keyword)
local normalized_status="unknown"
if [[ "$status" =~ ^Up ]]; then
normalized_status="running"
elif [[ "$status" =~ ^Exited ]]; then
normalized_status="exited"
elif [[ "$status" =~ ^Created ]]; then
normalized_status="created"
elif [[ "$status" =~ ^Restarting ]]; then
normalized_status="restarting"
elif [[ "$status" =~ ^Paused ]]; then
normalized_status="paused"
elif [[ "$status" =~ ^Dead ]]; then
normalized_status="dead"
fi
# Parse ports
local ports_json="null"
if [[ -n "$ports" && "$ports" != "null" ]]; then
# Convert Docker port format to JSON
ports_json=$(echo "$ports" | jq -R -s -c 'split(",") | map(select(length > 0)) | map(split("->") | {(.[0]): .[1]}) | add // {}')
fi
# Convert dates to ISO 8601 format
# If date conversion fails, use null instead of invalid date string
local created_iso=$(date -d "$created" -Iseconds 2>/dev/null || echo "null")
local started_iso="null"
if [[ -n "$started" && "$started" != "null" ]]; then
started_iso=$(date -d "$started" -Iseconds 2>/dev/null || echo "null")
fi
# Add comma for JSON array
if [[ "$first" == false ]]; then
containers_json+=","
fi
first=false
# Build JSON object for this container
containers_json+="{\"container_id\":\"$container_id\","
containers_json+="\"name\":\"$name\","
containers_json+="\"image_name\":\"$image_name\","
containers_json+="\"image_tag\":\"$image_tag\","
containers_json+="\"image_repository\":\"$image_repository\","
containers_json+="\"image_source\":\"$image_source\","
containers_json+="\"image_id\":\"$full_image_id\","
containers_json+="\"status\":\"$normalized_status\","
containers_json+="\"state\":\"$state\","
containers_json+="\"ports\":$ports_json"
# Only add created_at if we have a valid date
if [[ "$created_iso" != "null" ]]; then
containers_json+=",\"created_at\":\"$created_iso\""
fi
# Only add started_at if we have a valid date
if [[ "$started_iso" != "null" ]]; then
containers_json+=",\"started_at\":\"$started_iso\""
fi
containers_json+="}"
done < <(docker ps -a --format '{{.ID}}|{{.Names}}|{{.Image}}|{{.Status}}|{{.State}}|{{.CreatedAt}}|{{.RunningFor}}|{{.Ports}}' 2>/dev/null)
containers_json+="]"
echo "$containers_json"
}
# Collect Docker images
collect_images() {
info "Collecting Docker image information..."
local images_json="["
local first=true
while IFS='|' read -r repository tag image_id created size digest; do
if [[ -z "$repository" || "$repository" == "<none>" ]]; then
continue
fi
# Clean up tag
if [[ -z "$tag" || "$tag" == "<none>" ]]; then
tag="latest"
fi
# Clean image ID
image_id="${image_id#sha256:}"
# Determine source
local source="docker-hub"
if [[ "$repository" == ghcr.io/* ]]; then
source="github"
elif [[ "$repository" == registry.gitlab.com/* ]]; then
source="gitlab"
elif [[ "$repository" == *"/"*"/"* ]]; then
source="private"
fi
# Convert size to bytes (approximate)
local size_bytes=0
if [[ "$size" =~ ([0-9.]+)([KMGT]?B) ]]; then
local num="${BASH_REMATCH[1]}"
local unit="${BASH_REMATCH[2]}"
case "$unit" in
KB) size_bytes=$(echo "$num * 1024" | bc | cut -d. -f1) ;;
MB) size_bytes=$(echo "$num * 1024 * 1024" | bc | cut -d. -f1) ;;
GB) size_bytes=$(echo "$num * 1024 * 1024 * 1024" | bc | cut -d. -f1) ;;
TB) size_bytes=$(echo "$num * 1024 * 1024 * 1024 * 1024" | bc | cut -d. -f1) ;;
B) size_bytes=$(echo "$num" | cut -d. -f1) ;;
esac
fi
# Convert created date to ISO 8601
# If date conversion fails, use null instead of invalid date string
local created_iso=$(date -d "$created" -Iseconds 2>/dev/null || echo "null")
# Add comma for JSON array
if [[ "$first" == false ]]; then
images_json+=","
fi
first=false
# Build JSON object for this image
images_json+="{\"repository\":\"$repository\","
images_json+="\"tag\":\"$tag\","
images_json+="\"image_id\":\"$image_id\","
images_json+="\"source\":\"$source\","
images_json+="\"size_bytes\":$size_bytes"
# Only add created_at if we have a valid date
if [[ "$created_iso" != "null" ]]; then
images_json+=",\"created_at\":\"$created_iso\""
fi
# Only add digest if present
if [[ -n "$digest" && "$digest" != "<none>" ]]; then
images_json+=",\"digest\":\"$digest\""
fi
images_json+="}"
done < <(docker images --format '{{.Repository}}|{{.Tag}}|{{.ID}}|{{.CreatedAt}}|{{.Size}}|{{.Digest}}' --no-trunc 2>/dev/null)
images_json+="]"
echo "$images_json"
}
# Check for image updates
check_image_updates() {
info "Checking for image updates..."
local updates_json="["
local first=true
local update_count=0
# Get all images
while IFS='|' read -r repository tag image_id digest; do
if [[ -z "$repository" || "$repository" == "<none>" || "$tag" == "<none>" ]]; then
continue
fi
# Skip checking 'latest' tag as it's always considered current by name
# We'll still check digest though
local full_image="${repository}:${tag}"
# Try to get remote digest from registry
# Use docker manifest inspect to avoid pulling the image
local remote_digest=$(docker manifest inspect "$full_image" 2>/dev/null | jq -r '.config.digest // .manifests[0].digest // empty' 2>/dev/null)
if [[ -z "$remote_digest" ]]; then
# If manifest inspect fails, try buildx imagetools inspect (works for more registries)
remote_digest=$(docker buildx imagetools inspect "$full_image" 2>/dev/null | grep -oP 'Digest:\s*\K\S+' | head -1)
fi
# Clean up digests for comparison
local local_digest="${digest#sha256:}"
remote_digest="${remote_digest#sha256:}"
# If we got a remote digest and it's different from local, there's an update
if [[ -n "$remote_digest" && -n "$local_digest" && "$remote_digest" != "$local_digest" ]]; then
if [[ "$first" == false ]]; then
updates_json+=","
fi
first=false
# Build update JSON object
updates_json+="{\"repository\":\"$repository\","
updates_json+="\"current_tag\":\"$tag\","
updates_json+="\"available_tag\":\"$tag\","
updates_json+="\"current_digest\":\"$local_digest\","
updates_json+="\"available_digest\":\"$remote_digest\","
updates_json+="\"image_id\":\"${image_id#sha256:}\""
updates_json+="}"
((update_count++))
fi
done < <(docker images --format '{{.Repository}}|{{.Tag}}|{{.ID}}|{{.Digest}}' --no-trunc 2>/dev/null)
updates_json+="]"
info "Found $update_count image update(s) available"
echo "$updates_json"
}
# Send Docker data to server
send_docker_data() {
load_credentials
info "Collecting Docker data..."
local containers=$(collect_containers)
local images=$(collect_images)
local updates=$(check_image_updates)
# Count collected items
local container_count=$(echo "$containers" | jq '. | length' 2>/dev/null || echo "0")
local image_count=$(echo "$images" | jq '. | length' 2>/dev/null || echo "0")
local update_count=$(echo "$updates" | jq '. | length' 2>/dev/null || echo "0")
info "Found $container_count containers, $image_count images, and $update_count update(s) available"
# Build payload
local payload="{\"apiId\":\"$API_ID\",\"apiKey\":\"$API_KEY\",\"containers\":$containers,\"images\":$images,\"updates\":$updates}"
# Send to server
info "Sending Docker data to PatchMon server..."
local response=$(curl $CURL_FLAGS -s -w "\n%{http_code}" -X POST \
-H "Content-Type: application/json" \
-d "$payload" \
"${PATCHMON_SERVER}/api/${API_VERSION}/docker/collect" 2>&1)
local http_code=$(echo "$response" | tail -n1)
local response_body=$(echo "$response" | head -n-1)
if [[ "$http_code" == "200" ]]; then
success "Docker data sent successfully!"
log "Docker data sent: $container_count containers, $image_count images"
return 0
else
error "Failed to send Docker data. HTTP Status: $http_code\nResponse: $response_body"
fi
}
# Test Docker data collection without sending
test_collection() {
check_docker
info "Testing Docker data collection (dry run)..."
echo ""
local containers=$(collect_containers)
local images=$(collect_images)
local updates=$(check_image_updates)
local container_count=$(echo "$containers" | jq '. | length' 2>/dev/null || echo "0")
local image_count=$(echo "$images" | jq '. | length' 2>/dev/null || echo "0")
local update_count=$(echo "$updates" | jq '. | length' 2>/dev/null || echo "0")
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -e "${GREEN}Docker Data Collection Results${NC}"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo -e "Containers found: ${GREEN}$container_count${NC}"
echo -e "Images found: ${GREEN}$image_count${NC}"
echo -e "Updates available: ${YELLOW}$update_count${NC}"
echo ""
if command -v jq &> /dev/null; then
echo "━━━ Containers ━━━"
echo "$containers" | jq -r '.[] | "\(.name) (\(.status)) - \(.image_name):\(.image_tag)"' | head -10
if [[ $container_count -gt 10 ]]; then
echo "... and $((container_count - 10)) more"
fi
echo ""
echo "━━━ Images ━━━"
echo "$images" | jq -r '.[] | "\(.repository):\(.tag) (\(.size_bytes / 1024 / 1024 | floor)MB)"' | head -10
if [[ $image_count -gt 10 ]]; then
echo "... and $((image_count - 10)) more"
fi
if [[ $update_count -gt 0 ]]; then
echo ""
echo "━━━ Available Updates ━━━"
echo "$updates" | jq -r '.[] | "\(.repository):\(.current_tag) → \(.available_tag)"'
fi
fi
echo ""
success "Test collection completed successfully!"
}
# Show help
show_help() {
cat << EOF
PatchMon Docker Agent v${AGENT_VERSION}
This agent collects Docker container and image information and sends it to PatchMon.
USAGE:
$0 <command>
COMMANDS:
collect Collect and send Docker data to PatchMon server
test Test Docker data collection without sending (dry run)
help Show this help message
REQUIREMENTS:
- Docker must be installed and running
- Main PatchMon agent must be configured first
- Credentials file must exist at $CREDENTIALS_FILE
EXAMPLES:
# Test collection (dry run)
sudo $0 test
# Collect and send Docker data
sudo $0 collect
SCHEDULING:
To run this agent automatically, add a cron job:
# Run every 5 minutes
*/5 * * * * /usr/local/bin/patchmon-docker-agent.sh collect
# Run every hour
0 * * * * /usr/local/bin/patchmon-docker-agent.sh collect
FILES:
Config: $CONFIG_FILE
Credentials: $CREDENTIALS_FILE
Log: $LOG_FILE
EOF
}
# Main function
main() {
case "$1" in
"collect")
check_docker
load_config
send_docker_data
;;
"test")
check_docker
load_config
test_collection
;;
"help"|"--help"|"-h"|"")
show_help
;;
*)
error "Unknown command: $1\n\nRun '$0 help' for usage information."
;;
esac
}
# Run main function
main "$@"

View File

@@ -97,13 +97,22 @@ verify_datetime
# Clean up old files (keep only last 3 of each type)
cleanup_old_files() {
# Clean up old credential backups
ls -t /etc/patchmon/credentials.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
ls -t /etc/patchmon/credentials.yml.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Clean up old config backups
ls -t /etc/patchmon/config.yml.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Clean up old agent backups
ls -t /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
ls -t /usr/local/bin/patchmon-agent.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Clean up old log files
ls -t /var/log/patchmon-agent.log.old.* 2>/dev/null | tail -n +4 | xargs -r rm -f
ls -t /etc/patchmon/logs/patchmon-agent.log.old.* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Clean up old shell script backups (if any exist)
ls -t /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Clean up old credentials backups (if any exist)
ls -t /etc/patchmon/credentials.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
}
# Run cleanup at start
@@ -127,6 +136,12 @@ if [[ -z "$PATCHMON_URL" ]] || [[ -z "$API_ID" ]] || [[ -z "$API_KEY" ]]; then
error "Missing required parameters. This script should be called via the PatchMon web interface."
fi
# Parse architecture parameter (default to amd64)
ARCHITECTURE="${ARCHITECTURE:-amd64}"
if [[ "$ARCHITECTURE" != "amd64" && "$ARCHITECTURE" != "386" && "$ARCHITECTURE" != "arm64" ]]; then
error "Invalid architecture '$ARCHITECTURE'. Must be one of: amd64, 386, arm64"
fi
# Check if --force flag is set (for bypassing broken packages)
FORCE_INSTALL="${FORCE_INSTALL:-false}"
if [[ "$*" == *"--force"* ]] || [[ "$FORCE_INSTALL" == "true" ]]; then
@@ -142,6 +157,7 @@ info "🚀 Starting PatchMon Agent Installation..."
info "📋 Server: $PATCHMON_URL"
info "🔑 API ID: ${API_ID:0:16}..."
info "🆔 Machine ID: ${MACHINE_ID:0:16}..."
info "🏗️ Architecture: $ARCHITECTURE"
# Display diagnostic information
echo ""
@@ -150,6 +166,7 @@ echo " • URL: $PATCHMON_URL"
echo " • CURL FLAGS: $CURL_FLAGS"
echo " • API ID: ${API_ID:0:16}..."
echo " • API Key: ${API_KEY:0:16}..."
echo " • Architecture: $ARCHITECTURE"
echo ""
# Install required dependencies
@@ -294,67 +311,118 @@ else
mkdir -p /etc/patchmon
fi
# Step 2: Create credentials file
info "🔐 Creating API credentials file..."
# Step 2: Create configuration files
info "🔐 Creating configuration files..."
# Check if config file already exists
if [[ -f "/etc/patchmon/config.yml" ]]; then
warning "⚠️ Config file already exists at /etc/patchmon/config.yml"
warning "⚠️ Moving existing file out of the way for fresh installation"
# Clean up old config backups (keep only last 3)
ls -t /etc/patchmon/config.yml.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Move existing file out of the way
mv /etc/patchmon/config.yml /etc/patchmon/config.yml.backup.$(date +%Y%m%d_%H%M%S)
info "📋 Moved existing config to: /etc/patchmon/config.yml.backup.$(date +%Y%m%d_%H%M%S)"
fi
# Check if credentials file already exists
if [[ -f "/etc/patchmon/credentials" ]]; then
warning "⚠️ Credentials file already exists at /etc/patchmon/credentials"
if [[ -f "/etc/patchmon/credentials.yml" ]]; then
warning "⚠️ Credentials file already exists at /etc/patchmon/credentials.yml"
warning "⚠️ Moving existing file out of the way for fresh installation"
# Clean up old credential backups (keep only last 3)
ls -t /etc/patchmon/credentials.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
ls -t /etc/patchmon/credentials.yml.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Move existing file out of the way
mv /etc/patchmon/credentials /etc/patchmon/credentials.backup.$(date +%Y%m%d_%H%M%S)
info "📋 Moved existing credentials to: /etc/patchmon/credentials.backup.$(date +%Y%m%d_%H%M%S)"
mv /etc/patchmon/credentials.yml /etc/patchmon/credentials.yml.backup.$(date +%Y%m%d_%H%M%S)
info "📋 Moved existing credentials to: /etc/patchmon/credentials.yml.backup.$(date +%Y%m%d_%H%M%S)"
fi
cat > /etc/patchmon/credentials << EOF
# Clean up old credentials file if it exists (from previous installations)
if [[ -f "/etc/patchmon/credentials" ]]; then
warning "⚠️ Found old credentials file, removing it..."
rm -f /etc/patchmon/credentials
info "📋 Removed old credentials file"
fi
# Create main config file
cat > /etc/patchmon/config.yml << EOF
# PatchMon Agent Configuration
# Generated on $(date)
patchmon_server: "$PATCHMON_URL"
api_version: "v1"
credentials_file: "/etc/patchmon/credentials.yml"
log_file: "/etc/patchmon/logs/patchmon-agent.log"
log_level: "info"
skip_ssl_verify: ${SKIP_SSL_VERIFY:-false}
EOF
# Create credentials file
cat > /etc/patchmon/credentials.yml << EOF
# PatchMon API Credentials
# Generated on $(date)
PATCHMON_URL="$PATCHMON_URL"
API_ID="$API_ID"
API_KEY="$API_KEY"
api_id: "$API_ID"
api_key: "$API_KEY"
EOF
chmod 600 /etc/patchmon/credentials
# Step 3: Download the agent script using API credentials
info "📥 Downloading PatchMon agent script..."
chmod 600 /etc/patchmon/config.yml
chmod 600 /etc/patchmon/credentials.yml
# Check if agent script already exists
if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then
warning "⚠️ Agent script already exists at /usr/local/bin/patchmon-agent.sh"
# Step 3: Download the PatchMon agent binary using API credentials
info "📥 Downloading PatchMon agent binary..."
# Determine the binary filename based on architecture
BINARY_NAME="patchmon-agent-linux-${ARCHITECTURE}"
# Check if agent binary already exists
if [[ -f "/usr/local/bin/patchmon-agent" ]]; then
warning "⚠️ Agent binary already exists at /usr/local/bin/patchmon-agent"
warning "⚠️ Moving existing file out of the way for fresh installation"
# Clean up old agent backups (keep only last 3)
ls -t /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
ls -t /usr/local/bin/patchmon-agent.backup.* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Move existing file out of the way
mv /usr/local/bin/patchmon-agent.sh /usr/local/bin/patchmon-agent.sh.backup.$(date +%Y%m%d_%H%M%S)
info "📋 Moved existing agent to: /usr/local/bin/patchmon-agent.sh.backup.$(date +%Y%m%d_%H%M%S)"
mv /usr/local/bin/patchmon-agent /usr/local/bin/patchmon-agent.backup.$(date +%Y%m%d_%H%M%S)
info "📋 Moved existing agent to: /usr/local/bin/patchmon-agent.backup.$(date +%Y%m%d_%H%M%S)"
fi
# Clean up old shell script if it exists (from previous installations)
if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then
warning "⚠️ Found old shell script agent, removing it..."
rm -f /usr/local/bin/patchmon-agent.sh
info "📋 Removed old shell script agent"
fi
# Download the binary
curl $CURL_FLAGS \
-H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \
"$PATCHMON_URL/api/v1/hosts/agent/download" \
-o /usr/local/bin/patchmon-agent.sh
"$PATCHMON_URL/api/v1/hosts/agent/download?arch=$ARCHITECTURE&force=binary" \
-o /usr/local/bin/patchmon-agent
chmod +x /usr/local/bin/patchmon-agent.sh
chmod +x /usr/local/bin/patchmon-agent
# Get the agent version from the downloaded script
AGENT_VERSION=$(grep '^AGENT_VERSION=' /usr/local/bin/patchmon-agent.sh | cut -d'"' -f2 2>/dev/null || echo "Unknown")
# Get the agent version from the binary
AGENT_VERSION=$(/usr/local/bin/patchmon-agent version 2>/dev/null || echo "Unknown")
info "📋 Agent version: $AGENT_VERSION"
# Handle existing log files and create log directory
info "📁 Setting up log directory..."
# Create log directory if it doesn't exist
mkdir -p /etc/patchmon/logs
# Handle existing log files
if [[ -f "/var/log/patchmon-agent.log" ]]; then
warning "⚠️ Existing log file found at /var/log/patchmon-agent.log"
if [[ -f "/etc/patchmon/logs/patchmon-agent.log" ]]; then
warning "⚠️ Existing log file found at /etc/patchmon/logs/patchmon-agent.log"
warning "⚠️ Rotating log file for fresh start"
# Rotate the log file
mv /var/log/patchmon-agent.log /var/log/patchmon-agent.log.old.$(date +%Y%m%d_%H%M%S)
info "📋 Log file rotated to: /var/log/patchmon-agent.log.old.$(date +%Y%m%d_%H%M%S)"
mv /etc/patchmon/logs/patchmon-agent.log /etc/patchmon/logs/patchmon-agent.log.old.$(date +%Y%m%d_%H%M%S)
info "📋 Log file rotated to: /etc/patchmon/logs/patchmon-agent.log.old.$(date +%Y%m%d_%H%M%S)"
fi
# Step 4: Test the configuration
@@ -386,19 +454,76 @@ if [[ "$http_code" == "200" ]]; then
fi
info "🧪 Testing API credentials and connectivity..."
if /usr/local/bin/patchmon-agent.sh test; then
if /usr/local/bin/patchmon-agent ping; then
success "✅ TEST: API credentials are valid and server is reachable"
else
error "❌ Failed to validate API credentials or reach server"
fi
# Step 5: Send initial data and setup automated updates
# Step 5: Send initial data and setup systemd service
info "📊 Sending initial package data to server..."
if /usr/local/bin/patchmon-agent.sh update; then
if /usr/local/bin/patchmon-agent report; then
success "✅ UPDATE: Initial package data sent successfully"
info "✅ Automated updates configured by agent"
else
warning "⚠️ Failed to send initial data. You can retry later with: /usr/local/bin/patchmon-agent.sh update"
warning "⚠️ Failed to send initial data. You can retry later with: /usr/local/bin/patchmon-agent report"
fi
# Step 6: Setup systemd service for WebSocket connection
info "🔧 Setting up systemd service..."
# Stop and disable existing service if it exists
if systemctl is-active --quiet patchmon-agent.service 2>/dev/null; then
warning "⚠️ Stopping existing PatchMon agent service..."
systemctl stop patchmon-agent.service
fi
if systemctl is-enabled --quiet patchmon-agent.service 2>/dev/null; then
warning "⚠️ Disabling existing PatchMon agent service..."
systemctl disable patchmon-agent.service
fi
# Create systemd service file
cat > /etc/systemd/system/patchmon-agent.service << EOF
[Unit]
Description=PatchMon Agent Service
After=network.target
Wants=network.target
[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/patchmon-agent serve
Restart=always
RestartSec=10
WorkingDirectory=/etc/patchmon
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=patchmon-agent
[Install]
WantedBy=multi-user.target
EOF
# Clean up old crontab entries if they exist (from previous installations)
if crontab -l 2>/dev/null | grep -q "patchmon-agent"; then
warning "⚠️ Found old crontab entries, removing them..."
crontab -l 2>/dev/null | grep -v "patchmon-agent" | crontab -
info "📋 Removed old crontab entries"
fi
# Reload systemd and enable/start the service
systemctl daemon-reload
systemctl enable patchmon-agent.service
systemctl start patchmon-agent.service
# Check if service started successfully
if systemctl is-active --quiet patchmon-agent.service; then
success "✅ PatchMon Agent service started successfully"
info "🔗 WebSocket connection established"
else
warning "⚠️ Service may have failed to start. Check status with: systemctl status patchmon-agent"
fi
# Installation complete
@@ -406,14 +531,16 @@ success "🎉 PatchMon Agent installation completed successfully!"
echo ""
echo -e "${GREEN}📋 Installation Summary:${NC}"
echo " • Configuration directory: /etc/patchmon"
echo " • Agent installed: /usr/local/bin/patchmon-agent.sh"
echo " • Agent binary installed: /usr/local/bin/patchmon-agent"
echo " • Architecture: $ARCHITECTURE"
echo " • Dependencies installed: jq, curl, bc"
echo " • Automated updates configured via crontab"
echo " • Systemd service configured and running"
echo " • API credentials configured and tested"
echo " • Update schedule managed by agent"
echo " • WebSocket connection established"
echo " • Logs directory: /etc/patchmon/logs"
# Check for moved files and show them
MOVED_FILES=$(ls /etc/patchmon/credentials.backup.* /usr/local/bin/patchmon-agent.sh.backup.* /var/log/patchmon-agent.log.old.* 2>/dev/null || true)
MOVED_FILES=$(ls /etc/patchmon/credentials.yml.backup.* /etc/patchmon/config.yml.backup.* /usr/local/bin/patchmon-agent.backup.* /etc/patchmon/logs/patchmon-agent.log.old.* /usr/local/bin/patchmon-agent.sh.backup.* /etc/patchmon/credentials.backup.* 2>/dev/null || true)
if [[ -n "$MOVED_FILES" ]]; then
echo ""
echo -e "${YELLOW}📋 Files Moved for Fresh Installation:${NC}"
@@ -426,8 +553,11 @@ fi
echo ""
echo -e "${BLUE}🔧 Management Commands:${NC}"
echo " • Test connection: /usr/local/bin/patchmon-agent.sh test"
echo " • Manual update: /usr/local/bin/patchmon-agent.sh update"
echo " • Check status: /usr/local/bin/patchmon-agent.sh diagnostics"
echo " • Test connection: /usr/local/bin/patchmon-agent ping"
echo " • Manual report: /usr/local/bin/patchmon-agent report"
echo " • Check status: /usr/local/bin/patchmon-agent diagnostics"
echo " • Service status: systemctl status patchmon-agent"
echo " • Service logs: journalctl -u patchmon-agent -f"
echo " • Restart service: systemctl restart patchmon-agent"
echo ""
success "✅ Your system is now being monitored by PatchMon!"

View File

@@ -153,6 +153,32 @@ while IFS= read -r line; do
ip_address=$(timeout 5 pct exec "$vmid" -- hostname -I 2>/dev/null </dev/null | awk '{print $1}' || echo "unknown")
os_info=$(timeout 5 pct exec "$vmid" -- cat /etc/os-release 2>/dev/null </dev/null | grep "^PRETTY_NAME=" | cut -d'"' -f2 || echo "unknown")
# Detect container architecture
debug " Detecting container architecture..."
arch_raw=$(timeout 5 pct exec "$vmid" -- uname -m 2>/dev/null </dev/null || echo "unknown")
# Map architecture to supported values
case "$arch_raw" in
"x86_64")
architecture="amd64"
;;
"i386"|"i686")
architecture="386"
;;
"aarch64"|"arm64")
architecture="arm64"
;;
"armv7l"|"armv6l"|"arm")
architecture="arm"
;;
*)
warn " ⚠ Unknown architecture '$arch_raw', defaulting to amd64"
architecture="amd64"
;;
esac
debug " Detected architecture: $arch_raw -> $architecture"
# Get machine ID from container
machine_id=$(timeout 5 pct exec "$vmid" -- bash -c "cat /etc/machine-id 2>/dev/null || cat /var/lib/dbus/machine-id 2>/dev/null || echo 'proxmox-lxc-$vmid-'$(cat /proc/sys/kernel/random/uuid)" </dev/null 2>/dev/null || echo "proxmox-lxc-$vmid-unknown")
@@ -161,6 +187,7 @@ while IFS= read -r line; do
info " Hostname: $hostname"
info " IP Address: $ip_address"
info " OS: $os_info"
info " Architecture: $architecture ($arch_raw)"
info " Machine ID: ${machine_id:0:16}..."
if [[ "$DRY_RUN" == "true" ]]; then
@@ -244,12 +271,13 @@ while IFS= read -r line; do
# Install PatchMon agent in container
info " Installing PatchMon agent..."
# Build install URL with force flag if enabled
install_url="$PATCHMON_URL/api/v1/hosts/install"
# Build install URL with force flag and architecture if enabled
install_url="$PATCHMON_URL/api/v1/hosts/install?arch=$architecture"
if [[ "$FORCE_INSTALL" == "true" ]]; then
install_url="$install_url?force=true"
install_url="$install_url&force=true"
info " Using force mode - will bypass broken packages"
fi
info " Using architecture: $architecture"
# Reset exit code for this container
install_exit_code=0
@@ -400,7 +428,7 @@ if [[ ${#dpkg_error_containers[@]} -gt 0 ]]; then
-H \"X-API-ID: $api_id\" \
-H \"X-API-KEY: $api_key\" \
-o patchmon-install.sh \
'$PATCHMON_URL/api/v1/hosts/install' && \
'$PATCHMON_URL/api/v1/hosts/install?arch=$architecture' && \
bash patchmon-install.sh && \
rm -f patchmon-install.sh
" 2>&1 </dev/null) || install_exit_code=$?

View File

@@ -1,16 +1,36 @@
# Database Configuration
DATABASE_URL="postgresql://patchmon_user:p@tchm0n_p@55@localhost:5432/patchmon_db"
DATABASE_URL="postgresql://patchmon_user:your-password-here@localhost:5432/patchmon_db"
PM_DB_CONN_MAX_ATTEMPTS=30
PM_DB_CONN_WAIT_INTERVAL=2
# Database Connection Pool Configuration (Prisma)
DB_CONNECTION_LIMIT=30 # Maximum connections per instance (default: 30)
DB_POOL_TIMEOUT=20 # Seconds to wait for available connection (default: 20)
DB_CONNECT_TIMEOUT=10 # Seconds to wait for initial connection (default: 10)
DB_IDLE_TIMEOUT=300 # Seconds before closing idle connections (default: 300)
DB_MAX_LIFETIME=1800 # Maximum lifetime of a connection in seconds (default: 1800)
# JWT Configuration
JWT_SECRET=your-secure-random-secret-key-change-this-in-production
JWT_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=7d
# Server Configuration
PORT=3001
NODE_ENV=development
NODE_ENV=production
# API Configuration
API_VERSION=v1
# CORS Configuration
CORS_ORIGIN=http://localhost:3000
# Session Configuration
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
# User Configuration
DEFAULT_USER_ROLE=user
# Rate Limiting (times in milliseconds)
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX=5000
@@ -19,15 +39,18 @@ AUTH_RATE_LIMIT_MAX=500
AGENT_RATE_LIMIT_WINDOW_MS=60000
AGENT_RATE_LIMIT_MAX=1000
# Redis Configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_USER=your-redis-username-here
REDIS_PASSWORD=your-redis-password-here
REDIS_DB=0
# Logging
LOG_LEVEL=info
ENABLE_LOGGING=true
# User Registration
DEFAULT_USER_ROLE=user
# JWT Configuration
JWT_SECRET=your-secure-random-secret-key-change-this-in-production
JWT_EXPIRES_IN=1h
JWT_REFRESH_EXPIRES_IN=7d
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
# TFA Configuration (optional - used if TFA is enabled)
TFA_REMEMBER_ME_EXPIRES_IN=30d
TFA_MAX_REMEMBER_SESSIONS=5
TFA_SUSPICIOUS_ACTIVITY_THRESHOLD=3

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon-backend",
"version": "1.2.7",
"version": "1.3.1",
"description": "Backend API for Linux Patch Monitoring System",
"license": "AGPL-3.0",
"main": "src/server.js",
@@ -14,20 +14,27 @@
"db:studio": "prisma studio"
},
"dependencies": {
"@bull-board/api": "^6.13.1",
"@bull-board/express": "^6.13.1",
"@prisma/client": "^6.1.0",
"axios": "^1.7.9",
"bcryptjs": "^2.4.3",
"bullmq": "^5.61.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-validator": "^7.2.0",
"helmet": "^8.0.0",
"ioredis": "^5.8.1",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"qrcode": "^1.5.4",
"speakeasy": "^2.0.0",
"uuid": "^11.0.3",
"winston": "^3.17.0"
"winston": "^3.17.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",

View File

@@ -0,0 +1,64 @@
-- Reconcile user_sessions migration from 1.2.7 to 1.2.8+
-- This migration handles the case where 1.2.7 had 'add_user_sessions' without timestamp
-- and 1.2.8+ renamed it to '20251005000000_add_user_sessions' with timestamp
DO $$
DECLARE
table_exists boolean := false;
migration_exists boolean := false;
BEGIN
-- Check if user_sessions table exists
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'user_sessions'
) INTO table_exists;
-- Check if the migration record already exists
SELECT EXISTS (
SELECT 1 FROM _prisma_migrations
WHERE migration_name = '20251005000000_add_user_sessions'
) INTO migration_exists;
-- If table exists but no migration record, create one
IF table_exists AND NOT migration_exists THEN
RAISE NOTICE 'Table exists but no migration record found - creating migration record for 1.2.7 upgrade';
-- Insert a successful migration record for the existing table
INSERT INTO _prisma_migrations (
id,
checksum,
finished_at,
migration_name,
logs,
rolled_back_at,
started_at,
applied_steps_count
) VALUES (
gen_random_uuid()::text,
'', -- Empty checksum since we're reconciling
NOW(),
'20251005000000_add_user_sessions',
'Reconciled from 1.2.7 - table already exists',
NULL,
NOW(),
1
);
RAISE NOTICE 'Migration record created for existing table';
ELSIF table_exists AND migration_exists THEN
RAISE NOTICE 'Table exists and migration record exists - no action needed';
ELSE
RAISE NOTICE 'Table does not exist - migration will proceed normally';
END IF;
-- Additional check: If we have any old migration names, update them
IF EXISTS (SELECT 1 FROM _prisma_migrations WHERE migration_name = 'add_user_sessions') THEN
RAISE NOTICE 'Found old migration name - updating to new format';
UPDATE _prisma_migrations
SET migration_name = '20251005000000_add_user_sessions'
WHERE migration_name = 'add_user_sessions';
RAISE NOTICE 'Old migration name updated';
END IF;
END $$;

View File

@@ -0,0 +1,96 @@
-- Reconcile user_sessions migration from 1.2.7 to 1.2.8+
-- This migration handles the case where 1.2.7 had 'add_user_sessions' without timestamp
-- and 1.2.8+ renamed it to '20251005000000_add_user_sessions' with timestamp
DO $$
DECLARE
old_migration_exists boolean := false;
table_exists boolean := false;
failed_migration_exists boolean := false;
BEGIN
-- Check if the old migration name exists
SELECT EXISTS (
SELECT 1 FROM _prisma_migrations
WHERE migration_name = 'add_user_sessions'
) INTO old_migration_exists;
-- Check if user_sessions table exists
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'user_sessions'
) INTO table_exists;
-- Check if there's a failed migration attempt
SELECT EXISTS (
SELECT 1 FROM _prisma_migrations
WHERE migration_name = '20251005000000_add_user_sessions'
AND finished_at IS NULL
) INTO failed_migration_exists;
-- Scenario 1: Old migration exists, table exists, no failed migration
-- This means 1.2.7 was installed and we need to update the migration name
IF old_migration_exists AND table_exists AND NOT failed_migration_exists THEN
RAISE NOTICE 'Found 1.2.7 migration "add_user_sessions" - updating to timestamped version';
-- Update the old migration name to the new timestamped version
UPDATE _prisma_migrations
SET migration_name = '20251005000000_add_user_sessions'
WHERE migration_name = 'add_user_sessions';
RAISE NOTICE 'Migration name updated: add_user_sessions -> 20251005000000_add_user_sessions';
END IF;
-- Scenario 2: Failed migration exists (upgrade attempt gone wrong)
IF failed_migration_exists THEN
RAISE NOTICE 'Found failed migration attempt - cleaning up';
-- If table exists, it means the migration partially succeeded
IF table_exists THEN
RAISE NOTICE 'Table exists - marking migration as applied';
-- Delete the failed migration record
DELETE FROM _prisma_migrations
WHERE migration_name = '20251005000000_add_user_sessions'
AND finished_at IS NULL;
-- Insert a successful migration record
INSERT INTO _prisma_migrations (
id,
checksum,
finished_at,
migration_name,
logs,
rolled_back_at,
started_at,
applied_steps_count
) VALUES (
gen_random_uuid()::text,
'', -- Empty checksum since we're reconciling
NOW(),
'20251005000000_add_user_sessions',
NULL,
NULL,
NOW(),
1
);
RAISE NOTICE 'Migration marked as successfully applied';
ELSE
RAISE NOTICE 'Table does not exist - removing failed migration to allow retry';
-- Just delete the failed migration to allow it to retry
DELETE FROM _prisma_migrations
WHERE migration_name = '20251005000000_add_user_sessions'
AND finished_at IS NULL;
RAISE NOTICE 'Failed migration removed - will retry on next migration run';
END IF;
END IF;
-- Scenario 3: Everything is clean (fresh install or already reconciled)
IF NOT old_migration_exists AND NOT failed_migration_exists THEN
RAISE NOTICE 'No migration reconciliation needed';
END IF;
END $$;

View File

@@ -0,0 +1,106 @@
-- CreateTable (with existence check for 1.2.7 compatibility)
DO $$
BEGIN
-- Check if table already exists (from 1.2.7 installation)
IF NOT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'user_sessions'
) THEN
-- Table doesn't exist, create it
CREATE TABLE "user_sessions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"refresh_token" TEXT NOT NULL,
"access_token_hash" TEXT,
"ip_address" TEXT,
"user_agent" TEXT,
"last_activity" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"is_revoked" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id")
);
RAISE NOTICE 'Created user_sessions table';
ELSE
RAISE NOTICE 'user_sessions table already exists, skipping creation';
END IF;
END $$;
-- CreateIndex (with existence check)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'user_sessions'
AND indexname = 'user_sessions_refresh_token_key'
) THEN
CREATE UNIQUE INDEX "user_sessions_refresh_token_key" ON "user_sessions"("refresh_token");
RAISE NOTICE 'Created user_sessions_refresh_token_key index';
ELSE
RAISE NOTICE 'user_sessions_refresh_token_key index already exists, skipping';
END IF;
END $$;
-- CreateIndex (with existence check)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'user_sessions'
AND indexname = 'user_sessions_user_id_idx'
) THEN
CREATE INDEX "user_sessions_user_id_idx" ON "user_sessions"("user_id");
RAISE NOTICE 'Created user_sessions_user_id_idx index';
ELSE
RAISE NOTICE 'user_sessions_user_id_idx index already exists, skipping';
END IF;
END $$;
-- CreateIndex (with existence check)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'user_sessions'
AND indexname = 'user_sessions_refresh_token_idx'
) THEN
CREATE INDEX "user_sessions_refresh_token_idx" ON "user_sessions"("refresh_token");
RAISE NOTICE 'Created user_sessions_refresh_token_idx index';
ELSE
RAISE NOTICE 'user_sessions_refresh_token_idx index already exists, skipping';
END IF;
END $$;
-- CreateIndex (with existence check)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_indexes
WHERE tablename = 'user_sessions'
AND indexname = 'user_sessions_expires_at_idx'
) THEN
CREATE INDEX "user_sessions_expires_at_idx" ON "user_sessions"("expires_at");
RAISE NOTICE 'Created user_sessions_expires_at_idx index';
ELSE
RAISE NOTICE 'user_sessions_expires_at_idx index already exists, skipping';
END IF;
END $$;
-- AddForeignKey (with existence check)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.table_constraints
WHERE table_name = 'user_sessions'
AND constraint_name = 'user_sessions_user_id_fkey'
) THEN
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
RAISE NOTICE 'Created user_sessions_user_id_fkey foreign key';
ELSE
RAISE NOTICE 'user_sessions_user_id_fkey foreign key already exists, skipping';
END IF;
END $$;

View File

@@ -0,0 +1,6 @@
-- Add TFA remember me fields to user_sessions table
ALTER TABLE "user_sessions" ADD COLUMN "tfa_remember_me" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "user_sessions" ADD COLUMN "tfa_bypass_until" TIMESTAMP(3);
-- Create index for TFA bypass until field for efficient querying
CREATE INDEX "user_sessions_tfa_bypass_until_idx" ON "user_sessions"("tfa_bypass_until");

View File

@@ -0,0 +1,7 @@
-- Add security fields to user_sessions table for production-ready remember me
ALTER TABLE "user_sessions" ADD COLUMN "device_fingerprint" TEXT;
ALTER TABLE "user_sessions" ADD COLUMN "login_count" INTEGER NOT NULL DEFAULT 1;
ALTER TABLE "user_sessions" ADD COLUMN "last_login_ip" TEXT;
-- Create index for device fingerprint for efficient querying
CREATE INDEX "user_sessions_device_fingerprint_idx" ON "user_sessions"("device_fingerprint");

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "update_history" ADD COLUMN "total_packages" INTEGER;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "update_history" ADD COLUMN "payload_size_kb" DOUBLE PRECISION;
ALTER TABLE "update_history" ADD COLUMN "execution_time" DOUBLE PRECISION;

View File

@@ -0,0 +1,30 @@
-- Add indexes to host_packages table for performance optimization
-- These indexes will dramatically speed up queries filtering by host_id, package_id, needs_update, and is_security_update
-- Index for queries filtering by host_id (very common - used when viewing packages for a specific host)
CREATE INDEX IF NOT EXISTS "host_packages_host_id_idx" ON "host_packages"("host_id");
-- Index for queries filtering by package_id (used when finding hosts for a specific package)
CREATE INDEX IF NOT EXISTS "host_packages_package_id_idx" ON "host_packages"("package_id");
-- Index for queries filtering by needs_update (used when finding outdated packages)
CREATE INDEX IF NOT EXISTS "host_packages_needs_update_idx" ON "host_packages"("needs_update");
-- Index for queries filtering by is_security_update (used when finding security updates)
CREATE INDEX IF NOT EXISTS "host_packages_is_security_update_idx" ON "host_packages"("is_security_update");
-- Composite index for the most common query pattern: host_id + needs_update
-- This is optimal for "show me outdated packages for this host"
CREATE INDEX IF NOT EXISTS "host_packages_host_id_needs_update_idx" ON "host_packages"("host_id", "needs_update");
-- Composite index for host_id + needs_update + is_security_update
-- This is optimal for "show me security updates for this host"
CREATE INDEX IF NOT EXISTS "host_packages_host_id_needs_update_security_idx" ON "host_packages"("host_id", "needs_update", "is_security_update");
-- Index for queries filtering by package_id + needs_update
-- This is optimal for "show me hosts where this package needs updates"
CREATE INDEX IF NOT EXISTS "host_packages_package_id_needs_update_idx" ON "host_packages"("package_id", "needs_update");
-- Index on last_checked for cleanup/maintenance queries
CREATE INDEX IF NOT EXISTS "host_packages_last_checked_idx" ON "host_packages"("last_checked");

View File

@@ -0,0 +1,94 @@
-- CreateTable
CREATE TABLE "docker_images" (
"id" TEXT NOT NULL,
"repository" TEXT NOT NULL,
"tag" TEXT NOT NULL DEFAULT 'latest',
"image_id" TEXT NOT NULL,
"digest" TEXT,
"size_bytes" BIGINT,
"source" TEXT NOT NULL DEFAULT 'docker-hub',
"created_at" TIMESTAMP(3) NOT NULL,
"last_pulled" TIMESTAMP(3),
"last_checked" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "docker_images_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "docker_containers" (
"id" TEXT NOT NULL,
"host_id" TEXT NOT NULL,
"container_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"image_id" TEXT,
"image_name" TEXT NOT NULL,
"image_tag" TEXT NOT NULL DEFAULT 'latest',
"status" TEXT NOT NULL,
"state" TEXT,
"ports" JSONB,
"created_at" TIMESTAMP(3) NOT NULL,
"started_at" TIMESTAMP(3),
"updated_at" TIMESTAMP(3) NOT NULL,
"last_checked" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "docker_containers_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "docker_image_updates" (
"id" TEXT NOT NULL,
"image_id" TEXT NOT NULL,
"current_tag" TEXT NOT NULL,
"available_tag" TEXT NOT NULL,
"is_security_update" BOOLEAN NOT NULL DEFAULT false,
"severity" TEXT,
"changelog_url" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "docker_image_updates_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "docker_images_repository_idx" ON "docker_images"("repository");
-- CreateIndex
CREATE INDEX "docker_images_source_idx" ON "docker_images"("source");
-- CreateIndex
CREATE INDEX "docker_images_repository_tag_idx" ON "docker_images"("repository", "tag");
-- CreateIndex
CREATE UNIQUE INDEX "docker_images_repository_tag_image_id_key" ON "docker_images"("repository", "tag", "image_id");
-- CreateIndex
CREATE INDEX "docker_containers_host_id_idx" ON "docker_containers"("host_id");
-- CreateIndex
CREATE INDEX "docker_containers_image_id_idx" ON "docker_containers"("image_id");
-- CreateIndex
CREATE INDEX "docker_containers_status_idx" ON "docker_containers"("status");
-- CreateIndex
CREATE INDEX "docker_containers_name_idx" ON "docker_containers"("name");
-- CreateIndex
CREATE UNIQUE INDEX "docker_containers_host_id_container_id_key" ON "docker_containers"("host_id", "container_id");
-- CreateIndex
CREATE INDEX "docker_image_updates_image_id_idx" ON "docker_image_updates"("image_id");
-- CreateIndex
CREATE INDEX "docker_image_updates_is_security_update_idx" ON "docker_image_updates"("is_security_update");
-- CreateIndex
CREATE UNIQUE INDEX "docker_image_updates_image_id_available_tag_key" ON "docker_image_updates"("image_id", "available_tag");
-- AddForeignKey
ALTER TABLE "docker_containers" ADD CONSTRAINT "docker_containers_image_id_fkey" FOREIGN KEY ("image_id") REFERENCES "docker_images"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "docker_image_updates" ADD CONSTRAINT "docker_image_updates_image_id_fkey" FOREIGN KEY ("image_id") REFERENCES "docker_images"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,40 @@
-- CreateTable
CREATE TABLE "job_history" (
"id" TEXT NOT NULL,
"job_id" TEXT NOT NULL,
"queue_name" TEXT NOT NULL,
"job_name" TEXT NOT NULL,
"host_id" TEXT,
"api_id" TEXT,
"status" TEXT NOT NULL,
"attempt_number" INTEGER NOT NULL DEFAULT 1,
"error_message" TEXT,
"output" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"completed_at" TIMESTAMP(3),
CONSTRAINT "job_history_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "job_history_job_id_idx" ON "job_history"("job_id");
-- CreateIndex
CREATE INDEX "job_history_queue_name_idx" ON "job_history"("queue_name");
-- CreateIndex
CREATE INDEX "job_history_host_id_idx" ON "job_history"("host_id");
-- CreateIndex
CREATE INDEX "job_history_api_id_idx" ON "job_history"("api_id");
-- CreateIndex
CREATE INDEX "job_history_status_idx" ON "job_history"("status");
-- CreateIndex
CREATE INDEX "job_history_created_at_idx" ON "job_history"("created_at");
-- AddForeignKey
ALTER TABLE "job_history" ADD CONSTRAINT "job_history_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,43 @@
-- CreateTable
CREATE TABLE "host_group_memberships" (
"id" TEXT NOT NULL,
"host_id" TEXT NOT NULL,
"host_group_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "host_group_memberships_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "host_group_memberships_host_id_host_group_id_key" ON "host_group_memberships"("host_id", "host_group_id");
-- CreateIndex
CREATE INDEX "host_group_memberships_host_id_idx" ON "host_group_memberships"("host_id");
-- CreateIndex
CREATE INDEX "host_group_memberships_host_group_id_idx" ON "host_group_memberships"("host_group_id");
-- Migrate existing data from hosts.host_group_id to host_group_memberships
INSERT INTO "host_group_memberships" ("id", "host_id", "host_group_id", "created_at")
SELECT
gen_random_uuid()::text as "id",
"id" as "host_id",
"host_group_id" as "host_group_id",
CURRENT_TIMESTAMP as "created_at"
FROM "hosts"
WHERE "host_group_id" IS NOT NULL;
-- AddForeignKey
ALTER TABLE "host_group_memberships" ADD CONSTRAINT "host_group_memberships_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "host_group_memberships" ADD CONSTRAINT "host_group_memberships_host_group_id_fkey" FOREIGN KEY ("host_group_id") REFERENCES "host_groups"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- DropForeignKey
ALTER TABLE "hosts" DROP CONSTRAINT IF EXISTS "hosts_host_group_id_fkey";
-- DropIndex
DROP INDEX IF EXISTS "hosts_host_group_id_idx";
-- AlterTable
ALTER TABLE "hosts" DROP COLUMN "host_group_id";

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

@@ -1,31 +0,0 @@
-- CreateTable
CREATE TABLE "user_sessions" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"refresh_token" TEXT NOT NULL,
"access_token_hash" TEXT,
"ip_address" TEXT,
"user_agent" TEXT,
"last_activity" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"is_revoked" BOOLEAN NOT NULL DEFAULT false,
CONSTRAINT "user_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_sessions_refresh_token_key" ON "user_sessions"("refresh_token");
-- CreateIndex
CREATE INDEX "user_sessions_user_id_idx" ON "user_sessions"("user_id");
-- CreateIndex
CREATE INDEX "user_sessions_refresh_token_idx" ON "user_sessions"("refresh_token");
-- CreateIndex
CREATE INDEX "user_sessions_expires_at_idx" ON "user_sessions"("expires_at");
-- AddForeignKey
ALTER TABLE "user_sessions" ADD CONSTRAINT "user_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -27,10 +27,23 @@ model host_groups {
color String? @default("#3B82F6")
created_at DateTime @default(now())
updated_at DateTime
hosts hosts[]
host_group_memberships host_group_memberships[]
auto_enrollment_tokens auto_enrollment_tokens[]
}
model host_group_memberships {
id String @id
host_id String
host_group_id String
created_at DateTime @default(now())
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
host_groups host_groups @relation(fields: [host_group_id], references: [id], onDelete: Cascade)
@@unique([host_id, host_group_id])
@@index([host_id])
@@index([host_group_id])
}
model host_packages {
id String @id
host_id String
@@ -44,6 +57,14 @@ model host_packages {
packages packages @relation(fields: [package_id], references: [id], onDelete: Cascade)
@@unique([host_id, package_id])
@@index([host_id])
@@index([package_id])
@@index([needs_update])
@@index([is_security_update])
@@index([host_id, needs_update])
@@index([host_id, needs_update, is_security_update])
@@index([package_id, needs_update])
@@index([last_checked])
}
model host_repositories {
@@ -59,40 +80,40 @@ model host_repositories {
}
model hosts {
id String @id
machine_id String @unique
friendly_name String
ip String?
os_type String
os_version String
architecture String?
last_update DateTime @default(now())
status String @default("active")
created_at DateTime @default(now())
updated_at DateTime
api_id String @unique
api_key String @unique
host_group_id String?
agent_version String?
auto_update Boolean @default(true)
cpu_cores Int?
cpu_model String?
disk_details Json?
dns_servers Json?
gateway_ip String?
hostname String?
kernel_version String?
load_average Json?
network_interfaces Json?
ram_installed Int?
selinux_status String?
swap_size Int?
system_uptime String?
notes String?
host_packages host_packages[]
host_repositories host_repositories[]
host_groups host_groups? @relation(fields: [host_group_id], references: [id])
update_history update_history[]
id String @id
machine_id String @unique
friendly_name String
ip String?
os_type String
os_version String
architecture String?
last_update DateTime @default(now())
status String @default("active")
created_at DateTime @default(now())
updated_at DateTime
api_id String @unique
api_key String @unique
agent_version String?
auto_update Boolean @default(true)
cpu_cores Int?
cpu_model String?
disk_details Json?
dns_servers Json?
gateway_ip String?
hostname String?
kernel_version String?
load_average Json?
network_interfaces Json?
ram_installed Int?
selinux_status String?
swap_size Int?
system_uptime String?
notes String?
host_packages host_packages[]
host_repositories host_repositories[]
host_group_memberships host_group_memberships[]
update_history update_history[]
job_history job_history[]
@@index([machine_id])
@@index([friendly_name])
@@ -108,6 +129,9 @@ model packages {
created_at DateTime @default(now())
updated_at DateTime
host_packages host_packages[]
@@index([name])
@@index([category])
}
model repositories {
@@ -146,38 +170,45 @@ model role_permissions {
}
model settings {
id String @id
server_url String @default("http://localhost:3001")
server_protocol String @default("http")
server_host String @default("localhost")
server_port Int @default(3001)
created_at DateTime @default(now())
updated_at DateTime
update_interval Int @default(60)
auto_update Boolean @default(false)
github_repo_url String @default("git@github.com:9technologygroup/patchmon.net.git")
ssh_key_path String?
repository_type String @default("public")
last_update_check DateTime?
latest_version String?
update_available Boolean @default(false)
signup_enabled Boolean @default(false)
default_user_role String @default("user")
ignore_ssl_self_signed Boolean @default(false)
logo_dark String? @default("/assets/logo_dark.png")
logo_light String? @default("/assets/logo_light.png")
favicon String? @default("/assets/logo_square.svg")
id String @id
server_url String @default("http://localhost:3001")
server_protocol String @default("http")
server_host String @default("localhost")
server_port Int @default(3001)
created_at DateTime @default(now())
updated_at DateTime
update_interval Int @default(60)
auto_update Boolean @default(false)
github_repo_url String @default("https://github.com/PatchMon/PatchMon.git")
ssh_key_path String?
repository_type String @default("public")
last_update_check DateTime?
latest_version String?
update_available Boolean @default(false)
signup_enabled Boolean @default(false)
default_user_role String @default("user")
ignore_ssl_self_signed Boolean @default(false)
logo_dark String? @default("/assets/logo_dark.png")
logo_light String? @default("/assets/logo_light.png")
favicon String? @default("/assets/logo_square.svg")
metrics_enabled Boolean @default(true)
metrics_anonymous_id String?
metrics_last_sent DateTime?
color_theme String @default("default")
}
model update_history {
id String @id
host_id String
packages_count Int
security_count Int
timestamp DateTime @default(now())
status String @default("success")
error_message String?
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
id String @id
host_id String
packages_count Int
security_count Int
total_packages Int?
payload_size_kb Float?
execution_time Float?
timestamp DateTime @default(now())
status String @default("success")
error_message String?
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
}
model users {
@@ -207,15 +238,22 @@ model user_sessions {
access_token_hash String?
ip_address String?
user_agent String?
device_fingerprint String?
last_activity DateTime @default(now())
expires_at DateTime
created_at DateTime @default(now())
is_revoked Boolean @default(false)
tfa_remember_me Boolean @default(false)
tfa_bypass_until DateTime?
login_count Int @default(1)
last_login_ip String?
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id])
@@index([refresh_token])
@@index([expires_at])
@@index([tfa_bypass_until])
@@index([device_fingerprint])
}
model auto_enrollment_tokens {
@@ -241,3 +279,89 @@ model auto_enrollment_tokens {
@@index([token_key])
@@index([is_active])
}
model docker_containers {
id String @id
host_id String
container_id String
name String
image_id String?
image_name String
image_tag String @default("latest")
status String
state String?
ports Json?
created_at DateTime
started_at DateTime?
updated_at DateTime
last_checked DateTime @default(now())
docker_images docker_images? @relation(fields: [image_id], references: [id], onDelete: SetNull)
@@unique([host_id, container_id])
@@index([host_id])
@@index([image_id])
@@index([status])
@@index([name])
}
model docker_images {
id String @id
repository String
tag String @default("latest")
image_id String
digest String?
size_bytes BigInt?
source String @default("docker-hub")
created_at DateTime
last_pulled DateTime?
last_checked DateTime @default(now())
updated_at DateTime
docker_containers docker_containers[]
docker_image_updates docker_image_updates[]
@@unique([repository, tag, image_id])
@@index([repository])
@@index([source])
@@index([repository, tag])
}
model docker_image_updates {
id String @id
image_id String
current_tag String
available_tag String
is_security_update Boolean @default(false)
severity String?
changelog_url String?
created_at DateTime @default(now())
updated_at DateTime
docker_images docker_images @relation(fields: [image_id], references: [id], onDelete: Cascade)
@@unique([image_id, available_tag])
@@index([image_id])
@@index([is_security_update])
}
model job_history {
id String @id
job_id String
queue_name String
job_name String
host_id String?
api_id String?
status String
attempt_number Int @default(1)
error_message String?
output Json?
created_at DateTime @default(now())
updated_at DateTime
completed_at DateTime?
hosts hosts? @relation(fields: [host_id], references: [id], onDelete: SetNull)
@@index([job_id])
@@index([queue_name])
@@index([host_id])
@@index([api_id])
@@index([status])
@@index([created_at])
}

View File

@@ -1,6 +1,6 @@
/**
* Database configuration for multiple instances
* Optimizes connection pooling to prevent "too many connections" errors
* Centralized Prisma Client Singleton
* Prevents multiple Prisma clients from creating connection leaks
*/
const { PrismaClient } = require("@prisma/client");
@@ -16,32 +16,69 @@ function getOptimizedDatabaseUrl() {
// Parse the URL
const url = new URL(originalUrl);
// Add connection pooling parameters for multiple instances
url.searchParams.set("connection_limit", "5"); // Reduced from default 10
url.searchParams.set("pool_timeout", "10"); // 10 seconds
url.searchParams.set("connect_timeout", "10"); // 10 seconds
url.searchParams.set("idle_timeout", "300"); // 5 minutes
url.searchParams.set("max_lifetime", "1800"); // 30 minutes
// Add connection pooling parameters - configurable via environment variables
const connectionLimit = process.env.DB_CONNECTION_LIMIT || "30";
const poolTimeout = process.env.DB_POOL_TIMEOUT || "20";
const connectTimeout = process.env.DB_CONNECT_TIMEOUT || "10";
const idleTimeout = process.env.DB_IDLE_TIMEOUT || "300";
const maxLifetime = process.env.DB_MAX_LIFETIME || "1800";
url.searchParams.set("connection_limit", connectionLimit);
url.searchParams.set("pool_timeout", poolTimeout);
url.searchParams.set("connect_timeout", connectTimeout);
url.searchParams.set("idle_timeout", idleTimeout);
url.searchParams.set("max_lifetime", maxLifetime);
// Log connection pool settings in development/debug mode
if (
process.env.ENABLE_LOGGING === "true" ||
process.env.LOG_LEVEL === "debug"
) {
console.log(
`[Database Pool] connection_limit=${connectionLimit}, pool_timeout=${poolTimeout}s, connect_timeout=${connectTimeout}s`,
);
}
return url.toString();
}
// Create optimized Prisma client
function createPrismaClient() {
const optimizedUrl = getOptimizedDatabaseUrl();
// Singleton Prisma client instance
let prismaInstance = null;
return new PrismaClient({
datasources: {
db: {
url: optimizedUrl,
function getPrismaClient() {
if (!prismaInstance) {
const optimizedUrl = getOptimizedDatabaseUrl();
prismaInstance = new PrismaClient({
datasources: {
db: {
url: optimizedUrl,
},
},
},
log:
process.env.PRISMA_LOG_QUERIES === "true"
? ["query", "info", "warn", "error"]
: ["warn", "error"],
errorFormat: "pretty",
});
log:
process.env.PRISMA_LOG_QUERIES === "true"
? ["query", "info", "warn", "error"]
: ["warn", "error"],
errorFormat: "pretty",
});
// Handle graceful shutdown
process.on("beforeExit", async () => {
await prismaInstance.$disconnect();
});
process.on("SIGINT", async () => {
await prismaInstance.$disconnect();
process.exit(0);
});
process.on("SIGTERM", async () => {
await prismaInstance.$disconnect();
process.exit(0);
});
}
return prismaInstance;
}
// Connection health check
@@ -50,7 +87,7 @@ async function checkDatabaseConnection(prisma) {
await prisma.$queryRaw`SELECT 1`;
return true;
} catch (error) {
console.error("Database connection failed:", error.message);
console.error("Database connection check failed:", error.message);
return false;
}
}
@@ -121,9 +158,8 @@ async function disconnectPrisma(prisma, maxRetries = 3) {
}
module.exports = {
createPrismaClient,
getPrismaClient,
checkDatabaseConnection,
waitForDatabase,
disconnectPrisma,
getOptimizedDatabaseUrl,
};

View File

@@ -1,11 +1,12 @@
const jwt = require("jsonwebtoken");
const { PrismaClient } = require("@prisma/client");
const { getPrismaClient } = require("../config/prisma");
const {
validate_session,
update_session_activity,
is_tfa_bypassed,
} = require("../utils/session_manager");
const prisma = new PrismaClient();
const prisma = getPrismaClient();
// Middleware to verify JWT token with session validation
const authenticateToken = async (req, res, next) => {
@@ -46,6 +47,9 @@ const authenticateToken = async (req, res, next) => {
// Update session activity timestamp
await update_session_activity(decoded.sessionId);
// Check if TFA is bypassed for this session
const tfa_bypassed = await is_tfa_bypassed(decoded.sessionId);
// Update last login (only on successful authentication)
await prisma.users.update({
where: { id: validation.user.id },
@@ -57,6 +61,7 @@ const authenticateToken = async (req, res, next) => {
req.user = validation.user;
req.session_id = decoded.sessionId;
req.tfa_bypassed = tfa_bypassed;
next();
} catch (error) {
if (error.name === "JsonWebTokenError") {
@@ -114,8 +119,33 @@ const optionalAuth = async (req, _res, next) => {
}
};
// Middleware to check if TFA is required for sensitive operations
const requireTfaIfEnabled = async (req, res, next) => {
try {
// Check if user has TFA enabled
const user = await prisma.users.findUnique({
where: { id: req.user.id },
select: { tfa_enabled: true },
});
// If TFA is enabled and not bypassed, require TFA verification
if (user?.tfa_enabled && !req.tfa_bypassed) {
return res.status(403).json({
error: "Two-factor authentication required for this operation",
requires_tfa: true,
});
}
next();
} catch (error) {
console.error("TFA requirement check error:", error);
return res.status(500).json({ error: "Authentication check failed" });
}
};
module.exports = {
authenticateToken,
requireAdmin,
optionalAuth,
requireTfaIfEnabled,
};

View File

@@ -1,5 +1,5 @@
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
const { getPrismaClient } = require("../config/prisma");
const prisma = getPrismaClient();
// Permission middleware factory
const requirePermission = (permission) => {

View File

@@ -0,0 +1,419 @@
const express = require("express");
const router = express.Router();
const agentVersionService = require("../services/agentVersionService");
const { authenticateToken } = require("../middleware/auth");
const { requirePermission } = require("../middleware/permissions");
// Test GitHub API connectivity
router.get(
"/test-github",
authenticateToken,
requirePermission("can_manage_settings"),
async (_req, res) => {
try {
const axios = require("axios");
const response = await axios.get(
"https://api.github.com/repos/PatchMon/PatchMon-agent/releases",
{
timeout: 10000,
headers: {
"User-Agent": "PatchMon-Server/1.0",
Accept: "application/vnd.github.v3+json",
},
},
);
res.json({
success: true,
status: response.status,
releasesFound: response.data.length,
latestRelease: response.data[0]?.tag_name || "No releases",
rateLimitRemaining: response.headers["x-ratelimit-remaining"],
rateLimitLimit: response.headers["x-ratelimit-limit"],
});
} catch (error) {
console.error("❌ GitHub API test failed:", error.message);
res.status(500).json({
success: false,
error: error.message,
status: error.response?.status,
statusText: error.response?.statusText,
rateLimitRemaining: error.response?.headers["x-ratelimit-remaining"],
rateLimitLimit: error.response?.headers["x-ratelimit-limit"],
});
}
},
);
// Get current version information
router.get("/version", authenticateToken, async (_req, res) => {
try {
const versionInfo = await agentVersionService.getVersionInfo();
console.log(
"📊 Version info response:",
JSON.stringify(versionInfo, null, 2),
);
res.json(versionInfo);
} catch (error) {
console.error("❌ Failed to get version info:", error.message);
res.status(500).json({
error: "Failed to get version information",
details: error.message,
status: "error",
});
}
});
// Refresh current version by executing agent binary
router.post(
"/version/refresh",
authenticateToken,
requirePermission("can_manage_settings"),
async (_req, res) => {
try {
console.log("🔄 Refreshing current agent version...");
const currentVersion = await agentVersionService.refreshCurrentVersion();
console.log("📊 Refreshed current version:", currentVersion);
res.json({
success: true,
currentVersion: currentVersion,
message: currentVersion
? `Current version refreshed: ${currentVersion}`
: "No agent binary found",
});
} catch (error) {
console.error("❌ Failed to refresh current version:", error.message);
res.status(500).json({
success: false,
error: "Failed to refresh current version",
details: error.message,
});
}
},
);
// Download latest update
router.post(
"/version/download",
authenticateToken,
requirePermission("can_manage_settings"),
async (_req, res) => {
try {
console.log("🔄 Downloading latest agent update...");
const downloadResult = await agentVersionService.downloadLatestUpdate();
console.log(
"📊 Download result:",
JSON.stringify(downloadResult, null, 2),
);
res.json(downloadResult);
} catch (error) {
console.error("❌ Failed to download latest update:", error.message);
res.status(500).json({
success: false,
error: "Failed to download latest update",
details: error.message,
});
}
},
);
// Check for updates
router.post(
"/version/check",
authenticateToken,
requirePermission("can_manage_settings"),
async (_req, res) => {
try {
console.log("🔄 Manual update check triggered");
const updateInfo = await agentVersionService.checkForUpdates();
console.log(
"📊 Update check result:",
JSON.stringify(updateInfo, null, 2),
);
res.json(updateInfo);
} catch (error) {
console.error("❌ Failed to check for updates:", error.message);
res.status(500).json({ error: "Failed to check for updates" });
}
},
);
// Get available versions
router.get("/versions", authenticateToken, async (_req, res) => {
try {
const versions = await agentVersionService.getAvailableVersions();
console.log(
"📦 Available versions response:",
JSON.stringify(versions, null, 2),
);
res.json({ versions });
} catch (error) {
console.error("❌ Failed to get available versions:", error.message);
res.status(500).json({ error: "Failed to get available versions" });
}
});
// Get binary information
router.get(
"/binary/:version/:architecture",
authenticateToken,
async (_req, res) => {
try {
const { version, architecture } = req.params;
const binaryInfo = await agentVersionService.getBinaryInfo(
version,
architecture,
);
res.json(binaryInfo);
} catch (error) {
console.error("❌ Failed to get binary info:", error.message);
res.status(404).json({ error: error.message });
}
},
);
// Download agent binary
router.get(
"/download/:version/:architecture",
authenticateToken,
async (_req, res) => {
try {
const { version, architecture } = req.params;
// Validate architecture
if (!agentVersionService.supportedArchitectures.includes(architecture)) {
return res.status(400).json({ error: "Unsupported architecture" });
}
await agentVersionService.serveBinary(version, architecture, res);
} catch (error) {
console.error("❌ Failed to serve binary:", error.message);
res.status(500).json({ error: "Failed to serve binary" });
}
},
);
// Get latest binary for architecture (for agents to query)
router.get("/latest/:architecture", async (req, res) => {
try {
const { architecture } = req.params;
// Validate architecture
if (!agentVersionService.supportedArchitectures.includes(architecture)) {
return res.status(400).json({ error: "Unsupported architecture" });
}
const versionInfo = await agentVersionService.getVersionInfo();
if (!versionInfo.latestVersion) {
return res.status(404).json({ error: "No latest version available" });
}
const binaryInfo = await agentVersionService.getBinaryInfo(
versionInfo.latestVersion,
architecture,
);
res.json({
version: binaryInfo.version,
architecture: binaryInfo.architecture,
size: binaryInfo.size,
hash: binaryInfo.hash,
downloadUrl: `/api/v1/agent/download/${binaryInfo.version}/${binaryInfo.architecture}`,
});
} catch (error) {
console.error("❌ Failed to get latest binary info:", error.message);
res.status(500).json({ error: "Failed to get latest binary information" });
}
});
// Push update notification to specific agent
router.post(
"/notify-update/:apiId",
authenticateToken,
requirePermission("admin"),
async (_req, res) => {
try {
const { apiId } = req.params;
const { version, force = false } = req.body;
const versionInfo = await agentVersionService.getVersionInfo();
const targetVersion = version || versionInfo.latestVersion;
if (!targetVersion) {
return res
.status(400)
.json({ error: "No version specified or available" });
}
// Import WebSocket service
const { pushUpdateNotification } = require("../services/agentWs");
// Push update notification via WebSocket
pushUpdateNotification(apiId, {
version: targetVersion,
force,
downloadUrl: `/api/v1/agent/latest/${req.body.architecture || "linux-amd64"}`,
message: `Update available: ${targetVersion}`,
});
res.json({
success: true,
message: `Update notification sent to agent ${apiId}`,
version: targetVersion,
});
} catch (error) {
console.error("❌ Failed to notify agent update:", error.message);
res.status(500).json({ error: "Failed to notify agent update" });
}
},
);
// Push update notification to all agents
router.post(
"/notify-update-all",
authenticateToken,
requirePermission("admin"),
async (_req, res) => {
try {
const { version, force = false } = req.body;
const versionInfo = await agentVersionService.getVersionInfo();
const targetVersion = version || versionInfo.latestVersion;
if (!targetVersion) {
return res
.status(400)
.json({ error: "No version specified or available" });
}
// Import WebSocket service
const { pushUpdateNotificationToAll } = require("../services/agentWs");
// Push update notification to all connected agents
const result = await pushUpdateNotificationToAll({
version: targetVersion,
force,
message: `Update available: ${targetVersion}`,
});
res.json({
success: true,
message: `Update notification sent to ${result.notifiedCount} agents`,
version: targetVersion,
notifiedCount: result.notifiedCount,
failedCount: result.failedCount,
});
} catch (error) {
console.error("❌ Failed to notify all agents update:", error.message);
res.status(500).json({ error: "Failed to notify all agents update" });
}
},
);
// Check if specific agent needs update and push notification
router.post(
"/check-update/:apiId",
authenticateToken,
requirePermission("can_manage_settings"),
async (_req, res) => {
try {
const { apiId } = req.params;
const { version, force = false } = req.body;
if (!version) {
return res.status(400).json({
success: false,
error: "Agent version is required",
});
}
console.log(
`🔍 Checking update for agent ${apiId} (version: ${version})`,
);
const result = await agentVersionService.checkAndPushAgentUpdate(
apiId,
version,
force,
);
console.log(
"📊 Agent update check result:",
JSON.stringify(result, null, 2),
);
res.json({
success: true,
...result,
});
} catch (error) {
console.error("❌ Failed to check agent update:", error.message);
res.status(500).json({
success: false,
error: "Failed to check agent update",
details: error.message,
});
}
},
);
// Push updates to all connected agents
router.post(
"/push-updates-all",
authenticateToken,
requirePermission("can_manage_settings"),
async (_req, res) => {
try {
const { force = false } = req.body;
console.log(`🔄 Pushing updates to all agents (force: ${force})`);
const result = await agentVersionService.checkAndPushUpdatesToAll(force);
console.log("📊 Bulk update result:", JSON.stringify(result, null, 2));
res.json(result);
} catch (error) {
console.error("❌ Failed to push updates to all agents:", error.message);
res.status(500).json({
success: false,
error: "Failed to push updates to all agents",
details: error.message,
});
}
},
);
// Agent reports its version (for automatic update checking)
router.post("/report-version", authenticateToken, async (req, res) => {
try {
const { apiId, version } = req.body;
if (!apiId || !version) {
return res.status(400).json({
success: false,
error: "API ID and version are required",
});
}
console.log(`📊 Agent ${apiId} reported version: ${version}`);
// Check if agent needs update and push notification if needed
const updateResult = await agentVersionService.checkAndPushAgentUpdate(
apiId,
version,
);
res.json({
success: true,
message: "Version reported successfully",
updateCheck: updateResult,
});
} catch (error) {
console.error("❌ Failed to process agent version report:", error.message);
res.status(500).json({
success: false,
error: "Failed to process version report",
details: error.message,
});
}
});
module.exports = router;

View File

@@ -1,7 +1,7 @@
const express = require("express");
const bcrypt = require("bcryptjs");
const jwt = require("jsonwebtoken");
const { PrismaClient } = require("@prisma/client");
const { getPrismaClient } = require("../config/prisma");
const { body, validationResult } = require("express-validator");
const { authenticateToken, _requireAdmin } = require("../middleware/auth");
const {
@@ -17,11 +17,64 @@ const {
refresh_access_token,
revoke_session,
revoke_all_user_sessions,
get_user_sessions,
} = require("../utils/session_manager");
const router = express.Router();
const prisma = new PrismaClient();
const prisma = getPrismaClient();
/**
* Parse user agent string to extract browser and OS info
*/
function parse_user_agent(user_agent) {
if (!user_agent)
return { browser: "Unknown", os: "Unknown", device: "Unknown" };
const ua = user_agent.toLowerCase();
// Browser detection
let browser = "Unknown";
if (ua.includes("chrome") && !ua.includes("edg")) browser = "Chrome";
else if (ua.includes("firefox")) browser = "Firefox";
else if (ua.includes("safari") && !ua.includes("chrome")) browser = "Safari";
else if (ua.includes("edg")) browser = "Edge";
else if (ua.includes("opera")) browser = "Opera";
// OS detection
let os = "Unknown";
if (ua.includes("windows")) os = "Windows";
else if (ua.includes("macintosh") || ua.includes("mac os")) os = "macOS";
else if (ua.includes("linux")) os = "Linux";
else if (ua.includes("android")) os = "Android";
else if (ua.includes("iphone") || ua.includes("ipad")) os = "iOS";
// Device type
let device = "Desktop";
if (ua.includes("mobile")) device = "Mobile";
else if (ua.includes("tablet") || ua.includes("ipad")) device = "Tablet";
return { browser, os, device };
}
/**
* Get basic location info from IP (simplified - in production you'd use a service)
*/
function get_location_from_ip(ip) {
if (!ip) return { country: "Unknown", city: "Unknown" };
// For localhost/private IPs
if (
ip === "127.0.0.1" ||
ip === "::1" ||
ip.startsWith("192.168.") ||
ip.startsWith("10.")
) {
return { country: "Local", city: "Local Network" };
}
// In a real implementation, you'd use a service like MaxMind GeoIP2
// For now, return unknown for external IPs
return { country: "Unknown", city: "Unknown" };
}
// Check if any admin users exist (for first-time setup)
router.get("/check-admin-users", async (_req, res) => {
@@ -765,6 +818,8 @@ router.post(
id: user.id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role,
is_active: user.is_active,
last_login: user.last_login,
@@ -788,6 +843,10 @@ router.post(
.isLength({ min: 6, max: 6 })
.withMessage("Token must be 6 digits"),
body("token").isNumeric().withMessage("Token must contain only numbers"),
body("remember_me")
.optional()
.isBoolean()
.withMessage("Remember me must be a boolean"),
],
async (req, res) => {
try {
@@ -796,7 +855,7 @@ router.post(
return res.status(400).json({ errors: errors.array() });
}
const { username, token } = req.body;
const { username, token, remember_me = false } = req.body;
// Find user
const user = await prisma.users.findFirst({
@@ -865,13 +924,20 @@ router.post(
// Create session with access and refresh tokens
const ip_address = req.ip || req.connection.remoteAddress;
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,
remember_me,
req,
);
res.json({
message: "Login successful",
token: session.access_token,
refresh_token: session.refresh_token,
expires_at: session.expires_at,
tfa_bypass_until: session.tfa_bypass_until,
user: {
id: user.id,
username: user.username,
@@ -1109,10 +1175,43 @@ router.post(
// Get user's active sessions
router.get("/sessions", authenticateToken, async (req, res) => {
try {
const sessions = await get_user_sessions(req.user.id);
const sessions = await prisma.user_sessions.findMany({
where: {
user_id: req.user.id,
is_revoked: false,
expires_at: { gt: new Date() },
},
select: {
id: true,
ip_address: true,
user_agent: true,
device_fingerprint: true,
last_activity: true,
created_at: true,
expires_at: true,
tfa_remember_me: true,
tfa_bypass_until: true,
login_count: true,
last_login_ip: true,
},
orderBy: { last_activity: "desc" },
});
// Enhance sessions with device info
const enhanced_sessions = sessions.map((session) => {
const is_current_session = session.id === req.session_id;
const device_info = parse_user_agent(session.user_agent);
return {
...session,
is_current_session,
device_info,
location_info: get_location_from_ip(session.ip_address),
};
});
res.json({
sessions: sessions,
sessions: enhanced_sessions,
});
} catch (error) {
console.error("Get sessions error:", error);
@@ -1134,6 +1233,11 @@ router.delete("/sessions/:session_id", authenticateToken, async (req, res) => {
return res.status(404).json({ error: "Session not found" });
}
// Don't allow revoking the current session
if (session_id === req.session_id) {
return res.status(400).json({ error: "Cannot revoke current session" });
}
await revoke_session(session_id);
res.json({
@@ -1145,4 +1249,25 @@ router.delete("/sessions/:session_id", authenticateToken, async (req, res) => {
}
});
// Revoke all sessions except current one
router.delete("/sessions", authenticateToken, async (req, res) => {
try {
// Revoke all sessions except the current one
await prisma.user_sessions.updateMany({
where: {
user_id: req.user.id,
id: { not: req.session_id },
},
data: { is_revoked: true },
});
res.json({
message: "All other sessions revoked successfully",
});
} catch (error) {
console.error("Revoke all sessions error:", error);
res.status(500).json({ error: "Failed to revoke sessions" });
}
});
module.exports = router;

View File

@@ -1,5 +1,5 @@
const express = require("express");
const { PrismaClient } = require("@prisma/client");
const { getPrismaClient } = require("../config/prisma");
const crypto = require("node:crypto");
const bcrypt = require("bcryptjs");
const { body, validationResult } = require("express-validator");
@@ -8,7 +8,7 @@ const { requireManageSettings } = require("../middleware/permissions");
const { v4: uuidv4 } = require("uuid");
const router = express.Router();
const prisma = new PrismaClient();
const prisma = getPrismaClient();
// Generate auto-enrollment token credentials
const generate_auto_enrollment_token = () => {
@@ -570,22 +570,25 @@ router.post(
os_version: "unknown",
api_id: api_id,
api_key: api_key,
host_group_id: req.auto_enrollment_token.default_host_group_id,
status: "pending",
notes: `Auto-enrolled via ${req.auto_enrollment_token.token_name} on ${new Date().toISOString()}`,
updated_at: new Date(),
},
include: {
host_groups: {
select: {
id: true,
name: true,
color: true,
},
},
},
});
// Create host group membership if default host group is specified
let hostGroupMembership = null;
if (req.auto_enrollment_token.default_host_group_id) {
hostGroupMembership = await prisma.host_group_memberships.create({
data: {
id: uuidv4(),
host_id: host.id,
host_group_id: req.auto_enrollment_token.default_host_group_id,
created_at: new Date(),
},
});
}
// Update token usage stats
await prisma.auto_enrollment_tokens.update({
where: { id: req.auto_enrollment_token.id },
@@ -600,6 +603,19 @@ router.post(
`Auto-enrolled host: ${friendly_name} (${host.id}) via token: ${req.auto_enrollment_token.token_name}`,
);
// Get host group details for response if membership was created
let hostGroup = null;
if (hostGroupMembership) {
hostGroup = await prisma.host_groups.findUnique({
where: { id: req.auto_enrollment_token.default_host_group_id },
select: {
id: true,
name: true,
color: true,
},
});
}
res.status(201).json({
message: "Host enrolled successfully",
host: {
@@ -607,7 +623,7 @@ router.post(
friendly_name: host.friendly_name,
api_id: api_id,
api_key: api_key,
host_group: host.host_groups,
host_group: hostGroup,
status: host.status,
},
});
@@ -698,13 +714,24 @@ router.post(
os_version: "unknown",
api_id: api_id,
api_key: api_key,
host_group_id: req.auto_enrollment_token.default_host_group_id,
status: "pending",
notes: `Auto-enrolled via ${req.auto_enrollment_token.token_name} on ${new Date().toISOString()}`,
updated_at: new Date(),
},
});
// Create host group membership if default host group is specified
if (req.auto_enrollment_token.default_host_group_id) {
await prisma.host_group_memberships.create({
data: {
id: uuidv4(),
host_id: host.id,
host_group_id: req.auto_enrollment_token.default_host_group_id,
created_at: new Date(),
},
});
}
results.success.push({
id: host.id,
friendly_name: host.friendly_name,

View File

@@ -0,0 +1,461 @@
const express = require("express");
const { queueManager, QUEUE_NAMES } = require("../services/automation");
const { getConnectedApiIds } = require("../services/agentWs");
const { authenticateToken } = require("../middleware/auth");
const router = express.Router();
// Get all queue statistics
router.get("/stats", authenticateToken, async (_req, res) => {
try {
const stats = await queueManager.getAllQueueStats();
res.json({
success: true,
data: stats,
});
} catch (error) {
console.error("Error fetching queue stats:", error);
res.status(500).json({
success: false,
error: "Failed to fetch queue statistics",
});
}
});
// Get specific queue statistics
router.get("/stats/:queueName", authenticateToken, async (req, res) => {
try {
const { queueName } = req.params;
if (!Object.values(QUEUE_NAMES).includes(queueName)) {
return res.status(400).json({
success: false,
error: "Invalid queue name",
});
}
const stats = await queueManager.getQueueStats(queueName);
res.json({
success: true,
data: stats,
});
} catch (error) {
console.error("Error fetching queue stats:", error);
res.status(500).json({
success: false,
error: "Failed to fetch queue statistics",
});
}
});
// Get recent jobs for a queue
router.get("/jobs/:queueName", authenticateToken, async (req, res) => {
try {
const { queueName } = req.params;
const { limit = 10 } = req.query;
if (!Object.values(QUEUE_NAMES).includes(queueName)) {
return res.status(400).json({
success: false,
error: "Invalid queue name",
});
}
const jobs = await queueManager.getRecentJobs(
queueName,
parseInt(limit, 10),
);
// Format jobs for frontend
const formattedJobs = jobs.map((job) => ({
id: job.id,
name: job.name,
status: job.finishedOn
? job.failedReason
? "failed"
: "completed"
: "active",
progress: job.progress,
data: job.data,
returnvalue: job.returnvalue,
failedReason: job.failedReason,
processedOn: job.processedOn,
finishedOn: job.finishedOn,
createdAt: new Date(job.timestamp),
attemptsMade: job.attemptsMade,
delay: job.delay,
}));
res.json({
success: true,
data: formattedJobs,
});
} catch (error) {
console.error("Error fetching recent jobs:", error);
res.status(500).json({
success: false,
error: "Failed to fetch recent jobs",
});
}
});
// Trigger manual GitHub update check
router.post("/trigger/github-update", authenticateToken, async (_req, res) => {
try {
const job = await queueManager.triggerGitHubUpdateCheck();
res.json({
success: true,
data: {
jobId: job.id,
message: "GitHub update check triggered successfully",
},
});
} catch (error) {
console.error("Error triggering GitHub update check:", error);
res.status(500).json({
success: false,
error: "Failed to trigger GitHub update check",
});
}
});
// Trigger manual session cleanup
router.post(
"/trigger/session-cleanup",
authenticateToken,
async (_req, res) => {
try {
const job = await queueManager.triggerSessionCleanup();
res.json({
success: true,
data: {
jobId: job.id,
message: "Session cleanup triggered successfully",
},
});
} catch (error) {
console.error("Error triggering session cleanup:", error);
res.status(500).json({
success: false,
error: "Failed to trigger session cleanup",
});
}
},
);
// Trigger Agent Collection: enqueue report_now for connected agents only
router.post(
"/trigger/agent-collection",
authenticateToken,
async (_req, res) => {
try {
const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS];
const apiIds = getConnectedApiIds();
if (!apiIds || apiIds.length === 0) {
return res.json({ success: true, data: { enqueued: 0 } });
}
const jobs = apiIds.map((apiId) => ({
name: "report_now",
data: { api_id: apiId, type: "report_now" },
opts: { attempts: 3, backoff: { type: "fixed", delay: 2000 } },
}));
await queue.addBulk(jobs);
res.json({ success: true, data: { enqueued: jobs.length } });
} catch (error) {
console.error("Error triggering agent collection:", error);
res
.status(500)
.json({ success: false, error: "Failed to trigger agent collection" });
}
},
);
// Trigger manual orphaned repo cleanup
router.post(
"/trigger/orphaned-repo-cleanup",
authenticateToken,
async (_req, res) => {
try {
const job = await queueManager.triggerOrphanedRepoCleanup();
res.json({
success: true,
data: {
jobId: job.id,
message: "Orphaned repository cleanup triggered successfully",
},
});
} catch (error) {
console.error("Error triggering orphaned repository cleanup:", error);
res.status(500).json({
success: false,
error: "Failed to trigger orphaned repository cleanup",
});
}
},
);
// Trigger manual orphaned package cleanup
router.post(
"/trigger/orphaned-package-cleanup",
authenticateToken,
async (_req, res) => {
try {
const job = await queueManager.triggerOrphanedPackageCleanup();
res.json({
success: true,
data: {
jobId: job.id,
message: "Orphaned package cleanup triggered successfully",
},
});
} catch (error) {
console.error("Error triggering orphaned package cleanup:", error);
res.status(500).json({
success: false,
error: "Failed to trigger orphaned package cleanup",
});
}
},
);
// Trigger manual Docker inventory cleanup
router.post(
"/trigger/docker-inventory-cleanup",
authenticateToken,
async (_req, res) => {
try {
const job = await queueManager.triggerDockerInventoryCleanup();
res.json({
success: true,
data: {
jobId: job.id,
message: "Docker inventory cleanup triggered successfully",
},
});
} catch (error) {
console.error("Error triggering Docker inventory cleanup:", error);
res.status(500).json({
success: false,
error: "Failed to trigger Docker inventory cleanup",
});
}
},
);
// Get queue health status
router.get("/health", authenticateToken, async (_req, res) => {
try {
const stats = await queueManager.getAllQueueStats();
const totalJobs = Object.values(stats).reduce((sum, queueStats) => {
return sum + queueStats.waiting + queueStats.active + queueStats.failed;
}, 0);
const health = {
status: "healthy",
totalJobs,
queues: Object.keys(stats).length,
timestamp: new Date().toISOString(),
};
// Check for unhealthy conditions
if (totalJobs > 1000) {
health.status = "warning";
health.message = "High number of queued jobs";
}
const failedJobs = Object.values(stats).reduce((sum, queueStats) => {
return sum + queueStats.failed;
}, 0);
if (failedJobs > 10) {
health.status = "error";
health.message = "High number of failed jobs";
}
res.json({
success: true,
data: health,
});
} catch (error) {
console.error("Error checking queue health:", error);
res.status(500).json({
success: false,
error: "Failed to check queue health",
});
}
});
// Get automation overview (for dashboard cards)
router.get("/overview", authenticateToken, async (_req, res) => {
try {
const stats = await queueManager.getAllQueueStats();
const { getSettings } = require("../services/settingsService");
const settings = await getSettings();
// Get recent jobs for each queue to show last run times
const recentJobs = await Promise.all([
queueManager.getRecentJobs(QUEUE_NAMES.GITHUB_UPDATE_CHECK, 1),
queueManager.getRecentJobs(QUEUE_NAMES.SESSION_CLEANUP, 1),
queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_REPO_CLEANUP, 1),
queueManager.getRecentJobs(QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP, 1),
queueManager.getRecentJobs(QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP, 1),
queueManager.getRecentJobs(QUEUE_NAMES.AGENT_COMMANDS, 1),
]);
// Calculate overview metrics
const overview = {
scheduledTasks:
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].delayed +
stats[QUEUE_NAMES.SESSION_CLEANUP].delayed +
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].delayed +
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].delayed +
stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].delayed,
runningTasks:
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].active +
stats[QUEUE_NAMES.SESSION_CLEANUP].active +
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].active +
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].active +
stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].active,
failedTasks:
stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK].failed +
stats[QUEUE_NAMES.SESSION_CLEANUP].failed +
stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].failed +
stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].failed +
stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].failed,
totalAutomations: Object.values(stats).reduce((sum, queueStats) => {
return (
sum +
queueStats.completed +
queueStats.failed +
queueStats.active +
queueStats.waiting +
queueStats.delayed
);
}, 0),
// Automation details with last run times
automations: [
{
name: "GitHub Update Check",
queue: QUEUE_NAMES.GITHUB_UPDATE_CHECK,
description: "Checks for new PatchMon releases",
schedule: "Daily at midnight",
lastRun: recentJobs[0][0]?.finishedOn
? new Date(recentJobs[0][0].finishedOn).toLocaleString()
: "Never",
lastRunTimestamp: recentJobs[0][0]?.finishedOn || 0,
status: recentJobs[0][0]?.failedReason
? "Failed"
: recentJobs[0][0]
? "Success"
: "Never run",
stats: stats[QUEUE_NAMES.GITHUB_UPDATE_CHECK],
},
{
name: "Session Cleanup",
queue: QUEUE_NAMES.SESSION_CLEANUP,
description: "Cleans up expired user sessions",
schedule: "Every hour",
lastRun: recentJobs[1][0]?.finishedOn
? new Date(recentJobs[1][0].finishedOn).toLocaleString()
: "Never",
lastRunTimestamp: recentJobs[1][0]?.finishedOn || 0,
status: recentJobs[1][0]?.failedReason
? "Failed"
: recentJobs[1][0]
? "Success"
: "Never run",
stats: stats[QUEUE_NAMES.SESSION_CLEANUP],
},
{
name: "Orphaned Repo Cleanup",
queue: QUEUE_NAMES.ORPHANED_REPO_CLEANUP,
description: "Removes repositories with no associated hosts",
schedule: "Daily at 2 AM",
lastRun: recentJobs[2][0]?.finishedOn
? new Date(recentJobs[2][0].finishedOn).toLocaleString()
: "Never",
lastRunTimestamp: recentJobs[2][0]?.finishedOn || 0,
status: recentJobs[2][0]?.failedReason
? "Failed"
: recentJobs[2][0]
? "Success"
: "Never run",
stats: stats[QUEUE_NAMES.ORPHANED_REPO_CLEANUP],
},
{
name: "Orphaned Package Cleanup",
queue: QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP,
description: "Removes packages with no associated hosts",
schedule: "Daily at 3 AM",
lastRun: recentJobs[3][0]?.finishedOn
? new Date(recentJobs[3][0].finishedOn).toLocaleString()
: "Never",
lastRunTimestamp: recentJobs[3][0]?.finishedOn || 0,
status: recentJobs[3][0]?.failedReason
? "Failed"
: recentJobs[3][0]
? "Success"
: "Never run",
stats: stats[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP],
},
{
name: "Docker Inventory Cleanup",
queue: QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP,
description:
"Removes Docker containers and images for non-existent hosts",
schedule: "Daily at 4 AM",
lastRun: recentJobs[4][0]?.finishedOn
? new Date(recentJobs[4][0].finishedOn).toLocaleString()
: "Never",
lastRunTimestamp: recentJobs[4][0]?.finishedOn || 0,
status: recentJobs[4][0]?.failedReason
? "Failed"
: recentJobs[4][0]
? "Success"
: "Never run",
stats: stats[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP],
},
{
name: "Collect Host Statistics",
queue: QUEUE_NAMES.AGENT_COMMANDS,
description: "Collects package statistics from connected agents only",
schedule: `Every ${settings.update_interval} minutes (Agent-driven)`,
lastRun: recentJobs[5][0]?.finishedOn
? new Date(recentJobs[5][0].finishedOn).toLocaleString()
: "Never",
lastRunTimestamp: recentJobs[5][0]?.finishedOn || 0,
status: recentJobs[5][0]?.failedReason
? "Failed"
: recentJobs[5][0]
? "Success"
: "Never run",
stats: stats[QUEUE_NAMES.AGENT_COMMANDS],
},
].sort((a, b) => {
// Sort by last run timestamp (most recent first)
// If both have never run (timestamp 0), maintain original order
if (a.lastRunTimestamp === 0 && b.lastRunTimestamp === 0) return 0;
if (a.lastRunTimestamp === 0) return 1; // Never run goes to bottom
if (b.lastRunTimestamp === 0) return -1; // Never run goes to bottom
return b.lastRunTimestamp - a.lastRunTimestamp; // Most recent first
}),
};
res.json({
success: true,
data: overview,
});
} catch (error) {
console.error("Error fetching automation overview:", error);
res.status(500).json({
success: false,
error: "Failed to fetch automation overview",
});
}
});
module.exports = router;

View File

@@ -1,11 +1,11 @@
const express = require("express");
const { body, validationResult } = require("express-validator");
const { PrismaClient } = require("@prisma/client");
const { getPrismaClient } = require("../config/prisma");
const { authenticateToken } = require("../middleware/auth");
const { v4: uuidv4 } = require("uuid");
const router = express.Router();
const prisma = new PrismaClient();
const prisma = getPrismaClient();
// Helper function to get user permissions based on role
async function getUserPermissions(userRole) {
@@ -130,15 +130,20 @@ async function createDefaultDashboardPreferences(userId, userRole = "user") {
requiredPermission: "can_view_packages",
order: 13,
},
{
cardId: "packageTrends",
requiredPermission: "can_view_packages",
order: 14,
},
{
cardId: "recentUsers",
requiredPermission: "can_view_users",
order: 14,
order: 15,
},
{
cardId: "quickStats",
requiredPermission: "can_view_dashboard",
order: 15,
order: 16,
},
];
@@ -341,19 +346,26 @@ router.get("/defaults", authenticateToken, async (_req, res) => {
enabled: true,
order: 13,
},
{
cardId: "packageTrends",
title: "Package Trends",
icon: "TrendingUp",
enabled: true,
order: 14,
},
{
cardId: "recentUsers",
title: "Recent Users Logged in",
icon: "Users",
enabled: true,
order: 14,
order: 15,
},
{
cardId: "quickStats",
title: "Quick Stats",
icon: "TrendingUp",
enabled: true,
order: 15,
order: 16,
},
];

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,246 @@
const express = require("express");
const { getPrismaClient } = require("../config/prisma");
const bcrypt = require("bcryptjs");
const router = express.Router();
const prisma = getPrismaClient();
// Middleware to authenticate API key
const authenticateApiKey = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Basic ")) {
return res
.status(401)
.json({ error: "Missing or invalid authorization header" });
}
// Decode base64 credentials
const base64Credentials = authHeader.split(" ")[1];
const credentials = Buffer.from(base64Credentials, "base64").toString(
"ascii",
);
const [apiKey, apiSecret] = credentials.split(":");
if (!apiKey || !apiSecret) {
return res.status(401).json({ error: "Invalid credentials format" });
}
// Find the token in database
const token = await prisma.auto_enrollment_tokens.findUnique({
where: { token_key: apiKey },
include: {
users: {
select: {
id: true,
username: true,
role: true,
},
},
},
});
if (!token) {
console.log(`API key not found: ${apiKey}`);
return res.status(401).json({ error: "Invalid API key" });
}
// Check if token is active
if (!token.is_active) {
return res.status(401).json({ error: "API key is disabled" });
}
// Check if token has expired
if (token.expires_at && new Date(token.expires_at) < new Date()) {
return res.status(401).json({ error: "API key has expired" });
}
// Check if token is for gethomepage integration
if (token.metadata?.integration_type !== "gethomepage") {
return res.status(401).json({ error: "Invalid API key type" });
}
// Verify the secret
const isValidSecret = await bcrypt.compare(apiSecret, token.token_secret);
if (!isValidSecret) {
return res.status(401).json({ error: "Invalid API secret" });
}
// Check IP restrictions if any
if (token.allowed_ip_ranges && token.allowed_ip_ranges.length > 0) {
const clientIp = req.ip || req.connection.remoteAddress;
const forwardedFor = req.headers["x-forwarded-for"];
const realIp = req.headers["x-real-ip"];
// Get the actual client IP (considering proxies)
const actualClientIp = forwardedFor
? forwardedFor.split(",")[0].trim()
: realIp || clientIp;
const isAllowedIp = token.allowed_ip_ranges.some((range) => {
// Simple IP range check (can be enhanced for CIDR support)
return actualClientIp.startsWith(range) || actualClientIp === range;
});
if (!isAllowedIp) {
console.log(
`IP validation failed. Client IP: ${actualClientIp}, Allowed ranges: ${token.allowed_ip_ranges.join(", ")}`,
);
return res.status(403).json({ error: "IP address not allowed" });
}
}
// Update last used timestamp
await prisma.auto_enrollment_tokens.update({
where: { id: token.id },
data: { last_used_at: new Date() },
});
// Attach token info to request
req.apiToken = token;
next();
} catch (error) {
console.error("API key authentication error:", error);
res.status(500).json({ error: "Authentication failed" });
}
};
// Get homepage widget statistics
router.get("/stats", authenticateApiKey, async (_req, res) => {
try {
// Get total hosts count
const totalHosts = await prisma.hosts.count({
where: { status: "active" },
});
// Get total unique packages that need updates (consistent with dashboard)
const totalOutdatedPackages = await prisma.packages.count({
where: {
host_packages: {
some: {
needs_update: true,
},
},
},
});
// Get total repositories count
const totalRepos = await prisma.repositories.count({
where: { is_active: true },
});
// Get hosts that need updates (have outdated packages)
const hostsNeedingUpdates = await prisma.hosts.count({
where: {
status: "active",
host_packages: {
some: {
needs_update: true,
},
},
},
});
// Get security updates count (unique packages - consistent with dashboard)
const securityUpdates = await prisma.packages.count({
where: {
host_packages: {
some: {
needs_update: true,
is_security_update: true,
},
},
},
});
// Get hosts with security updates
const hostsWithSecurityUpdates = await prisma.hosts.count({
where: {
status: "active",
host_packages: {
some: {
needs_update: true,
is_security_update: true,
},
},
},
});
// Get up-to-date hosts count
const upToDateHosts = totalHosts - hostsNeedingUpdates;
// Get recent update activity (last 24 hours)
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
const recentUpdates = await prisma.update_history.count({
where: {
timestamp: {
gte: oneDayAgo,
},
status: "success",
},
});
// Get OS distribution
const osDistribution = await prisma.hosts.groupBy({
by: ["os_type"],
where: { status: "active" },
_count: {
id: true,
},
orderBy: {
_count: {
id: "desc",
},
},
});
// Format OS distribution data
const osDistributionFormatted = osDistribution.map((os) => ({
name: os.os_type,
count: os._count.id,
}));
// Extract top 3 OS types for flat display in widgets
const top_os_1 = osDistributionFormatted[0] || { name: "None", count: 0 };
const top_os_2 = osDistributionFormatted[1] || { name: "None", count: 0 };
const top_os_3 = osDistributionFormatted[2] || { name: "None", count: 0 };
// Prepare response data
const stats = {
total_hosts: totalHosts,
total_outdated_packages: totalOutdatedPackages,
total_repos: totalRepos,
hosts_needing_updates: hostsNeedingUpdates,
up_to_date_hosts: upToDateHosts,
security_updates: securityUpdates,
hosts_with_security_updates: hostsWithSecurityUpdates,
recent_updates_24h: recentUpdates,
os_distribution: osDistributionFormatted,
// Flattened OS data for easy widget display
top_os_1_name: top_os_1.name,
top_os_1_count: top_os_1.count,
top_os_2_name: top_os_2.name,
top_os_2_count: top_os_2.count,
top_os_3_name: top_os_3.name,
top_os_3_count: top_os_3.count,
last_updated: new Date().toISOString(),
};
res.json(stats);
} catch (error) {
console.error("Error fetching homepage stats:", error);
res.status(500).json({ error: "Failed to fetch statistics" });
}
});
// Health check endpoint for the API
router.get("/health", authenticateApiKey, async (req, res) => {
res.json({
status: "ok",
timestamp: new Date().toISOString(),
api_key: req.apiToken.token_name,
});
});
module.exports = router;

View File

@@ -1,12 +1,12 @@
const express = require("express");
const { body, validationResult } = require("express-validator");
const { PrismaClient } = require("@prisma/client");
const { getPrismaClient } = require("../config/prisma");
const { randomUUID } = require("node:crypto");
const { authenticateToken } = require("../middleware/auth");
const { requireManageHosts } = require("../middleware/permissions");
const router = express.Router();
const prisma = new PrismaClient();
const prisma = getPrismaClient();
// Get all host groups
router.get("/", authenticateToken, async (_req, res) => {
@@ -15,7 +15,7 @@ router.get("/", authenticateToken, async (_req, res) => {
include: {
_count: {
select: {
hosts: true,
host_group_memberships: true,
},
},
},
@@ -39,16 +39,20 @@ router.get("/:id", authenticateToken, async (req, res) => {
const hostGroup = await prisma.host_groups.findUnique({
where: { id },
include: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
os_type: true,
os_version: true,
status: true,
last_update: true,
host_group_memberships: {
include: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
os_type: true,
os_version: true,
status: true,
last_update: true,
},
},
},
},
},
@@ -195,7 +199,7 @@ router.delete(
include: {
_count: {
select: {
hosts: true,
host_group_memberships: true,
},
},
},
@@ -205,11 +209,10 @@ router.delete(
return res.status(404).json({ error: "Host group not found" });
}
// If host group has hosts, ungroup them first
if (existingGroup._count.hosts > 0) {
await prisma.hosts.updateMany({
// If host group has memberships, remove them first
if (existingGroup._count.host_group_memberships > 0) {
await prisma.host_group_memberships.deleteMany({
where: { host_group_id: id },
data: { host_group_id: null },
});
}
@@ -231,7 +234,13 @@ router.get("/:id/hosts", authenticateToken, async (req, res) => {
const { id } = req.params;
const hosts = await prisma.hosts.findMany({
where: { host_group_id: id },
where: {
host_group_memberships: {
some: {
host_group_id: id,
},
},
},
select: {
id: true,
friendly_name: true,

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
const express = require("express");
const { PrismaClient } = require("@prisma/client");
const { getPrismaClient } = require("../config/prisma");
const router = express.Router();
const prisma = new PrismaClient();
const prisma = getPrismaClient();
// Get all packages with their update status
router.get("/", async (req, res) => {
@@ -14,6 +14,7 @@ router.get("/", async (req, res) => {
category = "",
needsUpdate = "",
isSecurityUpdate = "",
host = "",
} = req.query;
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
@@ -33,8 +34,27 @@ router.get("/", async (req, res) => {
: {},
// Category filter
category ? { category: { equals: category } } : {},
// Update status filters
needsUpdate
// Host filter - only return packages installed on the specified host
// Combined with update status filters if both are present
host
? {
host_packages: {
some: {
host_id: host,
// If needsUpdate or isSecurityUpdate filters are present, apply them here
...(needsUpdate
? { needs_update: needsUpdate === "true" }
: {}),
...(isSecurityUpdate
? { is_security_update: isSecurityUpdate === "true" }
: {}),
},
},
}
: {},
// Update status filters (only applied if no host filter)
// If host filter is present, these are already applied above
!host && needsUpdate
? {
host_packages: {
some: {
@@ -43,7 +63,7 @@ router.get("/", async (req, res) => {
},
}
: {},
isSecurityUpdate
!host && isSecurityUpdate
? {
host_packages: {
some: {
@@ -81,62 +101,107 @@ router.get("/", async (req, res) => {
prisma.packages.count({ where }),
]);
// Get additional stats for each package
const packagesWithStats = await Promise.all(
packages.map(async (pkg) => {
const [updatesCount, securityCount, packageHosts] = await Promise.all([
prisma.host_packages.count({
where: {
package_id: pkg.id,
needs_update: true,
},
}),
prisma.host_packages.count({
where: {
package_id: pkg.id,
needs_update: true,
is_security_update: true,
},
}),
prisma.host_packages.findMany({
where: {
package_id: pkg.id,
needs_update: true,
},
select: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
os_type: true,
},
// OPTIMIZATION: Batch query all stats instead of N individual queries
const packageIds = packages.map((pkg) => pkg.id);
// Get all counts and host data in 3 batch queries instead of N*3 queries
const [allUpdatesCounts, allSecurityCounts, allPackageHostsData] =
await Promise.all([
// Batch count all packages that need updates
prisma.host_packages.groupBy({
by: ["package_id"],
where: {
package_id: { in: packageIds },
needs_update: true,
...(host ? { host_id: host } : {}),
},
_count: { id: true },
}),
// Batch count all packages with security updates
prisma.host_packages.groupBy({
by: ["package_id"],
where: {
package_id: { in: packageIds },
needs_update: true,
is_security_update: true,
...(host ? { host_id: host } : {}),
},
_count: { id: true },
}),
// Batch fetch all host data for packages
prisma.host_packages.findMany({
where: {
package_id: { in: packageIds },
...(host ? { host_id: host } : { needs_update: true }),
},
select: {
package_id: true,
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
os_type: true,
},
},
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,
current_version: true,
available_version: true,
needs_update: true,
is_security_update: true,
},
};
}),
// Limit to first 10 per package
take: 100, // Increased from package-based limit
}),
]);
// Create lookup maps for O(1) access
const updatesCountMap = new Map(
allUpdatesCounts.map((item) => [item.package_id, item._count.id]),
);
const securityCountMap = new Map(
allSecurityCounts.map((item) => [item.package_id, item._count.id]),
);
const packageHostsMap = new Map();
// Group host data by package_id
for (const hp of allPackageHostsData) {
if (!packageHostsMap.has(hp.package_id)) {
packageHostsMap.set(hp.package_id, []);
}
const hosts = packageHostsMap.get(hp.package_id);
hosts.push({
hostId: hp.hosts.id,
friendlyName: hp.hosts.friendly_name,
osType: hp.hosts.os_type,
currentVersion: hp.current_version,
availableVersion: hp.available_version,
needsUpdate: hp.needs_update,
isSecurityUpdate: hp.is_security_update,
});
// Limit to 10 hosts per package
if (hosts.length > 10) {
packageHostsMap.set(hp.package_id, hosts.slice(0, 10));
}
}
// Map packages with stats from lookup maps (no more DB queries!)
const packagesWithStats = packages.map((pkg) => {
const updatesCount = updatesCountMap.get(pkg.id) || 0;
const securityCount = securityCountMap.get(pkg.id) || 0;
const packageHosts = packageHostsMap.get(pkg.id) || [];
return {
...pkg,
packageHostsCount: pkg._count.host_packages,
packageHosts,
stats: {
totalInstalls: pkg._count.host_packages,
updatesNeeded: updatesCount,
securityUpdates: securityCount,
},
};
});
res.json({
packages: packagesWithStats,

View File

@@ -1,5 +1,5 @@
const express = require("express");
const { PrismaClient } = require("@prisma/client");
const { getPrismaClient } = require("../config/prisma");
const { authenticateToken } = require("../middleware/auth");
const {
requireManageSettings,
@@ -7,7 +7,7 @@ const {
} = require("../middleware/permissions");
const router = express.Router();
const prisma = new PrismaClient();
const prisma = getPrismaClient();
// Get all role permissions (allow users who can manage users to view roles)
router.get(

View File

@@ -1,6 +1,6 @@
const express = require("express");
const { body, validationResult } = require("express-validator");
const { PrismaClient } = require("@prisma/client");
const { getPrismaClient } = require("../config/prisma");
const { authenticateToken } = require("../middleware/auth");
const {
requireViewHosts,
@@ -8,7 +8,7 @@ const {
} = require("../middleware/permissions");
const router = express.Router();
const prisma = new PrismaClient();
const prisma = getPrismaClient();
// Get all repositories with host count
router.get("/", authenticateToken, requireViewHosts, async (_req, res) => {

View File

@@ -1,9 +1,9 @@
const express = require("express");
const router = express.Router();
const { createPrismaClient } = require("../config/database");
const { getPrismaClient } = require("../config/prisma");
const { authenticateToken } = require("../middleware/auth");
const prisma = createPrismaClient();
const prisma = getPrismaClient();
/**
* Global search endpoint

View File

@@ -1,109 +1,16 @@
const express = require("express");
const { body, validationResult } = require("express-validator");
const { PrismaClient } = require("@prisma/client");
const { getPrismaClient } = require("../config/prisma");
const { authenticateToken } = require("../middleware/auth");
const { requireManageSettings } = require("../middleware/permissions");
const { getSettings, updateSettings } = require("../services/settingsService");
const router = express.Router();
const prisma = new PrismaClient();
const prisma = getPrismaClient();
// Function to trigger crontab updates on all hosts with auto-update enabled
async function triggerCrontabUpdates() {
try {
console.log(
"Triggering crontab updates on all hosts with auto-update enabled...",
);
// Get current settings for server URL
const settings = await getSettings();
const serverUrl = settings.server_url;
// Get all hosts that have auto-update enabled
const hosts = await prisma.hosts.findMany({
where: {
auto_update: true,
status: "active", // Only update active hosts
},
select: {
id: true,
friendly_name: true,
api_id: true,
api_key: true,
},
});
console.log(`Found ${hosts.length} hosts with auto-update enabled`);
// For each host, we'll send a special update command that triggers crontab update
// This is done by sending a ping with a special flag
for (const host of hosts) {
try {
console.log(
`Triggering crontab update for host: ${host.friendly_name}`,
);
// We'll use the existing ping endpoint but add a special parameter
// The agent will detect this and run update-crontab command
const http = require("node:http");
const https = require("node:https");
const url = new URL(`${serverUrl}/api/v1/hosts/ping`);
const isHttps = url.protocol === "https:";
const client = isHttps ? https : http;
const postData = JSON.stringify({
triggerCrontabUpdate: true,
message: "Update interval changed, please update your crontab",
});
const options = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(postData),
"X-API-ID": host.api_id,
"X-API-KEY": host.api_key,
},
};
const req = client.request(options, (res) => {
if (res.statusCode === 200) {
console.log(
`Successfully triggered crontab update for ${host.friendly_name}`,
);
} else {
console.error(
`Failed to trigger crontab update for ${host.friendly_name}: ${res.statusCode}`,
);
}
});
req.on("error", (error) => {
console.error(
`Error triggering crontab update for ${host.friendly_name}:`,
error.message,
);
});
req.write(postData);
req.end();
} catch (error) {
console.error(
`Error triggering crontab update for ${host.friendly_name}:`,
error.message,
);
}
}
console.log("Crontab update trigger completed");
} catch (error) {
console.error("Error in triggerCrontabUpdates:", error);
}
}
// WebSocket broadcaster for agent policy updates (no longer used - queue-based delivery preferred)
// const { broadcastSettingsUpdate } = require("../services/agentWs");
const { queueManager, QUEUE_NAMES } = require("../services/automation");
// Helpers
function normalizeUpdateInterval(minutes) {
@@ -251,6 +158,7 @@ router.put(
logoDark,
logoLight,
favicon,
colorTheme,
} = req.body;
// Get current settings to check for update interval changes
@@ -282,6 +190,7 @@ router.put(
if (logoDark !== undefined) updateData.logo_dark = logoDark;
if (logoLight !== undefined) updateData.logo_light = logoLight;
if (favicon !== undefined) updateData.favicon = favicon;
if (colorTheme !== undefined) updateData.color_theme = colorTheme;
const updatedSettings = await updateSettings(
currentSettings.id,
@@ -290,15 +199,36 @@ router.put(
console.log("Settings updated successfully:", updatedSettings);
// If update interval changed, trigger crontab updates on all hosts with auto-update enabled
// If update interval changed, enqueue persistent jobs for agents
if (
updateInterval !== undefined &&
oldUpdateInterval !== updateData.update_interval
) {
console.log(
`Update interval changed from ${oldUpdateInterval} to ${updateData.update_interval} minutes. Triggering crontab updates...`,
`Update interval changed from ${oldUpdateInterval} to ${updateData.update_interval} minutes. Enqueueing agent settings updates...`,
);
await triggerCrontabUpdates();
const hosts = await prisma.hosts.findMany({
where: { status: "active" },
select: { api_id: true },
});
const queue = queueManager.queues[QUEUE_NAMES.AGENT_COMMANDS];
const jobs = hosts.map((h) => ({
name: "settings_update",
data: {
api_id: h.api_id,
type: "settings_update",
update_interval: updateData.update_interval,
},
opts: { attempts: 10, backoff: { type: "exponential", delay: 5000 } },
}));
// Bulk add jobs
await queue.addBulk(jobs);
// Note: Queue-based delivery handles retries and ensures reliable delivery
// No need for immediate broadcast as it would cause duplicate messages
}
res.json({

View File

@@ -1,12 +1,12 @@
const express = require("express");
const { PrismaClient } = require("@prisma/client");
const { getPrismaClient } = require("../config/prisma");
const speakeasy = require("speakeasy");
const QRCode = require("qrcode");
const { authenticateToken } = require("../middleware/auth");
const { body, validationResult } = require("express-validator");
const router = express.Router();
const prisma = new PrismaClient();
const prisma = getPrismaClient();
// Generate TFA secret and QR code
router.get("/setup", authenticateToken, async (req, res) => {

View File

@@ -1,12 +1,12 @@
const express = require("express");
const { authenticateToken } = require("../middleware/auth");
const { requireManageSettings } = require("../middleware/permissions");
const { PrismaClient } = require("@prisma/client");
const { getPrismaClient } = require("../config/prisma");
const prisma = new PrismaClient();
const prisma = getPrismaClient();
// Default GitHub repository URL
const DEFAULT_GITHUB_REPO = "https://github.com/patchMon/patchmon";
const DEFAULT_GITHUB_REPO = "https://github.com/PatchMon/PatchMon.git";
const router = express.Router();
@@ -14,13 +14,16 @@ const router = express.Router();
function getCurrentVersion() {
try {
const packageJson = require("../../package.json");
return packageJson?.version || "1.2.7";
if (!packageJson?.version) {
throw new Error("Version not found in package.json");
}
return packageJson.version;
} catch (packageError) {
console.warn(
"Could not read version from package.json, using fallback:",
console.error(
"Could not read version from package.json:",
packageError.message,
);
return "1.2.7";
return "unknown";
}
}
@@ -126,43 +129,61 @@ async function getLatestCommit(owner, repo) {
// Helper function to get commit count difference
async function getCommitDifference(owner, repo, currentVersion) {
try {
const currentVersionTag = `v${currentVersion}`;
// Compare main branch with the released version tag
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/compare/${currentVersionTag}...main`;
// Try both with and without 'v' prefix for compatibility
const versionTags = [
currentVersion, // Try without 'v' first (new format)
`v${currentVersion}`, // Try with 'v' prefix (old format)
];
const response = await fetch(apiUrl, {
method: "GET",
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": `PatchMon-Server/${getCurrentVersion()}`,
},
});
for (const versionTag of versionTags) {
try {
// Compare main branch with the released version tag
const apiUrl = `https://api.github.com/repos/${owner}/${repo}/compare/${versionTag}...main`;
if (!response.ok) {
const errorText = await response.text();
if (
errorText.includes("rate limit") ||
errorText.includes("API rate limit")
) {
throw new Error("GitHub API rate limit exceeded");
const response = await fetch(apiUrl, {
method: "GET",
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": `PatchMon-Server/${getCurrentVersion()}`,
},
});
if (!response.ok) {
const errorText = await response.text();
if (
errorText.includes("rate limit") ||
errorText.includes("API rate limit")
) {
throw new Error("GitHub API rate limit exceeded");
}
// If 404, try next tag format
if (response.status === 404) {
continue;
}
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
const compareData = await response.json();
return {
commitsBehind: compareData.behind_by || 0, // How many commits main is behind release
commitsAhead: compareData.ahead_by || 0, // How many commits main is ahead of release
totalCommits: compareData.total_commits || 0,
branchInfo: "main branch vs release",
};
} catch (error) {
console.error("Error fetching commit difference:", error.message);
throw error;
const compareData = await response.json();
return {
commitsBehind: compareData.behind_by || 0, // How many commits main is behind release
commitsAhead: compareData.ahead_by || 0, // How many commits main is ahead of release
totalCommits: compareData.total_commits || 0,
branchInfo: "main branch vs release",
};
} catch (error) {
// If rate limit, throw immediately
if (error.message.includes("rate limit")) {
throw error;
}
}
}
// If all attempts failed, throw error
throw new Error(
`Could not find tag '${currentVersion}' or 'v${currentVersion}' in repository`,
);
}
// Helper function to compare version strings (semantic versioning)
@@ -274,11 +295,11 @@ router.get(
) {
console.log("GitHub API rate limited, providing fallback data");
latestRelease = {
tagName: "v1.2.7",
version: "1.2.7",
tagName: "v1.2.8",
version: "1.2.8",
publishedAt: "2025-10-02T17:12:53Z",
htmlUrl:
"https://github.com/PatchMon/PatchMon/releases/tag/v1.2.7",
"https://github.com/PatchMon/PatchMon/releases/tag/v1.2.8",
};
latestCommit = {
sha: "cc89df161b8ea5d48ff95b0eb405fe69042052cd",
@@ -296,10 +317,13 @@ router.get(
};
} else {
// Fall back to cached data for other errors
const githubRepoUrl = settings.githubRepoUrl || DEFAULT_GITHUB_REPO;
latestRelease = settings.latest_version
? {
version: settings.latest_version,
tagName: `v${settings.latest_version}`,
publishedAt: null, // Only use date from GitHub API, not cached data
htmlUrl: `${githubRepoUrl.replace(/\.git$/, "")}/releases/tag/v${settings.latest_version}`,
}
: null;
}

View File

@@ -0,0 +1,163 @@
const express = require("express");
const { authenticateToken } = require("../middleware/auth");
const {
getConnectionInfo,
subscribeToConnectionChanges,
} = require("../services/agentWs");
const {
validate_session,
update_session_activity,
} = require("../utils/session_manager");
const router = express.Router();
// Get WebSocket connection status for multiple hosts at once (bulk endpoint)
router.get("/status", authenticateToken, async (req, res) => {
try {
const { apiIds } = req.query; // Comma-separated list of api_ids
const idArray = apiIds ? apiIds.split(",").filter((id) => id.trim()) : [];
const statusMap = {};
idArray.forEach((apiId) => {
statusMap[apiId] = getConnectionInfo(apiId);
});
res.json({
success: true,
data: statusMap,
});
} catch (error) {
console.error("Error fetching bulk WebSocket status:", error);
res.status(500).json({
success: false,
error: "Failed to fetch WebSocket status",
});
}
});
// Get WebSocket connection status by api_id (single endpoint)
router.get("/status/:apiId", authenticateToken, async (req, res) => {
try {
const { apiId } = req.params;
// Direct in-memory check - no database query needed
const connectionInfo = getConnectionInfo(apiId);
// Minimal response for maximum speed
res.json({
success: true,
data: connectionInfo,
});
} catch (error) {
console.error("Error fetching WebSocket status:", error);
res.status(500).json({
success: false,
error: "Failed to fetch WebSocket status",
});
}
});
// Server-Sent Events endpoint for real-time status updates (no polling needed!)
router.get("/status/:apiId/stream", async (req, res) => {
try {
const { apiId } = req.params;
// Manual authentication for SSE (EventSource doesn't support custom headers)
const token =
req.query.token || req.headers.authorization?.replace("Bearer ", "");
if (!token) {
return res.status(401).json({ error: "Authentication required" });
}
// Verify token manually with session validation
const jwt = require("jsonwebtoken");
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Validate session (same as regular auth middleware)
const validation = await validate_session(decoded.sessionId, token);
if (!validation.valid) {
return res.status(401).json({ error: "Invalid or expired session" });
}
// Update session activity to prevent inactivity timeout
await update_session_activity(decoded.sessionId);
req.user = validation.user;
} catch (_err) {
return res.status(401).json({ error: "Invalid or expired token" });
}
console.log("[SSE] Client connected for api_id:", apiId);
// Set headers for SSE
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering
// Send initial status immediately
const initialInfo = getConnectionInfo(apiId);
res.write(`data: ${JSON.stringify(initialInfo)}\n\n`);
res.flushHeaders(); // Ensure headers are sent immediately
// Subscribe to connection changes for this specific api_id
const unsubscribe = subscribeToConnectionChanges(apiId, (_connected) => {
try {
// Push update to client instantly when status changes
const connectionInfo = getConnectionInfo(apiId);
console.log(
`[SSE] Pushing status change for ${apiId}: connected=${connectionInfo.connected} secure=${connectionInfo.secure}`,
);
res.write(`data: ${JSON.stringify(connectionInfo)}\n\n`);
} catch (err) {
console.error("[SSE] Error writing to stream:", err);
}
});
// Heartbeat to keep connection alive (every 30 seconds)
const heartbeat = setInterval(() => {
try {
res.write(": heartbeat\n\n");
} catch (err) {
console.error("[SSE] Error writing heartbeat:", err);
clearInterval(heartbeat);
}
}, 30000);
// Cleanup on client disconnect
req.on("close", () => {
console.log("[SSE] Client disconnected for api_id:", apiId);
clearInterval(heartbeat);
unsubscribe();
});
// Handle errors - distinguish between different error types
req.on("error", (err) => {
// Only log non-connection-reset errors to reduce noise
if (err.code !== "ECONNRESET" && err.code !== "EPIPE") {
console.error("[SSE] Request error:", err);
} else {
console.log("[SSE] Client connection reset for api_id:", apiId);
}
clearInterval(heartbeat);
unsubscribe();
});
// Handle response errors
res.on("error", (err) => {
if (err.code !== "ECONNRESET" && err.code !== "EPIPE") {
console.error("[SSE] Response error:", err);
}
clearInterval(heartbeat);
unsubscribe();
});
} catch (error) {
console.error("[SSE] Unexpected error:", error);
if (!res.headersSent) {
res.status(500).json({ error: "Internal server error" });
}
}
});
module.exports = router;

View File

@@ -39,11 +39,12 @@ const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
const cookieParser = require("cookie-parser");
const {
createPrismaClient,
getPrismaClient,
waitForDatabase,
disconnectPrisma,
} = require("./config/database");
} = require("./config/prisma");
const winston = require("winston");
// Import routes
@@ -62,12 +63,22 @@ const versionRoutes = require("./routes/versionRoutes");
const tfaRoutes = require("./routes/tfaRoutes");
const searchRoutes = require("./routes/searchRoutes");
const autoEnrollmentRoutes = require("./routes/autoEnrollmentRoutes");
const updateScheduler = require("./services/updateScheduler");
const gethomepageRoutes = require("./routes/gethomepageRoutes");
const automationRoutes = require("./routes/automationRoutes");
const dockerRoutes = require("./routes/dockerRoutes");
const integrationRoutes = require("./routes/integrationRoutes");
const wsRoutes = require("./routes/wsRoutes");
const agentVersionRoutes = require("./routes/agentVersionRoutes");
const metricsRoutes = require("./routes/metricsRoutes");
const { initSettings } = require("./services/settingsService");
const { cleanup_expired_sessions } = require("./utils/session_manager");
const { queueManager } = require("./services/automation");
const { authenticateToken, requireAdmin } = require("./middleware/auth");
const { createBullBoard } = require("@bull-board/api");
const { BullMQAdapter } = require("@bull-board/api/bullMQAdapter");
const { ExpressAdapter } = require("@bull-board/express");
// Initialize Prisma client with optimized connection pooling for multiple instances
const prisma = createPrismaClient();
const prisma = getPrismaClient();
// Function to check and create default role permissions on startup
async function checkAndCreateRolePermissions() {
@@ -251,6 +262,10 @@ if (process.env.ENABLE_LOGGING === "true") {
const app = express();
const PORT = process.env.PORT || 3001;
const http = require("node:http");
const server = http.createServer(app);
const { init: initAgentWs } = require("./services/agentWs");
const agentVersionService = require("./services/agentVersionService");
// Trust proxy (needed when behind reverse proxy) and remove X-Powered-By
if (process.env.TRUST_PROXY) {
@@ -282,7 +297,7 @@ app.disable("x-powered-by");
// Rate limiting with monitoring
const limiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000,
max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 100,
max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 5000,
message: {
error: "Too many requests from this IP, please try again later.",
retryAfter: Math.ceil(
@@ -332,18 +347,51 @@ const allowedOrigins = parseOrigins(
process.env.CORS_ORIGIN ||
"http://localhost:3000",
);
// Add Bull Board origin to allowed origins if not already present
const bullBoardOrigin = process.env.CORS_ORIGIN || "http://localhost:3000";
if (!allowedOrigins.includes(bullBoardOrigin)) {
allowedOrigins.push(bullBoardOrigin);
}
app.use(
cors({
origin: (origin, callback) => {
// Allow non-browser/SSR tools with no origin
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) return callback(null, true);
// Allow Bull Board requests from the same origin as CORS_ORIGIN
if (origin === bullBoardOrigin) return callback(null, true);
// Allow same-origin requests (e.g., Bull Board accessing its own API)
// This allows http://hostname:3001 to make requests to http://hostname:3001
if (origin?.includes(":3001")) return callback(null, true);
// Allow Bull Board requests from the frontend origin (same host, different port)
// This handles cases where frontend is on port 3000 and backend on 3001
const frontendOrigin = origin?.replace(/:3001$/, ":3000");
if (frontendOrigin && allowedOrigins.includes(frontendOrigin)) {
return callback(null, true);
}
return callback(new Error("Not allowed by CORS"));
},
credentials: true,
// Additional CORS options for better cookie handling
optionsSuccessStatus: 200,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: [
"Content-Type",
"Authorization",
"Cookie",
"X-Requested-With",
],
}),
);
app.use(limiter);
// Cookie parser for Bull Board sessions
app.use(cookieParser());
// Reduce body size limits to reasonable defaults
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || "5mb" }));
app.use(
@@ -378,7 +426,7 @@ const apiVersion = process.env.API_VERSION || "v1";
const authLimiter = rateLimit({
windowMs:
parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 10) || 10 * 60 * 1000,
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 20,
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 500,
message: {
error: "Too many authentication requests, please try again later.",
retryAfter: Math.ceil(
@@ -392,7 +440,7 @@ const authLimiter = rateLimit({
});
const agentLimiter = rateLimit({
windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS, 10) || 60 * 1000,
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX, 10) || 120,
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX, 10) || 1000,
message: {
error: "Too many agent requests, please try again later.",
retryAfter: Math.ceil(
@@ -422,12 +470,415 @@ app.use(
authLimiter,
autoEnrollmentRoutes,
);
app.use(`/api/${apiVersion}/gethomepage`, gethomepageRoutes);
app.use(`/api/${apiVersion}/automation`, automationRoutes);
app.use(`/api/${apiVersion}/docker`, dockerRoutes);
app.use(`/api/${apiVersion}/integrations`, integrationRoutes);
app.use(`/api/${apiVersion}/ws`, wsRoutes);
app.use(`/api/${apiVersion}/agent`, agentVersionRoutes);
app.use(`/api/${apiVersion}/metrics`, metricsRoutes);
// Bull Board - will be populated after queue manager initializes
let bullBoardRouter = null;
const _bullBoardSessions = new Map(); // Store authenticated sessions
// Mount Bull Board at /bullboard for cleaner URL
app.use(`/bullboard`, (_req, res, next) => {
// Relax COOP/COEP for Bull Board in non-production to avoid browser warnings
if (process.env.NODE_ENV !== "production") {
res.setHeader("Cross-Origin-Opener-Policy", "same-origin-allow-popups");
res.setHeader("Cross-Origin-Embedder-Policy", "unsafe-none");
}
// Add headers to help with WebSocket connections
res.setHeader("X-Frame-Options", "SAMEORIGIN");
res.setHeader(
"Content-Security-Policy",
"default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; connect-src 'self' ws: wss:;",
);
next();
});
// Simplified Bull Board authentication - just validate token once and set a simple auth cookie
app.use(`/bullboard`, async (req, res, next) => {
// Skip authentication for static assets
if (req.path.includes("/static/") || req.path.includes("/favicon")) {
return next();
}
// Check for existing Bull Board auth cookie
if (req.cookies["bull-board-auth"]) {
// Already authenticated, allow access
return next();
}
// No auth cookie - check for token in query
const token = req.query.token;
if (!token) {
return res.status(401).json({
error:
"Authentication required. Please access Bull Board from the Automation page.",
});
}
// Validate token and set auth cookie
req.headers.authorization = `Bearer ${token}`;
return authenticateToken(req, res, (err) => {
if (err) {
return res.status(401).json({ error: "Invalid authentication token" });
}
return requireAdmin(req, res, (adminErr) => {
if (adminErr) {
return res.status(403).json({ error: "Admin access required" });
}
// Set a simple auth cookie that will persist for the session
res.cookie("bull-board-auth", token, {
httpOnly: false,
secure: false,
maxAge: 3600000, // 1 hour
path: "/bullboard",
sameSite: "lax",
});
console.log("Bull Board - Authentication successful, cookie set");
return next();
});
});
});
// Remove all the old complex middleware below and replace with the new Bull Board router setup
app.use(`/bullboard`, (req, res, next) => {
if (bullBoardRouter) {
return bullBoardRouter(req, res, next);
}
return res.status(503).json({ error: "Bull Board not initialized yet" });
});
/*
// OLD MIDDLEWARE - REMOVED FOR SIMPLIFICATION - DO NOT USE
if (false) {
const sessionId = req.cookies["bull-board-session"];
console.log("Bull Board API call - Session ID:", sessionId ? "present" : "missing");
console.log("Bull Board API call - Cookies:", req.cookies);
console.log("Bull Board API call - Bull Board token cookie:", req.cookies["bull-board-token"] ? "present" : "missing");
console.log("Bull Board API call - Query token:", req.query.token ? "present" : "missing");
console.log("Bull Board API call - Auth header:", req.headers.authorization ? "present" : "missing");
console.log("Bull Board API call - Origin:", req.headers.origin || "missing");
console.log("Bull Board API call - Referer:", req.headers.referer || "missing");
// Check if we have any authentication method available
const hasSession = !!sessionId;
const hasTokenCookie = !!req.cookies["bull-board-token"];
const hasQueryToken = !!req.query.token;
const hasAuthHeader = !!req.headers.authorization;
const hasReferer = !!req.headers.referer;
console.log("Bull Board API call - Auth methods available:", {
session: hasSession,
tokenCookie: hasTokenCookie,
queryToken: hasQueryToken,
authHeader: hasAuthHeader,
referer: hasReferer
});
// Check for valid session first
if (sessionId) {
const session = bullBoardSessions.get(sessionId);
console.log("Bull Board API call - Session found:", !!session);
if (session && Date.now() - session.timestamp < 3600000) {
// Valid session, extend it
session.timestamp = Date.now();
console.log("Bull Board API call - Using existing session, proceeding");
return next();
} else if (session) {
// Expired session, remove it
console.log("Bull Board API call - Session expired, removing");
bullBoardSessions.delete(sessionId);
}
}
// No valid session, check for token as fallback
let token = req.query.token;
if (!token && req.headers.authorization) {
token = req.headers.authorization.replace("Bearer ", "");
}
if (!token && req.cookies["bull-board-token"]) {
token = req.cookies["bull-board-token"];
}
// For API calls, also check if the token is in the referer URL
// This handles cases where the main page hasn't set the cookie yet
if (!token && req.headers.referer) {
try {
const refererUrl = new URL(req.headers.referer);
const refererToken = refererUrl.searchParams.get('token');
if (refererToken) {
token = refererToken;
console.log("Bull Board API call - Token found in referer URL:", refererToken.substring(0, 20) + "...");
} else {
console.log("Bull Board API call - No token found in referer URL");
// If no token in referer and no session, return 401 with redirect info
if (!sessionId) {
console.log("Bull Board API call - No authentication available, returning 401");
return res.status(401).json({
error: "Authentication required",
message: "Please refresh the page to re-authenticate"
});
}
}
} catch (error) {
console.log("Bull Board API call - Error parsing referer URL:", error.message);
}
}
if (token) {
console.log("Bull Board API call - Token found, authenticating");
// Add token to headers for authentication
req.headers.authorization = `Bearer ${token}`;
// Authenticate the user
return authenticateToken(req, res, (err) => {
if (err) {
console.log("Bull Board API call - Token authentication failed");
return res.status(401).json({ error: "Authentication failed" });
}
return requireAdmin(req, res, (adminErr) => {
if (adminErr) {
console.log("Bull Board API call - Admin access required");
return res.status(403).json({ error: "Admin access required" });
}
console.log("Bull Board API call - Token authentication successful");
return next();
});
});
}
// No valid session or token for API calls, deny access
console.log("Bull Board API call - No valid session or token, denying access");
return res.status(401).json({ error: "Valid Bull Board session or token required" });
}
// Check for bull-board-session cookie first
const sessionId = req.cookies["bull-board-session"];
if (sessionId) {
const session = bullBoardSessions.get(sessionId);
if (session && Date.now() - session.timestamp < 3600000) {
// 1 hour
// Valid session, extend it
session.timestamp = Date.now();
return next();
} else if (session) {
// Expired session, remove it
bullBoardSessions.delete(sessionId);
}
}
// No valid session, check for token
let token = req.query.token;
if (!token && req.headers.authorization) {
token = req.headers.authorization.replace("Bearer ", "");
}
if (!token && req.cookies["bull-board-token"]) {
token = req.cookies["bull-board-token"];
}
// If no token, deny access
if (!token) {
return res.status(401).json({ error: "Access token required" });
}
// Add token to headers for authentication
req.headers.authorization = `Bearer ${token}`;
// Authenticate the user
return authenticateToken(req, res, (err) => {
if (err) {
return res.status(401).json({ error: "Authentication failed" });
}
return requireAdmin(req, res, (adminErr) => {
if (adminErr) {
return res.status(403).json({ error: "Admin access required" });
}
// Authentication successful - create a session
const newSessionId = require("node:crypto")
.randomBytes(32)
.toString("hex");
bullBoardSessions.set(newSessionId, {
timestamp: Date.now(),
userId: req.user.id,
});
// Set session cookie with proper configuration for domain access
const isHttps = process.env.NODE_ENV === "production" || process.env.SERVER_PROTOCOL === "https";
const cookieOptions = {
httpOnly: true,
secure: isHttps,
maxAge: 3600000, // 1 hour
path: "/", // Set path to root so it's available for all Bull Board requests
};
// Configure sameSite based on protocol and environment
if (isHttps) {
cookieOptions.sameSite = "none"; // Required for HTTPS cross-origin
} else {
cookieOptions.sameSite = "lax"; // Better for HTTP same-origin
}
res.cookie("bull-board-session", newSessionId, cookieOptions);
// Clean up old sessions periodically
if (bullBoardSessions.size > 100) {
const now = Date.now();
for (const [sid, session] of bullBoardSessions.entries()) {
if (now - session.timestamp > 3600000) {
bullBoardSessions.delete(sid);
}
}
}
return next();
});
});
});
*/
// Second middleware block - COMMENTED OUT - using simplified version above instead
/*
app.use(`/bullboard`, (req, res, next) => {
if (bullBoardRouter) {
// If this is the main Bull Board page (not an API call), inject the token and create session
if (!req.path.includes("/api/") && !req.path.includes("/static/") && req.path === "/bullboard") {
const token = req.query.token;
console.log("Bull Board main page - Token:", token ? "present" : "missing");
console.log("Bull Board main page - Query params:", req.query);
console.log("Bull Board main page - Origin:", req.headers.origin || "missing");
console.log("Bull Board main page - Referer:", req.headers.referer || "missing");
console.log("Bull Board main page - Cookies:", req.cookies);
if (token) {
// Authenticate the user and create a session immediately on page load
req.headers.authorization = `Bearer ${token}`;
return authenticateToken(req, res, (err) => {
if (err) {
console.log("Bull Board main page - Token authentication failed");
return res.status(401).json({ error: "Authentication failed" });
}
return requireAdmin(req, res, (adminErr) => {
if (adminErr) {
console.log("Bull Board main page - Admin access required");
return res.status(403).json({ error: "Admin access required" });
}
console.log("Bull Board main page - Token authentication successful, creating session");
// Create a Bull Board session immediately
const newSessionId = require("node:crypto")
.randomBytes(32)
.toString("hex");
bullBoardSessions.set(newSessionId, {
timestamp: Date.now(),
userId: req.user.id,
});
// Set session cookie with proper configuration for domain access
const sessionCookieOptions = {
httpOnly: true,
secure: false, // Always false for HTTP
maxAge: 3600000, // 1 hour
path: "/", // Set path to root so it's available for all Bull Board requests
sameSite: "lax", // Always lax for HTTP
};
res.cookie("bull-board-session", newSessionId, sessionCookieOptions);
console.log("Bull Board main page - Session created:", newSessionId);
console.log("Bull Board main page - Cookie options:", sessionCookieOptions);
// Also set a token cookie for API calls as a fallback
const tokenCookieOptions = {
httpOnly: false, // Allow JavaScript to access it
secure: false, // Always false for HTTP
maxAge: 3600000, // 1 hour
path: "/", // Set path to root for broader compatibility
sameSite: "lax", // Always lax for HTTP
};
res.cookie("bull-board-token", token, tokenCookieOptions);
console.log("Bull Board main page - Token cookie also set for API fallback");
// Clean up old sessions periodically
if (bullBoardSessions.size > 100) {
const now = Date.now();
for (const [sid, session] of bullBoardSessions.entries()) {
if (now - session.timestamp > 3600000) {
bullBoardSessions.delete(sid);
}
}
}
// Now proceed to serve the Bull Board page
return bullBoardRouter(req, res, next);
});
});
} else {
console.log("Bull Board main page - No token provided, checking for existing session");
// Check if we have an existing session
const sessionId = req.cookies["bull-board-session"];
if (sessionId) {
const session = bullBoardSessions.get(sessionId);
if (session && Date.now() - session.timestamp < 3600000) {
console.log("Bull Board main page - Using existing session");
// Extend session
session.timestamp = Date.now();
return bullBoardRouter(req, res, next);
} else if (session) {
console.log("Bull Board main page - Session expired, removing");
bullBoardSessions.delete(sessionId);
}
}
console.log("Bull Board main page - No valid session, denying access");
return res.status(401).json({ error: "Access token required" });
}
}
return bullBoardRouter(req, res, next);
}
return res.status(503).json({ error: "Bull Board not initialized yet" });
});
*/
// Error handler specifically for Bull Board routes
app.use("/bullboard", (err, req, res, _next) => {
console.error("Bull Board error on", req.method, req.url);
console.error("Error details:", err.message);
console.error("Stack:", err.stack);
if (process.env.ENABLE_LOGGING === "true") {
logger.error(`Bull Board error on ${req.method} ${req.url}:`, err);
}
res.status(500).json({
error: "Internal server error",
message: err.message,
path: req.path,
url: req.url,
});
});
// Error handling middleware
app.use((err, _req, res, _next) => {
if (process.env.ENABLE_LOGGING === "true") {
logger.error(err.stack);
}
// Special handling for CORS errors - always include the message
if (err.message?.includes("Not allowed by CORS")) {
return res.status(500).json({
error: "Something went wrong!",
message: err.message, // Always include CORS error message
});
}
res.status(500).json({
error: "Something went wrong!",
message: process.env.NODE_ENV === "development" ? err.message : undefined,
@@ -444,10 +895,7 @@ process.on("SIGINT", async () => {
if (process.env.ENABLE_LOGGING === "true") {
logger.info("SIGINT received, shutting down gracefully");
}
if (app.locals.session_cleanup_interval) {
clearInterval(app.locals.session_cleanup_interval);
}
updateScheduler.stop();
await queueManager.shutdown();
await disconnectPrisma(prisma);
process.exit(0);
});
@@ -456,10 +904,7 @@ process.on("SIGTERM", async () => {
if (process.env.ENABLE_LOGGING === "true") {
logger.info("SIGTERM received, shutting down gracefully");
}
if (app.locals.session_cleanup_interval) {
clearInterval(app.locals.session_cleanup_interval);
}
updateScheduler.stop();
await queueManager.shutdown();
await disconnectPrisma(prisma);
process.exit(0);
});
@@ -674,11 +1119,16 @@ async function getPermissionBasedPreferences(userRole) {
requiredPermission: "can_view_packages",
order: 13,
},
{ cardId: "recentUsers", requiredPermission: "can_view_users", order: 14 },
{
cardId: "packageTrends",
requiredPermission: "can_view_packages",
order: 14,
},
{ cardId: "recentUsers", requiredPermission: "can_view_users", order: 15 },
{
cardId: "quickStats",
requiredPermission: "can_view_dashboard",
order: 15,
order: 16,
},
];
@@ -723,34 +1173,50 @@ async function startServer() {
// Initialize dashboard preferences for all users
await initializeDashboardPreferences();
// Initial session cleanup
await cleanup_expired_sessions();
// Initialize BullMQ queue manager
await queueManager.initialize();
// Schedule session cleanup every hour
const session_cleanup_interval = setInterval(
async () => {
try {
await cleanup_expired_sessions();
} catch (error) {
console.error("Session cleanup error:", error);
}
},
60 * 60 * 1000,
); // Every hour
// Schedule recurring jobs
await queueManager.scheduleAllJobs();
app.listen(PORT, () => {
// Set up Bull Board for queue monitoring
const serverAdapter = new ExpressAdapter();
// Set basePath to match where we mount the router
serverAdapter.setBasePath("/bullboard");
const { QUEUE_NAMES } = require("./services/automation");
const bullAdapters = Object.values(QUEUE_NAMES).map(
(queueName) => new BullMQAdapter(queueManager.queues[queueName]),
);
createBullBoard({
queues: bullAdapters,
serverAdapter: serverAdapter,
});
// Set the router for the Bull Board middleware (secured middleware above)
bullBoardRouter = serverAdapter.getRouter();
console.log("✅ Bull Board mounted at /bullboard (secured)");
// Initialize WS layer with the underlying HTTP server
initAgentWs(server, prisma);
await agentVersionService.initialize();
// Send metrics on startup (silent - no console output)
try {
const metricsReporting =
queueManager.automations[QUEUE_NAMES.METRICS_REPORTING];
await metricsReporting.sendSilent();
} catch (_error) {
// Silent failure - don't block server startup if metrics fail
}
server.listen(PORT, () => {
if (process.env.ENABLE_LOGGING === "true") {
logger.info(`Server running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV}`);
logger.info("✅ Session cleanup scheduled (every hour)");
}
// Start update scheduler
updateScheduler.start();
});
// Store interval for cleanup on shutdown
app.locals.session_cleanup_interval = session_cleanup_interval;
} catch (error) {
console.error("❌ Failed to start server:", error.message);
process.exit(1);

View File

@@ -0,0 +1,746 @@
const axios = require("axios");
const fs = require("node:fs").promises;
const path = require("node:path");
const { exec, spawn } = require("node:child_process");
const { promisify } = require("node:util");
const _execAsync = promisify(exec);
// Simple semver comparison function
function compareVersions(version1, version2) {
const v1parts = version1.split(".").map(Number);
const v2parts = version2.split(".").map(Number);
// Ensure both arrays have the same length
while (v1parts.length < 3) v1parts.push(0);
while (v2parts.length < 3) v2parts.push(0);
for (let i = 0; i < 3; i++) {
if (v1parts[i] > v2parts[i]) return 1;
if (v1parts[i] < v2parts[i]) return -1;
}
return 0;
}
const crypto = require("node:crypto");
class AgentVersionService {
constructor() {
this.githubApiUrl =
"https://api.github.com/repos/PatchMon/PatchMon-agent/releases";
this.agentsDir = path.resolve(__dirname, "../../../agents");
this.supportedArchitectures = [
"linux-amd64",
"linux-arm64",
"linux-386",
"linux-arm",
];
this.currentVersion = null;
this.latestVersion = null;
this.lastChecked = null;
this.checkInterval = 30 * 60 * 1000; // 30 minutes
}
async initialize() {
try {
// Ensure agents directory exists
await fs.mkdir(this.agentsDir, { recursive: true });
console.log("🔍 Testing GitHub API connectivity...");
try {
const testResponse = await axios.get(
"https://api.github.com/repos/PatchMon/PatchMon-agent/releases",
{
timeout: 5000,
headers: {
"User-Agent": "PatchMon-Server/1.0",
Accept: "application/vnd.github.v3+json",
},
},
);
console.log(
`✅ GitHub API accessible - found ${testResponse.data.length} releases`,
);
} catch (testError) {
console.error("❌ GitHub API not accessible:", testError.message);
if (testError.response) {
console.error(
"❌ Status:",
testError.response.status,
testError.response.statusText,
);
if (testError.response.status === 403) {
console.log("⚠️ GitHub API rate limit exceeded - will retry later");
}
}
}
// Get current agent version by executing the binary
await this.getCurrentAgentVersion();
// Try to check for updates, but don't fail initialization if GitHub API is unavailable
try {
await this.checkForUpdates();
} catch (updateError) {
console.log(
"⚠️ Failed to check for updates on startup, will retry later:",
updateError.message,
);
}
// Set up periodic checking
setInterval(() => {
this.checkForUpdates().catch((error) => {
console.log("⚠️ Periodic update check failed:", error.message);
});
}, this.checkInterval);
console.log("✅ Agent Version Service initialized");
} catch (error) {
console.error(
"❌ Failed to initialize Agent Version Service:",
error.message,
);
}
}
async getCurrentAgentVersion() {
try {
console.log("🔍 Getting current agent version...");
// Try to find the agent binary in agents/ folder only (what gets distributed)
const possiblePaths = [
path.join(this.agentsDir, "patchmon-agent-linux-amd64"),
path.join(this.agentsDir, "patchmon-agent"),
];
let agentPath = null;
for (const testPath of possiblePaths) {
try {
await fs.access(testPath);
agentPath = testPath;
console.log(`✅ Found agent binary at: ${testPath}`);
break;
} catch {
// Path doesn't exist, continue to next
}
}
if (!agentPath) {
console.log(
"⚠️ No agent binary found in agents/ folder, current version will be unknown",
);
console.log("💡 Use the Download Updates button to get agent binaries");
this.currentVersion = null;
return;
}
// Execute the agent binary with help flag to get version info
try {
const child = spawn(agentPath, ["--help"], {
timeout: 10000,
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (data) => {
stdout += data.toString();
});
child.stderr.on("data", (data) => {
stderr += data.toString();
});
const result = await new Promise((resolve, reject) => {
child.on("close", (code) => {
resolve({ stdout, stderr, code });
});
child.on("error", reject);
});
if (result.stderr) {
console.log("⚠️ Agent help stderr:", result.stderr);
}
// Parse version from help output (e.g., "PatchMon Agent v1.3.0")
const versionMatch = result.stdout.match(
/PatchMon Agent v([0-9]+\.[0-9]+\.[0-9]+)/i,
);
if (versionMatch) {
this.currentVersion = versionMatch[1];
console.log(`✅ Current agent version: ${this.currentVersion}`);
} else {
console.log(
"⚠️ Could not parse version from agent help output:",
result.stdout,
);
this.currentVersion = null;
}
} catch (execError) {
console.error("❌ Failed to execute agent binary:", execError.message);
this.currentVersion = null;
}
} catch (error) {
console.error("❌ Failed to get current agent version:", error.message);
this.currentVersion = null;
}
}
async checkForUpdates() {
try {
console.log("🔍 Checking for agent updates...");
const response = await axios.get(this.githubApiUrl, {
timeout: 10000,
headers: {
"User-Agent": "PatchMon-Server/1.0",
Accept: "application/vnd.github.v3+json",
},
});
console.log(`📡 GitHub API response status: ${response.status}`);
console.log(`📦 Found ${response.data.length} releases`);
const releases = response.data;
if (releases.length === 0) {
console.log(" No releases found");
this.latestVersion = null;
this.lastChecked = new Date();
return {
latestVersion: null,
currentVersion: this.currentVersion,
hasUpdate: false,
lastChecked: this.lastChecked,
};
}
const latestRelease = releases[0];
this.latestVersion = latestRelease.tag_name.replace("v", ""); // Remove 'v' prefix
this.lastChecked = new Date();
console.log(`📦 Latest agent version: ${this.latestVersion}`);
// Don't download binaries automatically - only when explicitly requested
console.log(
" Skipping automatic binary download - binaries will be downloaded on demand",
);
return {
latestVersion: this.latestVersion,
currentVersion: this.currentVersion,
hasUpdate: this.currentVersion !== this.latestVersion,
lastChecked: this.lastChecked,
};
} catch (error) {
console.error("❌ Failed to check for updates:", error.message);
if (error.response) {
console.error(
"❌ GitHub API error:",
error.response.status,
error.response.statusText,
);
console.error(
"❌ Rate limit info:",
error.response.headers["x-ratelimit-remaining"],
"/",
error.response.headers["x-ratelimit-limit"],
);
}
throw error;
}
}
async downloadBinariesToAgentsFolder(release) {
try {
console.log(
`⬇️ Downloading binaries for version ${release.tag_name} to agents folder...`,
);
for (const arch of this.supportedArchitectures) {
const assetName = `patchmon-agent-${arch}`;
const asset = release.assets.find((a) => a.name === assetName);
if (!asset) {
console.warn(`⚠️ Binary not found for architecture: ${arch}`);
continue;
}
const binaryPath = path.join(this.agentsDir, assetName);
console.log(`⬇️ Downloading ${assetName}...`);
const response = await axios.get(asset.browser_download_url, {
responseType: "stream",
timeout: 60000,
});
const writer = require("node:fs").createWriteStream(binaryPath);
response.data.pipe(writer);
await new Promise((resolve, reject) => {
writer.on("finish", resolve);
writer.on("error", reject);
});
// Make executable
await fs.chmod(binaryPath, "755");
console.log(`✅ Downloaded: ${assetName} to agents folder`);
}
} catch (error) {
console.error(
"❌ Failed to download binaries to agents folder:",
error.message,
);
throw error;
}
}
async downloadBinaryForVersion(version, architecture) {
try {
console.log(
`⬇️ Downloading binary for version ${version} architecture ${architecture}...`,
);
// Get the release info from GitHub
const response = await axios.get(this.githubApiUrl, {
timeout: 10000,
headers: {
"User-Agent": "PatchMon-Server/1.0",
Accept: "application/vnd.github.v3+json",
},
});
const releases = response.data;
const release = releases.find(
(r) => r.tag_name.replace("v", "") === version,
);
if (!release) {
throw new Error(`Release ${version} not found`);
}
const assetName = `patchmon-agent-${architecture}`;
const asset = release.assets.find((a) => a.name === assetName);
if (!asset) {
throw new Error(`Binary not found for architecture: ${architecture}`);
}
const binaryPath = path.join(
this.agentBinariesDir,
`${release.tag_name}-${assetName}`,
);
console.log(`⬇️ Downloading ${assetName}...`);
const downloadResponse = await axios.get(asset.browser_download_url, {
responseType: "stream",
timeout: 60000,
});
const writer = require("node:fs").createWriteStream(binaryPath);
downloadResponse.data.pipe(writer);
await new Promise((resolve, reject) => {
writer.on("finish", resolve);
writer.on("error", reject);
});
// Make executable
await fs.chmod(binaryPath, "755");
console.log(`✅ Downloaded: ${assetName}`);
return binaryPath;
} catch (error) {
console.error(
`❌ Failed to download binary ${version}-${architecture}:`,
error.message,
);
throw error;
}
}
async getBinaryPath(version, architecture) {
const binaryName = `patchmon-agent-${architecture}`;
const binaryPath = path.join(this.agentsDir, binaryName);
try {
await fs.access(binaryPath);
return binaryPath;
} catch {
throw new Error(`Binary not found: ${binaryName} version ${version}`);
}
}
async serveBinary(version, architecture, res) {
try {
// Check if binary exists, if not download it
const binaryPath = await this.getBinaryPath(version, architecture);
const stats = await fs.stat(binaryPath);
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader(
"Content-Disposition",
`attachment; filename="patchmon-agent-${architecture}"`,
);
res.setHeader("Content-Length", stats.size);
// Add cache headers
res.setHeader("Cache-Control", "public, max-age=3600");
res.setHeader("ETag", `"${version}-${architecture}"`);
const stream = require("node:fs").createReadStream(binaryPath);
stream.pipe(res);
} catch (_error) {
// Binary doesn't exist, try to download it
console.log(
`⬇️ Binary not found locally, attempting to download ${version}-${architecture}...`,
);
try {
await this.downloadBinaryForVersion(version, architecture);
// Retry serving the binary
const binaryPath = await this.getBinaryPath(version, architecture);
const stats = await fs.stat(binaryPath);
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader(
"Content-Disposition",
`attachment; filename="patchmon-agent-${architecture}"`,
);
res.setHeader("Content-Length", stats.size);
res.setHeader("Cache-Control", "public, max-age=3600");
res.setHeader("ETag", `"${version}-${architecture}"`);
const stream = require("node:fs").createReadStream(binaryPath);
stream.pipe(res);
} catch (downloadError) {
console.error(
`❌ Failed to download binary ${version}-${architecture}:`,
downloadError.message,
);
res
.status(404)
.json({ error: "Binary not found and could not be downloaded" });
}
}
}
async getVersionInfo() {
let hasUpdate = false;
let updateStatus = "unknown";
// Latest version should ALWAYS come from GitHub, not from local binaries
// currentVersion = what's installed locally
// latestVersion = what's available on GitHub
if (this.latestVersion) {
console.log(`📦 Latest version from GitHub: ${this.latestVersion}`);
} else {
console.log(
`⚠️ No GitHub release version available (API may be unavailable)`,
);
}
if (this.currentVersion) {
console.log(`💾 Current local agent version: ${this.currentVersion}`);
} else {
console.log(`⚠️ No local agent binary found`);
}
// Determine update status by comparing current vs latest (from GitHub)
if (this.currentVersion && this.latestVersion) {
const comparison = compareVersions(
this.currentVersion,
this.latestVersion,
);
if (comparison < 0) {
hasUpdate = true;
updateStatus = "update-available";
} else if (comparison > 0) {
hasUpdate = false;
updateStatus = "newer-version";
} else {
hasUpdate = false;
updateStatus = "up-to-date";
}
} else if (this.latestVersion && !this.currentVersion) {
hasUpdate = true;
updateStatus = "no-agent";
} else if (this.currentVersion && !this.latestVersion) {
// We have a current version but no latest version (GitHub API unavailable)
hasUpdate = false;
updateStatus = "github-unavailable";
} else if (!this.currentVersion && !this.latestVersion) {
updateStatus = "no-data";
}
return {
currentVersion: this.currentVersion,
latestVersion: this.latestVersion, // Always return GitHub version, not local
hasUpdate: hasUpdate,
updateStatus: updateStatus,
lastChecked: this.lastChecked,
supportedArchitectures: this.supportedArchitectures,
status: this.latestVersion ? "ready" : "no-releases",
};
}
async refreshCurrentVersion() {
await this.getCurrentAgentVersion();
return this.currentVersion;
}
async downloadLatestUpdate() {
try {
console.log("⬇️ Downloading latest agent update...");
// First check for updates to get the latest release info
const _updateInfo = await this.checkForUpdates();
if (!this.latestVersion) {
throw new Error("No latest version available to download");
}
// Get the release info from GitHub
const response = await axios.get(this.githubApiUrl, {
timeout: 10000,
headers: {
"User-Agent": "PatchMon-Server/1.0",
Accept: "application/vnd.github.v3+json",
},
});
const releases = response.data;
const latestRelease = releases[0];
if (!latestRelease) {
throw new Error("No releases found");
}
console.log(
`⬇️ Downloading binaries for version ${latestRelease.tag_name}...`,
);
// Download binaries for all architectures directly to agents folder
await this.downloadBinariesToAgentsFolder(latestRelease);
console.log("✅ Latest update downloaded successfully");
return {
success: true,
version: this.latestVersion,
downloadedArchitectures: this.supportedArchitectures,
message: `Successfully downloaded version ${this.latestVersion}`,
};
} catch (error) {
console.error("❌ Failed to download latest update:", error.message);
throw error;
}
}
async getAvailableVersions() {
// No local caching - only return latest from GitHub
if (this.latestVersion) {
return [this.latestVersion];
}
return [];
}
async getBinaryInfo(version, architecture) {
try {
// Always use local version if it matches the requested version
if (version === this.currentVersion && this.currentVersion) {
const binaryPath = await this.getBinaryPath(
this.currentVersion,
architecture,
);
const stats = await fs.stat(binaryPath);
// Calculate file hash
const fileBuffer = await fs.readFile(binaryPath);
const hash = crypto
.createHash("sha256")
.update(fileBuffer)
.digest("hex");
return {
version: this.currentVersion,
architecture,
size: stats.size,
hash,
lastModified: stats.mtime,
path: binaryPath,
};
}
// For other versions, try to find them in the agents folder
const binaryPath = await this.getBinaryPath(version, architecture);
const stats = await fs.stat(binaryPath);
// Calculate file hash
const fileBuffer = await fs.readFile(binaryPath);
const hash = crypto.createHash("sha256").update(fileBuffer).digest("hex");
return {
version,
architecture,
size: stats.size,
hash,
lastModified: stats.mtime,
path: binaryPath,
};
} catch (error) {
throw new Error(`Failed to get binary info: ${error.message}`);
}
}
/**
* Check if an agent needs an update and push notification if needed
* @param {string} agentApiId - The agent's API ID
* @param {string} agentVersion - The agent's current version
* @param {boolean} force - Force update regardless of version
* @returns {Object} Update check result
*/
async checkAndPushAgentUpdate(agentApiId, agentVersion, force = false) {
try {
console.log(
`🔍 Checking update for agent ${agentApiId} (version: ${agentVersion})`,
);
// Get current server version info
const versionInfo = await this.getVersionInfo();
if (!versionInfo.latestVersion) {
console.log(`⚠️ No latest version available for agent ${agentApiId}`);
return {
needsUpdate: false,
reason: "no-latest-version",
message: "No latest version available on server",
};
}
// Compare versions
const comparison = compareVersions(
agentVersion,
versionInfo.latestVersion,
);
const needsUpdate = force || comparison < 0;
if (needsUpdate) {
console.log(
`📤 Agent ${agentApiId} needs update: ${agentVersion}${versionInfo.latestVersion}`,
);
// Import agentWs service to push notification
const { pushUpdateNotification } = require("./agentWs");
const updateInfo = {
version: versionInfo.latestVersion,
force: force,
downloadUrl: `/api/v1/agent/binary/${versionInfo.latestVersion}/linux-amd64`,
message: force
? "Force update requested"
: `Update available: ${versionInfo.latestVersion}`,
};
const pushed = pushUpdateNotification(agentApiId, updateInfo);
if (pushed) {
console.log(`✅ Update notification pushed to agent ${agentApiId}`);
return {
needsUpdate: true,
reason: force ? "force-update" : "version-outdated",
message: `Update notification sent: ${agentVersion}${versionInfo.latestVersion}`,
targetVersion: versionInfo.latestVersion,
};
} else {
console.log(
`⚠️ Failed to push update notification to agent ${agentApiId} (not connected)`,
);
return {
needsUpdate: true,
reason: "agent-offline",
message: "Agent needs update but is not connected",
targetVersion: versionInfo.latestVersion,
};
}
} else {
console.log(`✅ Agent ${agentApiId} is up to date: ${agentVersion}`);
return {
needsUpdate: false,
reason: "up-to-date",
message: `Agent is up to date: ${agentVersion}`,
};
}
} catch (error) {
console.error(
`❌ Failed to check update for agent ${agentApiId}:`,
error.message,
);
return {
needsUpdate: false,
reason: "error",
message: `Error checking update: ${error.message}`,
};
}
}
/**
* Check and push updates to all connected agents
* @param {boolean} force - Force update regardless of version
* @returns {Object} Bulk update result
*/
async checkAndPushUpdatesToAll(force = false) {
try {
console.log(
`🔍 Checking updates for all connected agents (force: ${force})`,
);
// Import agentWs service to get connected agents
const { pushUpdateNotificationToAll } = require("./agentWs");
const versionInfo = await this.getVersionInfo();
if (!versionInfo.latestVersion) {
return {
success: false,
message: "No latest version available on server",
updatedAgents: 0,
totalAgents: 0,
};
}
const updateInfo = {
version: versionInfo.latestVersion,
force: force,
downloadUrl: `/api/v1/agent/binary/${versionInfo.latestVersion}/linux-amd64`,
message: force
? "Force update requested for all agents"
: `Update available: ${versionInfo.latestVersion}`,
};
const result = await pushUpdateNotificationToAll(updateInfo);
console.log(
`✅ Bulk update notification sent to ${result.notifiedCount} agents`,
);
return {
success: true,
message: `Update notifications sent to ${result.notifiedCount} agents`,
updatedAgents: result.notifiedCount,
totalAgents: result.totalAgents,
targetVersion: versionInfo.latestVersion,
};
} catch (error) {
console.error("❌ Failed to push updates to all agents:", error.message);
return {
success: false,
message: `Error pushing updates: ${error.message}`,
updatedAgents: 0,
totalAgents: 0,
};
}
}
}
module.exports = new AgentVersionService();

View File

@@ -0,0 +1,363 @@
// Lightweight WebSocket hub for agent connections
// Auth: X-API-ID / X-API-KEY headers on the upgrade request
const WebSocket = require("ws");
const url = require("node:url");
// Connection registry by api_id
const apiIdToSocket = new Map();
// Connection metadata (secure/insecure)
// Map<api_id, { ws: WebSocket, secure: boolean }>
const connectionMetadata = new Map();
// Subscribers for connection status changes (for SSE)
// Map<api_id, Set<callback>>
const connectionChangeSubscribers = new Map();
let wss;
let prisma;
function init(server, prismaClient) {
prisma = prismaClient;
wss = new WebSocket.Server({ noServer: true });
// Handle HTTP upgrade events and authenticate before accepting WS
server.on("upgrade", async (request, socket, head) => {
try {
const { pathname } = url.parse(request.url);
if (!pathname) {
socket.destroy();
return;
}
// Handle Bull Board WebSocket connections
if (pathname.startsWith("/bullboard")) {
// For Bull Board, we need to check if the user is authenticated
// Check for session cookie or authorization header
const sessionCookie = request.headers.cookie?.match(
/bull-board-session=([^;]+)/,
)?.[1];
const authHeader = request.headers.authorization;
if (!sessionCookie && !authHeader) {
socket.destroy();
return;
}
// Accept the WebSocket connection for Bull Board
wss.handleUpgrade(request, socket, head, (ws) => {
ws.on("message", (message) => {
// Echo back for Bull Board WebSocket
ws.send(message);
});
});
return;
}
// Handle agent WebSocket connections
if (!pathname.startsWith("/api/")) {
socket.destroy();
return;
}
// Expected path: /api/{v}/agents/ws
const parts = pathname.split("/").filter(Boolean); // [api, v1, agents, ws]
if (parts.length !== 4 || parts[2] !== "agents" || parts[3] !== "ws") {
socket.destroy();
return;
}
const apiId = request.headers["x-api-id"];
const apiKey = request.headers["x-api-key"];
if (!apiId || !apiKey) {
socket.destroy();
return;
}
// Validate credentials
const host = await prisma.hosts.findUnique({ where: { api_id: apiId } });
if (!host || host.api_key !== apiKey) {
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
ws.apiId = apiId;
// Detect if connection is secure (wss://) or not (ws://)
const isSecure =
socket.encrypted || request.headers["x-forwarded-proto"] === "https";
apiIdToSocket.set(apiId, ws);
connectionMetadata.set(apiId, { ws, secure: isSecure });
console.log(
`[agent-ws] connected api_id=${apiId} protocol=${isSecure ? "wss" : "ws"} total=${apiIdToSocket.size}`,
);
// Notify subscribers of connection
notifyConnectionChange(apiId, true);
ws.on("message", async (data) => {
// Handle incoming messages from agent (e.g., Docker status updates)
try {
const message = JSON.parse(data.toString());
if (message.type === "docker_status") {
// Handle Docker container status events
await handleDockerStatusEvent(apiId, message);
}
// Add more message types here as needed
} catch (err) {
console.error(
`[agent-ws] error parsing message from ${apiId}:`,
err,
);
}
});
ws.on("close", () => {
const existing = apiIdToSocket.get(apiId);
if (existing === ws) {
apiIdToSocket.delete(apiId);
connectionMetadata.delete(apiId);
// Notify subscribers of disconnection
notifyConnectionChange(apiId, false);
}
console.log(
`[agent-ws] disconnected api_id=${apiId} total=${apiIdToSocket.size}`,
);
});
// Optional: greet/ack
safeSend(ws, JSON.stringify({ type: "connected" }));
});
} catch (_err) {
try {
socket.destroy();
} catch {
/* ignore */
}
}
});
}
function safeSend(ws, data) {
if (ws && ws.readyState === WebSocket.OPEN) {
try {
ws.send(data);
} catch {
/* ignore */
}
}
}
function broadcastSettingsUpdate(newInterval) {
const payload = JSON.stringify({
type: "settings_update",
update_interval: newInterval,
});
for (const [, ws] of apiIdToSocket) {
safeSend(ws, payload);
}
}
function pushReportNow(apiId) {
const ws = apiIdToSocket.get(apiId);
safeSend(ws, JSON.stringify({ type: "report_now" }));
}
function pushSettingsUpdate(apiId, newInterval) {
const ws = apiIdToSocket.get(apiId);
safeSend(
ws,
JSON.stringify({ type: "settings_update", update_interval: newInterval }),
);
}
function pushUpdateAgent(apiId) {
const ws = apiIdToSocket.get(apiId);
safeSend(ws, JSON.stringify({ type: "update_agent" }));
}
function getConnectionByApiId(apiId) {
return apiIdToSocket.get(apiId);
}
function pushUpdateNotification(apiId, updateInfo) {
const ws = apiIdToSocket.get(apiId);
if (ws && ws.readyState === WebSocket.OPEN) {
safeSend(
ws,
JSON.stringify({
type: "update_notification",
version: updateInfo.version,
force: updateInfo.force || false,
downloadUrl: updateInfo.downloadUrl,
message: updateInfo.message,
}),
);
console.log(
`📤 Pushed update notification to agent ${apiId}: version ${updateInfo.version}`,
);
return true;
} else {
console.log(
`⚠️ Agent ${apiId} not connected, cannot push update notification`,
);
return false;
}
}
async function pushUpdateNotificationToAll(updateInfo) {
let notifiedCount = 0;
let failedCount = 0;
for (const [apiId, ws] of apiIdToSocket) {
if (ws && ws.readyState === WebSocket.OPEN) {
try {
safeSend(
ws,
JSON.stringify({
type: "update_notification",
version: updateInfo.version,
force: updateInfo.force || false,
message: updateInfo.message,
}),
);
notifiedCount++;
console.log(
`📤 Pushed update notification to agent ${apiId}: version ${updateInfo.version}`,
);
} catch (error) {
failedCount++;
console.error(`❌ Failed to notify agent ${apiId}:`, error.message);
}
} else {
failedCount++;
}
}
console.log(
`📤 Update notification sent to ${notifiedCount} agents, ${failedCount} failed`,
);
return { notifiedCount, failedCount };
}
// Notify all subscribers when connection status changes
function notifyConnectionChange(apiId, connected) {
const subscribers = connectionChangeSubscribers.get(apiId);
if (subscribers) {
for (const callback of subscribers) {
try {
callback(connected);
} catch (err) {
console.error(`[agent-ws] error notifying subscriber:`, err);
}
}
}
}
// Subscribe to connection status changes for a specific api_id
function subscribeToConnectionChanges(apiId, callback) {
if (!connectionChangeSubscribers.has(apiId)) {
connectionChangeSubscribers.set(apiId, new Set());
}
connectionChangeSubscribers.get(apiId).add(callback);
// Return unsubscribe function
return () => {
const subscribers = connectionChangeSubscribers.get(apiId);
if (subscribers) {
subscribers.delete(callback);
if (subscribers.size === 0) {
connectionChangeSubscribers.delete(apiId);
}
}
};
}
// Handle Docker container status events from agent
async function handleDockerStatusEvent(apiId, message) {
try {
const { event: _event, container_id, name, status, timestamp } = message;
console.log(
`[Docker Event] ${apiId}: Container ${name} (${container_id}) - ${status}`,
);
// Find the host
const host = await prisma.hosts.findUnique({
where: { api_id: apiId },
});
if (!host) {
console.error(`[Docker Event] Host not found for api_id: ${apiId}`);
return;
}
// Update container status in database
const container = await prisma.docker_containers.findUnique({
where: {
host_id_container_id: {
host_id: host.id,
container_id: container_id,
},
},
});
if (container) {
await prisma.docker_containers.update({
where: { id: container.id },
data: {
status: status,
state: status,
updated_at: new Date(timestamp || Date.now()),
last_checked: new Date(),
},
});
console.log(
`[Docker Event] Updated container ${name} status to ${status}`,
);
} else {
console.log(
`[Docker Event] Container ${name} not found in database (may be new)`,
);
}
// TODO: Broadcast to connected dashboard clients via SSE or WebSocket
// This would notify the frontend UI in real-time
} catch (error) {
console.error(`[Docker Event] Error handling Docker status event:`, error);
}
}
module.exports = {
init,
broadcastSettingsUpdate,
pushReportNow,
pushSettingsUpdate,
pushUpdateAgent,
pushUpdateNotification,
pushUpdateNotificationToAll,
// Expose read-only view of connected agents
getConnectedApiIds: () => Array.from(apiIdToSocket.keys()),
getConnectionByApiId,
isConnected: (apiId) => {
const ws = apiIdToSocket.get(apiId);
return !!ws && ws.readyState === WebSocket.OPEN;
},
// Get connection info including protocol (ws/wss)
getConnectionInfo: (apiId) => {
const metadata = connectionMetadata.get(apiId);
if (!metadata) {
return { connected: false, secure: false };
}
const connected = metadata.ws.readyState === WebSocket.OPEN;
return { connected, secure: metadata.secure };
},
// Subscribe to connection status changes (for SSE)
subscribeToConnectionChanges,
};

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

@@ -0,0 +1,160 @@
const { prisma } = require("./shared/prisma");
const { compareVersions, checkPublicRepo } = require("./shared/utils");
/**
* GitHub Update Check Automation
* Checks for new releases on GitHub using HTTPS API
*/
class GitHubUpdateCheck {
constructor(queueManager) {
this.queueManager = queueManager;
this.queueName = "github-update-check";
}
/**
* Process GitHub update check job
*/
async process(_job) {
const startTime = Date.now();
console.log("🔍 Starting GitHub update check...");
try {
// Get settings
const settings = await prisma.settings.findFirst();
const DEFAULT_GITHUB_REPO = "https://github.com/PatchMon/PatchMon.git";
const repoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO;
let owner, repo;
// Parse GitHub repository URL (supports both HTTPS and SSH formats)
if (repoUrl.includes("git@github.com:")) {
const match = repoUrl.match(/git@github\.com:([^/]+)\/([^/]+)\.git/);
if (match) {
[, owner, repo] = match;
}
} else if (repoUrl.includes("github.com/")) {
const match = repoUrl.match(
/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/,
);
if (match) {
[, owner, repo] = match;
}
}
if (!owner || !repo) {
throw new Error("Could not parse GitHub repository URL");
}
// Always use HTTPS GitHub API (simpler and more reliable)
const latestVersion = await checkPublicRepo(owner, repo);
if (!latestVersion) {
throw new Error("Could not determine latest version");
}
// Read version from package.json
let currentVersion = null;
try {
const packageJson = require("../../../package.json");
if (packageJson?.version) {
currentVersion = packageJson.version;
}
} catch (packageError) {
console.error(
"Could not read version from package.json:",
packageError.message,
);
throw new Error(
"Could not determine current version from package.json",
);
}
if (!currentVersion) {
throw new Error("Version not found in package.json");
}
const isUpdateAvailable =
compareVersions(latestVersion, currentVersion) > 0;
// Update settings with check results
await prisma.settings.update({
where: { id: settings.id },
data: {
last_update_check: new Date(),
update_available: isUpdateAvailable,
latest_version: latestVersion,
},
});
const executionTime = Date.now() - startTime;
console.log(
`✅ GitHub update check completed in ${executionTime}ms - Current: ${currentVersion}, Latest: ${latestVersion}, Update Available: ${isUpdateAvailable}`,
);
return {
success: true,
currentVersion,
latestVersion,
isUpdateAvailable,
executionTime,
};
} catch (error) {
const executionTime = Date.now() - startTime;
console.error(
`❌ GitHub update check failed after ${executionTime}ms:`,
error.message,
);
// Update last check time even on error
try {
const settings = await prisma.settings.findFirst();
if (settings) {
await prisma.settings.update({
where: { id: settings.id },
data: {
last_update_check: new Date(),
update_available: false,
},
});
}
} catch (updateError) {
console.error(
"❌ Error updating last check time:",
updateError.message,
);
}
throw error;
}
}
/**
* Schedule recurring GitHub update check (daily at midnight)
*/
async schedule() {
const job = await this.queueManager.queues[this.queueName].add(
"github-update-check",
{},
{
repeat: { cron: "0 0 * * *" }, // Daily at midnight
jobId: "github-update-check-recurring",
},
);
console.log("✅ GitHub update check scheduled");
return job;
}
/**
* Trigger manual GitHub update check
*/
async triggerManual() {
const job = await this.queueManager.queues[this.queueName].add(
"github-update-check-manual",
{},
{ priority: 1 },
);
console.log("✅ Manual GitHub update check triggered");
return job;
}
}
module.exports = GitHubUpdateCheck;

View File

@@ -0,0 +1,440 @@
const { Queue, Worker } = require("bullmq");
const { redis, redisConnection } = require("./shared/redis");
const { prisma } = require("./shared/prisma");
const agentWs = require("../agentWs");
// Import automation classes
const GitHubUpdateCheck = require("./githubUpdateCheck");
const SessionCleanup = require("./sessionCleanup");
const OrphanedRepoCleanup = require("./orphanedRepoCleanup");
const OrphanedPackageCleanup = require("./orphanedPackageCleanup");
const DockerInventoryCleanup = require("./dockerInventoryCleanup");
const MetricsReporting = require("./metricsReporting");
// Queue names
const QUEUE_NAMES = {
GITHUB_UPDATE_CHECK: "github-update-check",
SESSION_CLEANUP: "session-cleanup",
ORPHANED_REPO_CLEANUP: "orphaned-repo-cleanup",
ORPHANED_PACKAGE_CLEANUP: "orphaned-package-cleanup",
DOCKER_INVENTORY_CLEANUP: "docker-inventory-cleanup",
METRICS_REPORTING: "metrics-reporting",
AGENT_COMMANDS: "agent-commands",
};
/**
* Main Queue Manager
* Manages all BullMQ queues and workers
*/
class QueueManager {
constructor() {
this.queues = {};
this.workers = {};
this.automations = {};
this.isInitialized = false;
}
/**
* Initialize all queues, workers, and automations
*/
async initialize() {
try {
console.log("✅ Redis connection successful");
// Initialize queues
await this.initializeQueues();
// Initialize automation classes
await this.initializeAutomations();
// Initialize workers
await this.initializeWorkers();
// Setup event listeners
this.setupEventListeners();
this.isInitialized = true;
console.log("✅ Queue manager initialized successfully");
} catch (error) {
console.error("❌ Failed to initialize queue manager:", error.message);
throw error;
}
}
/**
* Initialize all queues
*/
async initializeQueues() {
for (const [_key, queueName] of Object.entries(QUEUE_NAMES)) {
this.queues[queueName] = new Queue(queueName, {
connection: redisConnection,
defaultJobOptions: {
removeOnComplete: 50, // Keep last 50 completed jobs
removeOnFail: 20, // Keep last 20 failed jobs
attempts: 3, // Retry failed jobs 3 times
backoff: {
type: "exponential",
delay: 2000,
},
},
});
console.log(`✅ Queue '${queueName}' initialized`);
}
}
/**
* Initialize automation classes
*/
async initializeAutomations() {
this.automations[QUEUE_NAMES.GITHUB_UPDATE_CHECK] = new GitHubUpdateCheck(
this,
);
this.automations[QUEUE_NAMES.SESSION_CLEANUP] = new SessionCleanup(this);
this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP] =
new OrphanedRepoCleanup(this);
this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP] =
new OrphanedPackageCleanup(this);
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] =
new DockerInventoryCleanup(this);
this.automations[QUEUE_NAMES.METRICS_REPORTING] = new MetricsReporting(
this,
);
console.log("✅ All automation classes initialized");
}
/**
* Initialize all workers
*/
async initializeWorkers() {
// Optimized worker options to reduce Redis connections
const workerOptions = {
connection: redisConnection,
concurrency: 1, // Keep concurrency low to reduce connections
// Connection optimization
maxStalledCount: 1,
stalledInterval: 30000,
// Reduce connection churn
settings: {
stalledInterval: 30000,
maxStalledCount: 1,
},
};
// GitHub Update Check Worker
this.workers[QUEUE_NAMES.GITHUB_UPDATE_CHECK] = new Worker(
QUEUE_NAMES.GITHUB_UPDATE_CHECK,
this.automations[QUEUE_NAMES.GITHUB_UPDATE_CHECK].process.bind(
this.automations[QUEUE_NAMES.GITHUB_UPDATE_CHECK],
),
workerOptions,
);
// Session Cleanup Worker
this.workers[QUEUE_NAMES.SESSION_CLEANUP] = new Worker(
QUEUE_NAMES.SESSION_CLEANUP,
this.automations[QUEUE_NAMES.SESSION_CLEANUP].process.bind(
this.automations[QUEUE_NAMES.SESSION_CLEANUP],
),
workerOptions,
);
// Orphaned Repo Cleanup Worker
this.workers[QUEUE_NAMES.ORPHANED_REPO_CLEANUP] = new Worker(
QUEUE_NAMES.ORPHANED_REPO_CLEANUP,
this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].process.bind(
this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP],
),
workerOptions,
);
// Orphaned Package Cleanup Worker
this.workers[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP] = new Worker(
QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP,
this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].process.bind(
this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP],
),
workerOptions,
);
// Docker Inventory Cleanup Worker
this.workers[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP] = new Worker(
QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP,
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].process.bind(
this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP],
),
workerOptions,
);
// Metrics Reporting Worker
this.workers[QUEUE_NAMES.METRICS_REPORTING] = new Worker(
QUEUE_NAMES.METRICS_REPORTING,
this.automations[QUEUE_NAMES.METRICS_REPORTING].process.bind(
this.automations[QUEUE_NAMES.METRICS_REPORTING],
),
workerOptions,
);
// Agent Commands Worker
this.workers[QUEUE_NAMES.AGENT_COMMANDS] = new Worker(
QUEUE_NAMES.AGENT_COMMANDS,
async (job) => {
const { api_id, type } = job.data;
console.log(`Processing agent command: ${type} for ${api_id}`);
// 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}`);
}
},
workerOptions,
);
console.log(
"✅ All workers initialized with optimized connection settings",
);
}
/**
* Setup event listeners for all queues
*/
setupEventListeners() {
for (const queueName of Object.values(QUEUE_NAMES)) {
const queue = this.queues[queueName];
queue.on("error", (error) => {
console.error(`❌ Queue '${queueName}' experienced an error:`, error);
});
queue.on("failed", (job, err) => {
console.error(
`❌ Job '${job.id}' in queue '${queueName}' failed:`,
err,
);
});
queue.on("completed", (job) => {
console.log(`✅ Job '${job.id}' in queue '${queueName}' completed.`);
});
}
console.log("✅ Queue events initialized");
}
/**
* Schedule all recurring jobs
*/
async scheduleAllJobs() {
await this.automations[QUEUE_NAMES.GITHUB_UPDATE_CHECK].schedule();
await this.automations[QUEUE_NAMES.SESSION_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP].schedule();
await this.automations[QUEUE_NAMES.METRICS_REPORTING].schedule();
}
/**
* Manual job triggers
*/
async triggerGitHubUpdateCheck() {
return this.automations[QUEUE_NAMES.GITHUB_UPDATE_CHECK].triggerManual();
}
async triggerSessionCleanup() {
return this.automations[QUEUE_NAMES.SESSION_CLEANUP].triggerManual();
}
async triggerOrphanedRepoCleanup() {
return this.automations[QUEUE_NAMES.ORPHANED_REPO_CLEANUP].triggerManual();
}
async triggerOrphanedPackageCleanup() {
return this.automations[
QUEUE_NAMES.ORPHANED_PACKAGE_CLEANUP
].triggerManual();
}
async triggerDockerInventoryCleanup() {
return this.automations[
QUEUE_NAMES.DOCKER_INVENTORY_CLEANUP
].triggerManual();
}
async triggerMetricsReporting() {
return this.automations[QUEUE_NAMES.METRICS_REPORTING].triggerManual();
}
/**
* Get queue statistics
*/
async getQueueStats(queueName) {
const queue = this.queues[queueName];
if (!queue) {
throw new Error(`Queue ${queueName} not found`);
}
const [waiting, active, completed, failed, delayed] = await Promise.all([
queue.getWaiting(),
queue.getActive(),
queue.getCompleted(),
queue.getFailed(),
queue.getDelayed(),
]);
return {
waiting: waiting.length,
active: active.length,
completed: completed.length,
failed: failed.length,
delayed: delayed.length,
};
}
/**
* Get all queue statistics
*/
async getAllQueueStats() {
const stats = {};
for (const queueName of Object.values(QUEUE_NAMES)) {
stats[queueName] = await this.getQueueStats(queueName);
}
return stats;
}
/**
* Get recent jobs for a queue
*/
async getRecentJobs(queueName, limit = 10) {
const queue = this.queues[queueName];
if (!queue) {
throw new Error(`Queue ${queueName} not found`);
}
const [completed, failed] = await Promise.all([
queue.getCompleted(0, limit - 1),
queue.getFailed(0, limit - 1),
]);
return [...completed, ...failed]
.sort((a, b) => new Date(b.finishedOn) - new Date(a.finishedOn))
.slice(0, limit);
}
/**
* Get jobs for a specific host (by API ID)
*/
async getHostJobs(apiId, limit = 20) {
const queue = this.queues[QUEUE_NAMES.AGENT_COMMANDS];
if (!queue) {
throw new Error(`Queue ${QUEUE_NAMES.AGENT_COMMANDS} not found`);
}
console.log(`[getHostJobs] Looking for jobs with api_id: ${apiId}`);
// Get active queue status (waiting, active, delayed, failed)
const [waiting, active, delayed, failed] = await Promise.all([
queue.getWaiting(),
queue.getActive(),
queue.getDelayed(),
queue.getFailed(),
]);
// Filter by API ID
const filterByApiId = (jobs) =>
jobs.filter((job) => job.data && job.data.api_id === apiId);
const waitingCount = filterByApiId(waiting).length;
const activeCount = filterByApiId(active).length;
const delayedCount = filterByApiId(delayed).length;
const failedCount = filterByApiId(failed).length;
console.log(
`[getHostJobs] Queue status - Waiting: ${waitingCount}, Active: ${activeCount}, Delayed: ${delayedCount}, Failed: ${failedCount}`,
);
// Get job history from database (shows all attempts and status changes)
const jobHistory = await prisma.job_history.findMany({
where: {
api_id: apiId,
},
orderBy: {
created_at: "desc",
},
take: limit,
});
console.log(
`[getHostJobs] Found ${jobHistory.length} job history records for api_id: ${apiId}`,
);
return {
waiting: waitingCount,
active: activeCount,
delayed: delayedCount,
failed: failedCount,
jobHistory: jobHistory.map((job) => ({
id: job.id,
job_id: job.job_id,
job_name: job.job_name,
status: job.status,
attempt_number: job.attempt_number,
error_message: job.error_message,
output: job.output,
created_at: job.created_at,
updated_at: job.updated_at,
completed_at: job.completed_at,
})),
};
}
/**
* Graceful shutdown
*/
async shutdown() {
console.log("🛑 Shutting down queue manager...");
for (const queueName of Object.keys(this.queues)) {
try {
await this.queues[queueName].close();
} catch (e) {
console.warn(
`⚠️ Failed to close queue '${queueName}':`,
e?.message || e,
);
}
if (this.workers?.[queueName]) {
try {
await this.workers[queueName].close();
} catch (e) {
console.warn(
`⚠️ Failed to close worker for '${queueName}':`,
e?.message || e,
);
}
}
}
await redis.quit();
console.log("✅ Queue manager shutdown complete");
}
}
const queueManager = new QueueManager();
module.exports = { queueManager, QUEUE_NAMES };

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

@@ -0,0 +1,116 @@
const { prisma } = require("./shared/prisma");
/**
* Orphaned Package Cleanup Automation
* Removes packages with no associated hosts
*/
class OrphanedPackageCleanup {
constructor(queueManager) {
this.queueManager = queueManager;
this.queueName = "orphaned-package-cleanup";
}
/**
* Process orphaned package cleanup job
*/
async process(_job) {
const startTime = Date.now();
console.log("🧹 Starting orphaned package cleanup...");
try {
// Find packages with 0 hosts
const orphanedPackages = await prisma.packages.findMany({
where: {
host_packages: {
none: {},
},
},
include: {
_count: {
select: {
host_packages: true,
},
},
},
});
let deletedCount = 0;
const deletedPackages = [];
// Delete orphaned packages
for (const pkg of orphanedPackages) {
try {
await prisma.packages.delete({
where: { id: pkg.id },
});
deletedCount++;
deletedPackages.push({
id: pkg.id,
name: pkg.name,
description: pkg.description,
category: pkg.category,
latest_version: pkg.latest_version,
});
console.log(
`🗑️ Deleted orphaned package: ${pkg.name} (${pkg.latest_version})`,
);
} catch (deleteError) {
console.error(
`❌ Failed to delete package ${pkg.id}:`,
deleteError.message,
);
}
}
const executionTime = Date.now() - startTime;
console.log(
`✅ Orphaned package cleanup completed in ${executionTime}ms - Deleted ${deletedCount} packages`,
);
return {
success: true,
deletedCount,
deletedPackages,
executionTime,
};
} catch (error) {
const executionTime = Date.now() - startTime;
console.error(
`❌ Orphaned package cleanup failed after ${executionTime}ms:`,
error.message,
);
throw error;
}
}
/**
* Schedule recurring orphaned package cleanup (daily at 3 AM)
*/
async schedule() {
const job = await this.queueManager.queues[this.queueName].add(
"orphaned-package-cleanup",
{},
{
repeat: { cron: "0 3 * * *" }, // Daily at 3 AM
jobId: "orphaned-package-cleanup-recurring",
},
);
console.log("✅ Orphaned package cleanup scheduled");
return job;
}
/**
* Trigger manual orphaned package cleanup
*/
async triggerManual() {
const job = await this.queueManager.queues[this.queueName].add(
"orphaned-package-cleanup-manual",
{},
{ priority: 1 },
);
console.log("✅ Manual orphaned package cleanup triggered");
return job;
}
}
module.exports = OrphanedPackageCleanup;

View File

@@ -0,0 +1,114 @@
const { prisma } = require("./shared/prisma");
/**
* Orphaned Repository Cleanup Automation
* Removes repositories with no associated hosts
*/
class OrphanedRepoCleanup {
constructor(queueManager) {
this.queueManager = queueManager;
this.queueName = "orphaned-repo-cleanup";
}
/**
* Process orphaned repository cleanup job
*/
async process(_job) {
const startTime = Date.now();
console.log("🧹 Starting orphaned repository cleanup...");
try {
// Find repositories with 0 hosts
const orphanedRepos = await prisma.repositories.findMany({
where: {
host_repositories: {
none: {},
},
},
include: {
_count: {
select: {
host_repositories: true,
},
},
},
});
let deletedCount = 0;
const deletedRepos = [];
// Delete orphaned repositories
for (const repo of orphanedRepos) {
try {
await prisma.repositories.delete({
where: { id: repo.id },
});
deletedCount++;
deletedRepos.push({
id: repo.id,
name: repo.name,
url: repo.url,
});
console.log(
`🗑️ Deleted orphaned repository: ${repo.name} (${repo.url})`,
);
} catch (deleteError) {
console.error(
`❌ Failed to delete repository ${repo.id}:`,
deleteError.message,
);
}
}
const executionTime = Date.now() - startTime;
console.log(
`✅ Orphaned repository cleanup completed in ${executionTime}ms - Deleted ${deletedCount} repositories`,
);
return {
success: true,
deletedCount,
deletedRepos,
executionTime,
};
} catch (error) {
const executionTime = Date.now() - startTime;
console.error(
`❌ Orphaned repository cleanup failed after ${executionTime}ms:`,
error.message,
);
throw error;
}
}
/**
* Schedule recurring orphaned repository cleanup (daily at 2 AM)
*/
async schedule() {
const job = await this.queueManager.queues[this.queueName].add(
"orphaned-repo-cleanup",
{},
{
repeat: { cron: "0 2 * * *" }, // Daily at 2 AM
jobId: "orphaned-repo-cleanup-recurring",
},
);
console.log("✅ Orphaned repository cleanup scheduled");
return job;
}
/**
* Trigger manual orphaned repository cleanup
*/
async triggerManual() {
const job = await this.queueManager.queues[this.queueName].add(
"orphaned-repo-cleanup-manual",
{},
{ priority: 1 },
);
console.log("✅ Manual orphaned repository cleanup triggered");
return job;
}
}
module.exports = OrphanedRepoCleanup;

View File

@@ -0,0 +1,77 @@
const { prisma } = require("./shared/prisma");
/**
* Session Cleanup Automation
* Cleans up expired user sessions
*/
class SessionCleanup {
constructor(queueManager) {
this.queueManager = queueManager;
this.queueName = "session-cleanup";
}
/**
* Process session cleanup job
*/
async process(_job) {
const startTime = Date.now();
console.log("🧹 Starting session cleanup...");
try {
const result = await prisma.user_sessions.deleteMany({
where: {
OR: [{ expires_at: { lt: new Date() } }, { is_revoked: true }],
},
});
const executionTime = Date.now() - startTime;
console.log(
`✅ Session cleanup completed in ${executionTime}ms - Cleaned up ${result.count} expired sessions`,
);
return {
success: true,
sessionsCleaned: result.count,
executionTime,
};
} catch (error) {
const executionTime = Date.now() - startTime;
console.error(
`❌ Session cleanup failed after ${executionTime}ms:`,
error.message,
);
throw error;
}
}
/**
* Schedule recurring session cleanup (every hour)
*/
async schedule() {
const job = await this.queueManager.queues[this.queueName].add(
"session-cleanup",
{},
{
repeat: { cron: "0 * * * *" }, // Every hour
jobId: "session-cleanup-recurring",
},
);
console.log("✅ Session cleanup scheduled");
return job;
}
/**
* Trigger manual session cleanup
*/
async triggerManual() {
const job = await this.queueManager.queues[this.queueName].add(
"session-cleanup-manual",
{},
{ priority: 1 },
);
console.log("✅ Manual session cleanup triggered");
return job;
}
}
module.exports = SessionCleanup;

View File

@@ -0,0 +1,5 @@
const { getPrismaClient } = require("../../../config/prisma");
const prisma = getPrismaClient();
module.exports = { prisma };

View File

@@ -0,0 +1,56 @@
const IORedis = require("ioredis");
// Redis connection configuration with connection pooling
const redisConnection = {
host: process.env.REDIS_HOST || "localhost",
port: parseInt(process.env.REDIS_PORT, 10) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
username: process.env.REDIS_USER || undefined,
db: parseInt(process.env.REDIS_DB, 10) || 0,
// Connection pooling settings
lazyConnect: true,
keepAlive: 30000,
connectTimeout: 30000, // Increased from 10s to 30s
commandTimeout: 30000, // Increased from 5s to 30s
enableReadyCheck: false,
// Reduce connection churn
family: 4, // Force IPv4
// Retry settings
retryDelayOnClusterDown: 300,
retryDelayOnFailover: 100,
maxRetriesPerRequest: null, // BullMQ requires this to be null
// Connection pool settings
maxLoadingTimeout: 30000,
};
// Create Redis connection with singleton pattern
let redisInstance = null;
function getRedisConnection() {
if (!redisInstance) {
redisInstance = new IORedis(redisConnection);
// Handle graceful shutdown
process.on("beforeExit", async () => {
await redisInstance.quit();
});
process.on("SIGINT", async () => {
await redisInstance.quit();
process.exit(0);
});
process.on("SIGTERM", async () => {
await redisInstance.quit();
process.exit(0);
});
}
return redisInstance;
}
module.exports = {
redis: getRedisConnection(),
redisConnection,
getRedisConnection,
};

View File

@@ -0,0 +1,83 @@
// Common utilities for automation jobs
/**
* Compare two semantic versions
* @param {string} version1 - First version
* @param {string} version2 - Second version
* @returns {number} - 1 if version1 > version2, -1 if version1 < version2, 0 if equal
*/
function compareVersions(version1, version2) {
const v1parts = version1.split(".").map(Number);
const v2parts = version2.split(".").map(Number);
const maxLength = Math.max(v1parts.length, v2parts.length);
for (let i = 0; i < maxLength; i++) {
const v1part = v1parts[i] || 0;
const v2part = v2parts[i] || 0;
if (v1part > v2part) return 1;
if (v1part < v2part) return -1;
}
return 0;
}
/**
* Check public GitHub repository for latest release
* @param {string} owner - Repository owner
* @param {string} repo - Repository name
* @returns {Promise<string|null>} - Latest version or null
*/
async function checkPublicRepo(owner, repo) {
try {
const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
// Get current version for User-Agent (or use generic if unavailable)
let currentVersion = "unknown";
try {
const packageJson = require("../../../package.json");
if (packageJson?.version) {
currentVersion = packageJson.version;
}
} catch (packageError) {
console.warn(
"Could not read version from package.json for User-Agent:",
packageError.message,
);
}
const response = await fetch(httpsRepoUrl, {
method: "GET",
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": `PatchMon-Server/${currentVersion}`,
},
});
if (!response.ok) {
const errorText = await response.text();
if (
errorText.includes("rate limit") ||
errorText.includes("API rate limit")
) {
console.log("⚠️ GitHub API rate limit exceeded, skipping update check");
return null;
}
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
const releaseData = await response.json();
return releaseData.tag_name.replace("v", "");
} catch (error) {
console.error("GitHub API error:", error.message);
throw error;
}
}
module.exports = {
compareVersions,
checkPublicRepo,
};

View File

@@ -1,7 +1,7 @@
const { PrismaClient } = require("@prisma/client");
const { getPrismaClient } = require("../config/prisma");
const { v4: uuidv4 } = require("uuid");
const prisma = new PrismaClient();
const prisma = getPrismaClient();
// Cached settings instance
let cachedSettings = null;

View File

@@ -1,295 +0,0 @@
const { PrismaClient } = require("@prisma/client");
const { exec } = require("node:child_process");
const { promisify } = require("node:util");
const prisma = new PrismaClient();
const execAsync = promisify(exec);
class UpdateScheduler {
constructor() {
this.isRunning = false;
this.intervalId = null;
this.checkInterval = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
}
// Start the scheduler
start() {
if (this.isRunning) {
console.log("Update scheduler is already running");
return;
}
console.log("🔄 Starting update scheduler...");
this.isRunning = true;
// Run initial check
this.checkForUpdates();
// Schedule regular checks
this.intervalId = setInterval(() => {
this.checkForUpdates();
}, this.checkInterval);
console.log(
`✅ Update scheduler started - checking every ${this.checkInterval / (60 * 60 * 1000)} hours`,
);
}
// Stop the scheduler
stop() {
if (!this.isRunning) {
console.log("Update scheduler is not running");
return;
}
console.log("🛑 Stopping update scheduler...");
this.isRunning = false;
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
console.log("✅ Update scheduler stopped");
}
// Check for updates
async checkForUpdates() {
try {
console.log("🔍 Checking for updates...");
// Get settings
const settings = await prisma.settings.findFirst();
const DEFAULT_GITHUB_REPO = "https://github.com/patchMon/patchmon";
const repoUrl = settings?.githubRepoUrl || DEFAULT_GITHUB_REPO;
let owner, repo;
if (repoUrl.includes("git@github.com:")) {
const match = repoUrl.match(/git@github\.com:([^/]+)\/([^/]+)\.git/);
if (match) {
[, owner, repo] = match;
}
} else if (repoUrl.includes("github.com/")) {
const match = repoUrl.match(
/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/,
);
if (match) {
[, owner, repo] = match;
}
}
if (!owner || !repo) {
console.log(
"⚠️ Could not parse GitHub repository URL, skipping update check",
);
return;
}
let latestVersion;
const isPrivate = settings.repositoryType === "private";
if (isPrivate) {
// Use SSH for private repositories
latestVersion = await this.checkPrivateRepo(settings, owner, repo);
} else {
// Use GitHub API for public repositories
latestVersion = await this.checkPublicRepo(owner, repo);
}
if (!latestVersion) {
console.log(
"⚠️ Could not determine latest version, skipping update check",
);
return;
}
// Read version from package.json dynamically
let currentVersion = "1.2.7"; // fallback
try {
const packageJson = require("../../package.json");
if (packageJson?.version) {
currentVersion = packageJson.version;
}
} catch (packageError) {
console.warn(
"Could not read version from package.json, using fallback:",
packageError.message,
);
}
const isUpdateAvailable =
this.compareVersions(latestVersion, currentVersion) > 0;
// Update settings with check results
await prisma.settings.update({
where: { id: settings.id },
data: {
last_update_check: new Date(),
update_available: isUpdateAvailable,
latest_version: latestVersion,
},
});
console.log(
`✅ Update check completed - Current: ${currentVersion}, Latest: ${latestVersion}, Update Available: ${isUpdateAvailable}`,
);
} catch (error) {
console.error("❌ Error checking for updates:", error.message);
// Update last check time even on error
try {
const settings = await prisma.settings.findFirst();
if (settings) {
await prisma.settings.update({
where: { id: settings.id },
data: {
last_update_check: new Date(),
update_available: false,
},
});
}
} catch (updateError) {
console.error(
"❌ Error updating last check time:",
updateError.message,
);
}
}
}
// Check private repository using SSH
async checkPrivateRepo(settings, owner, repo) {
try {
let sshKeyPath = settings.sshKeyPath;
// Try to find SSH key if not configured
if (!sshKeyPath) {
const possibleKeyPaths = [
"/root/.ssh/id_ed25519",
"/root/.ssh/id_rsa",
"/home/patchmon/.ssh/id_ed25519",
"/home/patchmon/.ssh/id_rsa",
"/var/www/.ssh/id_ed25519",
"/var/www/.ssh/id_rsa",
];
for (const path of possibleKeyPaths) {
try {
require("node:fs").accessSync(path);
sshKeyPath = path;
break;
} catch {
// Key not found at this path, try next
}
}
}
if (!sshKeyPath) {
throw new Error("No SSH deploy key found");
}
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
const env = {
...process.env,
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes`,
};
const { stdout: sshLatestTag } = await execAsync(
`git ls-remote --tags --sort=-version:refname ${sshRepoUrl} | head -n 1 | sed 's/.*refs\\/tags\\///' | sed 's/\\^{}//'`,
{
timeout: 10000,
env: env,
},
);
return sshLatestTag.trim().replace("v", "");
} catch (error) {
console.error("SSH Git error:", error.message);
throw error;
}
}
// Check public repository using GitHub API
async checkPublicRepo(owner, repo) {
try {
const httpsRepoUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`;
// Get current version for User-Agent
let currentVersion = "1.2.7"; // fallback
try {
const packageJson = require("../../package.json");
if (packageJson?.version) {
currentVersion = packageJson.version;
}
} catch (packageError) {
console.warn(
"Could not read version from package.json for User-Agent, using fallback:",
packageError.message,
);
}
const response = await fetch(httpsRepoUrl, {
method: "GET",
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": `PatchMon-Server/${currentVersion}`,
},
});
if (!response.ok) {
const errorText = await response.text();
if (
errorText.includes("rate limit") ||
errorText.includes("API rate limit")
) {
console.log(
"⚠️ GitHub API rate limit exceeded, skipping update check",
);
return null; // Return null instead of throwing error
}
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
const releaseData = await response.json();
return releaseData.tag_name.replace("v", "");
} catch (error) {
console.error("GitHub API error:", error.message);
throw error;
}
}
// Compare version strings (semantic versioning)
compareVersions(version1, version2) {
const v1parts = version1.split(".").map(Number);
const v2parts = version2.split(".").map(Number);
const maxLength = Math.max(v1parts.length, v2parts.length);
for (let i = 0; i < maxLength; i++) {
const v1part = v1parts[i] || 0;
const v2part = v2parts[i] || 0;
if (v1part > v2part) return 1;
if (v1part < v2part) return -1;
}
return 0;
}
// Get scheduler status
getStatus() {
return {
isRunning: this.isRunning,
checkInterval: this.checkInterval,
nextCheck: this.isRunning
? new Date(Date.now() + this.checkInterval)
: null,
};
}
}
// Create singleton instance
const updateScheduler = new UpdateScheduler();
module.exports = updateScheduler;

View File

@@ -1,8 +1,8 @@
const jwt = require("jsonwebtoken");
const crypto = require("node:crypto");
const { PrismaClient } = require("@prisma/client");
const { getPrismaClient } = require("../config/prisma");
const prisma = new PrismaClient();
const prisma = getPrismaClient();
/**
* Session Manager - Handles secure session management with inactivity timeout
@@ -15,6 +15,16 @@ if (!process.env.JWT_SECRET) {
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h";
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d";
const TFA_REMEMBER_ME_EXPIRES_IN =
process.env.TFA_REMEMBER_ME_EXPIRES_IN || "30d";
const TFA_MAX_REMEMBER_SESSIONS = parseInt(
process.env.TFA_MAX_REMEMBER_SESSIONS || "5",
10,
);
const TFA_SUSPICIOUS_ACTIVITY_THRESHOLD = parseInt(
process.env.TFA_SUSPICIOUS_ACTIVITY_THRESHOLD || "3",
10,
);
const INACTIVITY_TIMEOUT_MINUTES = parseInt(
process.env.SESSION_INACTIVITY_TIMEOUT_MINUTES || "30",
10,
@@ -70,16 +80,136 @@ function parse_expiration(expiration_string) {
}
}
/**
* Generate device fingerprint from request data
*/
function generate_device_fingerprint(req) {
const components = [
req.get("user-agent") || "",
req.get("accept-language") || "",
req.get("accept-encoding") || "",
req.ip || "",
];
// Create a simple hash of device characteristics
const fingerprint = crypto
.createHash("sha256")
.update(components.join("|"))
.digest("hex")
.substring(0, 32); // Use first 32 chars for storage efficiency
return fingerprint;
}
/**
* Check for suspicious activity patterns
*/
async function check_suspicious_activity(
user_id,
_ip_address,
_device_fingerprint,
) {
try {
// Check for multiple sessions from different IPs in short time
const recent_sessions = await prisma.user_sessions.findMany({
where: {
user_id: user_id,
created_at: {
gte: new Date(Date.now() - 24 * 60 * 60 * 1000), // Last 24 hours
},
is_revoked: false,
},
select: {
ip_address: true,
device_fingerprint: true,
created_at: true,
},
});
// Count unique IPs and devices
const unique_ips = new Set(recent_sessions.map((s) => s.ip_address));
const unique_devices = new Set(
recent_sessions.map((s) => s.device_fingerprint),
);
// Flag as suspicious if more than threshold different IPs or devices in 24h
if (
unique_ips.size > TFA_SUSPICIOUS_ACTIVITY_THRESHOLD ||
unique_devices.size > TFA_SUSPICIOUS_ACTIVITY_THRESHOLD
) {
console.warn(
`Suspicious activity detected for user ${user_id}: ${unique_ips.size} IPs, ${unique_devices.size} devices`,
);
return true;
}
return false;
} catch (error) {
console.error("Error checking suspicious activity:", error);
return false;
}
}
/**
* Create a new session for user
*/
async function create_session(user_id, ip_address, user_agent) {
async function create_session(
user_id,
ip_address,
user_agent,
remember_me = false,
req = null,
) {
try {
const session_id = crypto.randomUUID();
const refresh_token = generate_refresh_token();
const access_token = generate_access_token(user_id, session_id);
const expires_at = parse_expiration(JWT_REFRESH_EXPIRES_IN);
// Generate device fingerprint if request is available
const device_fingerprint = req ? generate_device_fingerprint(req) : null;
// Check for suspicious activity
if (device_fingerprint) {
const is_suspicious = await check_suspicious_activity(
user_id,
ip_address,
device_fingerprint,
);
if (is_suspicious) {
console.warn(
`Suspicious activity detected for user ${user_id}, session creation may be restricted`,
);
}
}
// Check session limits for remember me
if (remember_me) {
const existing_remember_sessions = await prisma.user_sessions.count({
where: {
user_id: user_id,
tfa_remember_me: true,
is_revoked: false,
expires_at: { gt: new Date() },
},
});
// Limit remember me sessions per user
if (existing_remember_sessions >= TFA_MAX_REMEMBER_SESSIONS) {
throw new Error(
"Maximum number of remembered devices reached. Please revoke an existing session first.",
);
}
}
// Use longer expiration for remember me sessions
const expires_at = remember_me
? parse_expiration(TFA_REMEMBER_ME_EXPIRES_IN)
: parse_expiration(JWT_REFRESH_EXPIRES_IN);
// Calculate TFA bypass until date for remember me sessions
const tfa_bypass_until = remember_me
? parse_expiration(TFA_REMEMBER_ME_EXPIRES_IN)
: null;
// Store session in database
await prisma.user_sessions.create({
@@ -90,8 +220,13 @@ async function create_session(user_id, ip_address, user_agent) {
access_token_hash: hash_token(access_token),
ip_address: ip_address || null,
user_agent: user_agent || null,
device_fingerprint: device_fingerprint,
last_login_ip: ip_address || null,
last_activity: new Date(),
expires_at: expires_at,
tfa_remember_me: remember_me,
tfa_bypass_until: tfa_bypass_until,
login_count: 1,
},
});
@@ -100,6 +235,7 @@ async function create_session(user_id, ip_address, user_agent) {
access_token,
refresh_token,
expires_at,
tfa_bypass_until,
};
} catch (error) {
console.error("Error creating session:", error);
@@ -299,6 +435,8 @@ async function get_user_sessions(user_id) {
last_activity: true,
created_at: true,
expires_at: true,
tfa_remember_me: true,
tfa_bypass_until: true,
},
orderBy: { last_activity: "desc" },
});
@@ -308,6 +446,42 @@ async function get_user_sessions(user_id) {
}
}
/**
* Check if TFA is bypassed for a session
*/
async function is_tfa_bypassed(session_id) {
try {
const session = await prisma.user_sessions.findUnique({
where: { id: session_id },
select: {
tfa_remember_me: true,
tfa_bypass_until: true,
is_revoked: true,
expires_at: true,
},
});
if (!session) {
return false;
}
// Check if session is still valid
if (session.is_revoked || new Date() > session.expires_at) {
return false;
}
// Check if TFA is bypassed and still within bypass period
if (session.tfa_remember_me && session.tfa_bypass_until) {
return new Date() < session.tfa_bypass_until;
}
return false;
} catch (error) {
console.error("Error checking TFA bypass:", error);
return false;
}
}
module.exports = {
create_session,
validate_session,
@@ -317,6 +491,9 @@ module.exports = {
revoke_all_user_sessions,
cleanup_expired_sessions,
get_user_sessions,
is_tfa_bypassed,
generate_device_fingerprint,
check_suspicious_activity,
generate_access_token,
INACTIVITY_TIMEOUT_MINUTES,
};

View File

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

View File

@@ -2,9 +2,10 @@
## Overview
PatchMon is a containerised application that monitors system patches and updates. The application consists of three main services:
PatchMon is a containerised application that monitors system patches and updates. The application consists of four main services:
- **Database**: PostgreSQL 17
- **Redis**: Redis 7 for BullMQ job queues and caching
- **Backend**: Node.js API server
- **Frontend**: React application served via NGINX
@@ -19,6 +20,7 @@ PatchMon is a containerised application that monitors system patches and updates
- `x.y.z`: Full version tags (e.g. `1.2.3`) - Use this for exact version pinning.
- `x.y`: Minor version tags (e.g. `1.2`) - Use this to get the latest patch release in a minor version series.
- `x`: Major version tags (e.g. `1`) - Use this to get the latest minor and patch release in a major version series.
- `edge`: The latest development build with the most recent features and fixes. This tag may often be unstable and is intended only for testing and development purposes.
These tags are available for both backend and frontend images as they are versioned together.
@@ -37,21 +39,31 @@ These tags are available for both backend and frontend images as they are versio
environment:
DATABASE_URL: postgresql://patchmon_user:REPLACE_YOUR_POSTGRES_PASSWORD_HERE@database:5432/patchmon_db
```
4. Generate a strong JWT secret. You can do this like so:
4. Set a Redis password in the Redis service command where it says:
```yaml
command: redis-server --requirepass your-redis-password-here
```
Note: The Redis service uses a hardcoded password in the command line for better reliability and to avoid environment variable parsing issues.
5. Update the corresponding `REDIS_PASSWORD` in the backend service where it says:
```yaml
environment:
REDIS_PASSWORD: your-redis-password-here
```
6. Generate a strong JWT secret. You can do this like so:
```bash
openssl rand -hex 64
```
5. Set a JWT secret in the backend service where it says:
7. Set a JWT secret in the backend service where it says:
```yaml
environment:
JWT_SECRET: # CREATE A STRONG SECRET AND PUT IT HERE
```
6. Configure environment variables (see [Configuration](#configuration) section)
7. Start the application:
8. Configure environment variables (see [Configuration](#configuration) section)
9. Start the application:
```bash
docker compose up -d
```
8. Access the application at `http://localhost:3000`
10. Access the application at `http://localhost:3000`
## Updating
@@ -105,6 +117,15 @@ When you do this, updating to a new version requires manually updating the image
| `POSTGRES_USER` | Database user | `patchmon_user` |
| `POSTGRES_PASSWORD` | Database password | **MUST BE SET!** |
#### Redis Service
| Variable | Description | Default |
| -------------- | ------------------ | ---------------- |
| `REDIS_PASSWORD` | Redis password | **MUST BE SET!** |
> [!NOTE]
> The Redis service uses a hardcoded password in the command line (`redis-server --requirepass your-password`) instead of environment variables or configuration files. This approach eliminates parsing issues and provides better reliability. The password must be set in both the Redis command and the backend service environment variables.
#### Backend Service
##### Database Configuration
@@ -115,6 +136,33 @@ When you do this, updating to a new version requires manually updating the image
| `PM_DB_CONN_MAX_ATTEMPTS` | Maximum database connection attempts | `30` |
| `PM_DB_CONN_WAIT_INTERVAL` | Wait interval between connection attempts in seconds | `2` |
##### Database Connection Pool Configuration (Prisma)
| Variable | Description | Default |
| --------------------- | ---------------------------------------------------------- | ------- |
| `DB_CONNECTION_LIMIT` | Maximum number of database connections per instance | `30` |
| `DB_POOL_TIMEOUT` | Seconds to wait for an available connection before timeout | `20` |
| `DB_CONNECT_TIMEOUT` | Seconds to wait for initial database connection | `10` |
| `DB_IDLE_TIMEOUT` | Seconds before closing idle connections | `300` |
| `DB_MAX_LIFETIME` | Maximum lifetime of a connection in seconds | `1800` |
> [!TIP]
> The connection pool limit should be adjusted based on your deployment size:
> - **Small deployment (1-10 hosts)**: `DB_CONNECTION_LIMIT=15` is sufficient
> - **Medium deployment (10-50 hosts)**: `DB_CONNECTION_LIMIT=30` (default)
> - **Large deployment (50+ hosts)**: `DB_CONNECTION_LIMIT=50` or higher
>
> Each connection pool serves one backend instance. If you have concurrent operations (multiple users, background jobs, agent checkins), increase the pool size accordingly.
##### Redis Configuration
| Variable | Description | Default |
| --------------- | ------------------------------ | ------- |
| `REDIS_HOST` | Redis server hostname | `redis` |
| `REDIS_PORT` | Redis server port | `6379` |
| `REDIS_PASSWORD` | Redis authentication password | **MUST BE UPDATED WITH YOUR REDIS_PASSWORD!** |
| `REDIS_DB` | Redis database number | `0` |
##### Authentication & Security
| Variable | Description | Default |
@@ -164,9 +212,10 @@ When you do this, updating to a new version requires manually updating the image
### Volumes
The compose file creates two Docker volumes:
The compose file creates three Docker volumes:
* `postgres_data`: PostgreSQL's data directory.
* `redis_data`: Redis's data directory.
* `agent_files`: PatchMon's agent files.
If you wish to bind either if their respective container paths to a host path rather than a Docker volume, you can do so in the Docker Compose file.
@@ -200,6 +249,7 @@ For development with live reload and source code mounting:
- Frontend: `http://localhost:3000`
- Backend API: `http://localhost:3001`
- Database: `localhost:5432`
- Redis: `localhost:6379`
## Development Docker Compose
@@ -253,6 +303,7 @@ docker compose -f docker/docker-compose.dev.yml up -d --build
### Development Ports
The development setup exposes additional ports for debugging:
- **Database**: `5432` - Direct PostgreSQL access
- **Redis**: `6379` - Direct Redis access
- **Backend**: `3001` - API server with development features
- **Frontend**: `3000` - React development server with hot reload
@@ -276,8 +327,8 @@ The development setup exposes additional ports for debugging:
- **Prisma Schema Changes**: Backend service restarts automatically
4. **Database Access**: Connect database client directly to `localhost:5432`
5. **Debug**: If started with `docker compose [...] up -d` or `docker compose [...] watch`, check logs manually:
5. **Redis Access**: Connect Redis client directly to `localhost:6379`
6. **Debug**: If started with `docker compose [...] up -d` or `docker compose [...] watch`, check logs manually:
```bash
docker compose -f docker/docker-compose.dev.yml logs -f
```
@@ -287,6 +338,6 @@ The development setup exposes additional ports for debugging:
- **Hot Reload**: Automatic code synchronization and service restarts
- **Enhanced Logging**: Detailed logs for debugging
- **Direct Access**: Exposed ports for database and API debugging
- **Direct Access**: Exposed ports for database, Redis, and API debugging
- **Health Checks**: Built-in health monitoring for services
- **Volume Persistence**: Development data persists between restarts

View File

@@ -8,7 +8,7 @@ ENV NODE_ENV=development \
PM_LOG_TO_CONSOLE=true \
PORT=3001
RUN apk add --no-cache openssl tini curl
RUN apk add --no-cache openssl tini curl libc6-compat
USER node
@@ -46,8 +46,10 @@ COPY --chown=node:node backend/ ./backend/
WORKDIR /app/backend
RUN npm ci --ignore-scripts &&\
npx prisma generate &&\
RUN npm cache clean --force &&\
rm -rf node_modules ~/.npm /root/.npm &&\
npm ci --ignore-scripts --legacy-peer-deps --no-audit --prefer-online --fetch-retries=3 --fetch-retry-mintimeout=20000 --fetch-retry-maxtimeout=120000 &&\
PRISMA_CLI_BINARY_TYPE=binary npm run db:generate &&\
npm prune --omit=dev &&\
npm cache clean --force
@@ -64,7 +66,7 @@ ENV NODE_ENV=production \
JWT_REFRESH_EXPIRES_IN=7d \
SESSION_INACTIVITY_TIMEOUT_MINUTES=30
RUN apk add --no-cache openssl tini curl
RUN apk add --no-cache openssl tini curl libc6-compat
USER node

View File

@@ -1 +1,3 @@
**/env.example
**/.env
**/.env.*

View File

@@ -8,19 +8,143 @@ log() {
echo "[$(date +'%Y-%m-%d %H:%M:%S')] $*" >&2
}
# Copy files from agents_backup to agents if agents directory is empty
if [ -d "/app/agents" ] && [ -z "$(ls -A /app/agents 2>/dev/null)" ]; then
if [ -d "/app/agents_backup" ]; then
log "Agents directory is empty, copying from backup..."
cp -r /app/agents_backup/* /app/agents/
# Function to extract version from agent script (legacy)
get_agent_version() {
local file="$1"
if [ -f "$file" ]; then
grep -m 1 '^AGENT_VERSION=' "$file" | cut -d'"' -f2 2>/dev/null || echo "0.0.0"
else
log "Warning: agents_backup directory not found"
echo "0.0.0"
fi
else
log "Agents directory already contains files, skipping copy"
fi
}
log "Starting PatchMon Backend (${NODE_ENV:-production})..."
# Function to get version from binary using --help flag
get_binary_version() {
local binary="$1"
if [ -f "$binary" ]; then
# Make sure binary is executable
chmod +x "$binary" 2>/dev/null || true
# Try to execute the binary and extract version from help output
# The Go binary shows version in the --help output as "PatchMon Agent v1.3.0"
local version=$("$binary" --help 2>&1 | grep -oE 'v[0-9]+\.[0-9]+\.[0-9]+' | head -n 1 | tr -d 'v')
if [ -n "$version" ]; then
echo "$version"
else
# Fallback: try --version flag
version=$("$binary" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n 1)
if [ -n "$version" ]; then
echo "$version"
else
echo "0.0.0"
fi
fi
else
echo "0.0.0"
fi
}
# Function to compare versions (returns 0 if $1 > $2)
version_greater() {
# Use sort -V for version comparison
test "$(printf '%s\n' "$1" "$2" | sort -V | tail -n1)" = "$1" && test "$1" != "$2"
}
# Check and update agent files if necessary
update_agents() {
local backup_agent="/app/agents_backup/patchmon-agent.sh"
local current_agent="/app/agents/patchmon-agent.sh"
local backup_binary="/app/agents_backup/patchmon-agent-linux-amd64"
local current_binary="/app/agents/patchmon-agent-linux-amd64"
# Check if agents directory exists
if [ ! -d "/app/agents" ]; then
log "ERROR: /app/agents directory not found"
return 1
fi
# Check if backup exists
if [ ! -d "/app/agents_backup" ]; then
log "WARNING: agents_backup directory not found, skipping agent update"
return 0
fi
# Get versions from both script and binary
local backup_script_version=$(get_agent_version "$backup_agent")
local current_script_version=$(get_agent_version "$current_agent")
local backup_binary_version=$(get_binary_version "$backup_binary")
local current_binary_version=$(get_binary_version "$current_binary")
log "Agent version check:"
log " Image script version: ${backup_script_version}"
log " Volume script version: ${current_script_version}"
log " Image binary version: ${backup_binary_version}"
log " Volume binary version: ${current_binary_version}"
# Determine if update is needed
local needs_update=0
# Case 1: No agents in volume at all (first time setup)
if [ -z "$(find /app/agents -maxdepth 1 -type f 2>/dev/null | head -n 1)" ]; then
log "Agents directory is empty - performing initial copy"
needs_update=1
# Case 2: Binary exists but backup binary is newer
elif [ "$current_binary_version" != "0.0.0" ] && version_greater "$backup_binary_version" "$current_binary_version"; then
log "Newer agent binary available (${backup_binary_version} > ${current_binary_version})"
needs_update=1
# Case 3: No binary in volume, but shell scripts exist (legacy setup) - copy binaries
elif [ "$current_binary_version" = "0.0.0" ] && [ "$backup_binary_version" != "0.0.0" ]; then
log "No binary found in volume but backup has binaries - performing update"
needs_update=1
else
log "Agents are up to date (binary: ${current_binary_version})"
needs_update=0
fi
# Perform update if needed
if [ $needs_update -eq 1 ]; then
log "Updating agents to version ${backup_binary_version}..."
# Create backup of existing agents if they exist
if [ -f "$current_agent" ] || [ -f "$current_binary" ]; then
local backup_timestamp=$(date +%Y%m%d_%H%M%S)
mkdir -p "/app/agents/backups"
# Backup shell script if it exists
if [ -f "$current_agent" ]; then
cp "$current_agent" "/app/agents/backups/patchmon-agent.sh.${backup_timestamp}" 2>/dev/null || true
log "Previous script backed up"
fi
# Backup binary if it exists
if [ -f "$current_binary" ]; then
cp "$current_binary" "/app/agents/backups/patchmon-agent-linux-amd64.${backup_timestamp}" 2>/dev/null || true
log "Previous binary backed up"
fi
fi
# Copy new agents (both scripts and binaries)
cp -r /app/agents_backup/* /app/agents/
# Make agent binaries executable
chmod +x /app/agents/patchmon-agent-linux-* 2>/dev/null || true
# Verify update
local new_binary_version=$(get_binary_version "$current_binary")
if [ "$new_binary_version" = "$backup_binary_version" ]; then
log "✅ Agents successfully updated to version ${new_binary_version}"
else
log "⚠️ Warning: Agent update may have failed (expected: ${backup_binary_version}, got: ${new_binary_version})"
fi
fi
}
# Main execution
log "PatchMon Backend Container Starting..."
log "Environment: ${NODE_ENV:-production}"
# Update agents (version-aware)
update_agents
log "Running database migrations..."
npx prisma migrate deploy

View File

@@ -18,6 +18,22 @@ services:
timeout: 5s
retries: 7
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass 1NS3CU6E_DEV_R3DIS_PASSW0RD
environment:
REDIS_PASSWORD: 1NS3CU6E_DEV_R3DIS_PASSW0RD
ports:
- "6379:6379"
volumes:
- ./compose_dev_data/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "1NS3CU6E_DEV_R3DIS_PASSW0RD", "ping"]
interval: 3s
timeout: 5s
retries: 7
backend:
build:
context: ..
@@ -34,6 +50,24 @@ services:
SERVER_HOST: localhost
SERVER_PORT: 3000
CORS_ORIGIN: http://localhost:3000
# Database Connection Pool Configuration (Prisma)
DB_CONNECTION_LIMIT: 30
DB_POOL_TIMEOUT: 20
DB_CONNECT_TIMEOUT: 10
DB_IDLE_TIMEOUT: 300
DB_MAX_LIFETIME: 1800
# Rate Limiting (times in milliseconds)
RATE_LIMIT_WINDOW_MS: 900000
RATE_LIMIT_MAX: 5000
AUTH_RATE_LIMIT_WINDOW_MS: 600000
AUTH_RATE_LIMIT_MAX: 500
AGENT_RATE_LIMIT_WINDOW_MS: 60000
AGENT_RATE_LIMIT_MAX: 1000
# Redis Configuration
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: 1NS3CU6E_DEV_R3DIS_PASSW0RD
REDIS_DB: 0
ports:
- "3001:3001"
volumes:
@@ -41,6 +75,8 @@ services:
depends_on:
database:
condition: service_healthy
redis:
condition: service_healthy
develop:
watch:
- action: sync

View File

@@ -1,3 +1,19 @@
# Change 3 Passwords in this file:
# Generate passwords with 'openssl rand -hex 64'
#
# 1. The database password in the environment variable POSTGRES_PASSWORD
# 2. The redis password in the command redis-server --requirepass your-redis-password-here
# 3. The jwt secret in the environment variable JWT_SECRET
#
#
# Change 2 URL areas in this file:
# 1. Setup your CORS_ORIGIN to what url you will use for accessing PatchMon frontend url
# 2. Setup your SERVER_PROTOCOL, SERVER_HOST and SERVER_PORT to what you will use for linux agents to access PatchMon
#
# This is generally the same as your CORS_ORIGIN url , in some cases it might be different - SERVER_* variables are used in the scripts for Server connection.
# You can also change this in the front-end but in the case of docker-compose - it is overwritten by the variables set here.
name: patchmon
services:
@@ -7,7 +23,7 @@ services:
environment:
POSTGRES_DB: patchmon_db
POSTGRES_USER: patchmon_user
POSTGRES_PASSWORD: # CREATE A STRONG PASSWORD AND PUT IT HERE
POSTGRES_PASSWORD: # CREATE A STRONG DB PASSWORD AND PUT IT HERE
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
@@ -16,6 +32,18 @@ services:
timeout: 5s
retries: 7
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --requirepass your-redis-password-here # CHANGE THIS TO YOUR REDIS PASSWORD
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "your-redis-password-here", "ping"] # CHANGE THIS TO YOUR REDIS PASSWORD
interval: 3s
timeout: 5s
retries: 7
backend:
image: ghcr.io/patchmon/patchmon-backend:latest
restart: unless-stopped
@@ -23,16 +51,36 @@ services:
environment:
LOG_LEVEL: info
DATABASE_URL: postgresql://patchmon_user:REPLACE_YOUR_POSTGRES_PASSWORD_HERE@database:5432/patchmon_db
JWT_SECRET: # CREATE A STRONG SECRET AND PUT IT HERE - Generate with 'openssl rand -hex 64'
JWT_SECRET: # CREATE A STRONG SECRET AND PUT IT HERE
SERVER_PROTOCOL: http
SERVER_HOST: localhost
SERVER_PORT: 3000
CORS_ORIGIN: http://localhost:3000
# Database Connection Pool Configuration (Prisma)
DB_CONNECTION_LIMIT: 30
DB_POOL_TIMEOUT: 20
DB_CONNECT_TIMEOUT: 10
DB_IDLE_TIMEOUT: 300
DB_MAX_LIFETIME: 1800
# Rate Limiting (times in milliseconds)
RATE_LIMIT_WINDOW_MS: 900000
RATE_LIMIT_MAX: 5000
AUTH_RATE_LIMIT_WINDOW_MS: 600000
AUTH_RATE_LIMIT_MAX: 500
AGENT_RATE_LIMIT_WINDOW_MS: 60000
AGENT_RATE_LIMIT_MAX: 1000
# Redis Configuration
REDIS_HOST: redis
REDIS_PORT: 6379
REDIS_PASSWORD: your-redis-password-here
REDIS_DB: 0
volumes:
- agent_files:/app/agents
depends_on:
database:
condition: service_healthy
redis:
condition: service_healthy
frontend:
image: ghcr.io/patchmon/patchmon-frontend:latest
@@ -45,4 +93,5 @@ services:
volumes:
postgres_data:
redis_data:
agent_files:

View File

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

View File

@@ -24,6 +24,38 @@ server {
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Bull Board proxy - must come before the root location to avoid conflicts
location /bullboard {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Cookie $http_cookie; # Forward cookies to backend
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
# Enable cookie passthrough in both directions
proxy_pass_header Set-Cookie;
proxy_cookie_path / /;
# Preserve original client IP through proxy chain
proxy_set_header X-Original-Forwarded-For $http_x_forwarded_for;
# CORS headers for Bull Board - let backend handle CORS properly
# Note: Backend handles CORS with proper origin validation and credentials
# Handle preflight requests
if ($request_method = 'OPTIONS') {
return 204;
}
}
# Handle client-side routing
location / {
try_files $uri $uri/ /index.html;
@@ -38,13 +70,19 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
# For the Websocket connection:
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300s;
proxy_connect_timeout 75s;
# Preserve original client IP through proxy chain
proxy_set_header X-Original-Forwarded-For $http_x_forwarded_for;
# CORS headers for API calls
add_header Access-Control-Allow-Origin * always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" always;
# CORS headers for API calls - let backend handle CORS properly
# Note: Backend handles CORS with proper origin validation and credentials
# Handle preflight requests
if ($request_method = 'OPTIONS') {
@@ -52,8 +90,9 @@ server {
}
}
# Static assets caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
# Static assets caching (exclude Bull Board assets)
location ~* ^/(?!bullboard).*\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}

10
frontend/env.example Normal file
View File

@@ -0,0 +1,10 @@
# Frontend Environment Configuration
# This file is used by Vite during build and runtime
# API URL - Update this to match your backend server
VITE_API_URL=http://localhost:3001/api/v1
# Application Metadata
VITE_APP_NAME=PatchMon
VITE_APP_VERSION=1.3.1

View File

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

View File

@@ -0,0 +1,23 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36">
<circle fill="#DD2E44" cx="18" cy="18" r="18" />
<circle fill="#FFF" cx="18" cy="18" r="13.5" />
<circle fill="#DD2E44" cx="18" cy="18" r="10" />
<circle fill="#FFF" cx="18" cy="18" r="6" />
<circle fill="#DD2E44" cx="18" cy="18" r="3" />
<path
opacity=".2"
d="M18.24 18.282l13.144 11.754s-2.647 3.376-7.89 5.109L17.579 18.42l.661-.138z"
/>
<path
fill="#FFAC33"
d="M18.294 19a.994.994 0 01-.704-1.699l.563-.563a.995.995 0 011.408 1.407l-.564.563a.987.987 0 01-.703.292z"
/>
<path
fill="#55ACEE"
d="M24.016 6.981c-.403 2.079 0 4.691 0 4.691l7.054-7.388c.291-1.454-.528-3.932-1.718-4.238-1.19-.306-4.079.803-5.336 6.935zm5.003 5.003c-2.079.403-4.691 0-4.691 0l7.388-7.054c1.454-.291 3.932.528 4.238 1.718.306 1.19-.803 4.079-6.935 5.336z"
/>
<path
fill="#3A87C2"
d="M32.798 4.485L21.176 17.587c-.362.362-1.673.882-2.51.046-.836-.836-.419-2.08-.057-2.443L31.815 3.501s.676-.635 1.159-.152-.176 1.136-.176 1.136z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,3 +1,4 @@
import { lazy, Suspense } from "react";
import { Route, Routes } from "react-router-dom";
import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
import Layout from "./components/Layout";
@@ -6,25 +7,52 @@ import ProtectedRoute from "./components/ProtectedRoute";
import SettingsLayout from "./components/SettingsLayout";
import { isAuthPhase } from "./constants/authPhases";
import { AuthProvider, useAuth } from "./contexts/AuthContext";
import { ColorThemeProvider } from "./contexts/ColorThemeContext";
import { ThemeProvider } from "./contexts/ThemeContext";
import { UpdateNotificationProvider } from "./contexts/UpdateNotificationContext";
import Dashboard from "./pages/Dashboard";
import HostDetail from "./pages/HostDetail";
import Hosts from "./pages/Hosts";
import Login from "./pages/Login";
import PackageDetail from "./pages/PackageDetail";
import Packages from "./pages/Packages";
import Profile from "./pages/Profile";
import Repositories from "./pages/Repositories";
import RepositoryDetail from "./pages/RepositoryDetail";
import AlertChannels from "./pages/settings/AlertChannels";
import Integrations from "./pages/settings/Integrations";
import Notifications from "./pages/settings/Notifications";
import PatchManagement from "./pages/settings/PatchManagement";
import SettingsAgentConfig from "./pages/settings/SettingsAgentConfig";
import SettingsHostGroups from "./pages/settings/SettingsHostGroups";
import SettingsServerConfig from "./pages/settings/SettingsServerConfig";
import SettingsUsers from "./pages/settings/SettingsUsers";
// Lazy load pages
const Dashboard = lazy(() => import("./pages/Dashboard"));
const HostDetail = lazy(() => import("./pages/HostDetail"));
const Hosts = lazy(() => import("./pages/Hosts"));
const Login = lazy(() => import("./pages/Login"));
const PackageDetail = lazy(() => import("./pages/PackageDetail"));
const Packages = lazy(() => import("./pages/Packages"));
const Profile = lazy(() => import("./pages/Profile"));
const Automation = lazy(() => import("./pages/Automation"));
const Repositories = lazy(() => import("./pages/Repositories"));
const RepositoryDetail = lazy(() => import("./pages/RepositoryDetail"));
const Docker = lazy(() => import("./pages/Docker"));
const DockerContainerDetail = lazy(
() => import("./pages/docker/ContainerDetail"),
);
const DockerImageDetail = lazy(() => import("./pages/docker/ImageDetail"));
const DockerHostDetail = lazy(() => import("./pages/docker/HostDetail"));
const AlertChannels = lazy(() => import("./pages/settings/AlertChannels"));
const Integrations = lazy(() => import("./pages/settings/Integrations"));
const Notifications = lazy(() => import("./pages/settings/Notifications"));
const PatchManagement = lazy(() => import("./pages/settings/PatchManagement"));
const SettingsAgentConfig = lazy(
() => import("./pages/settings/SettingsAgentConfig"),
);
const SettingsHostGroups = lazy(
() => import("./pages/settings/SettingsHostGroups"),
);
const SettingsServerConfig = lazy(
() => import("./pages/settings/SettingsServerConfig"),
);
const SettingsUsers = lazy(() => import("./pages/settings/SettingsUsers"));
const SettingsMetrics = lazy(() => import("./pages/settings/SettingsMetrics"));
// Loading fallback component
const LoadingFallback = () => (
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-secondary-50 dark:from-secondary-900 dark:to-secondary-800 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto mb-4"></div>
<p className="text-secondary-600 dark:text-secondary-300">Loading...</p>
</div>
</div>
);
function AppRoutes() {
const { needsFirstTimeSetup, authPhase, isAuthenticated } = useAuth();
@@ -53,298 +81,362 @@ function AppRoutes() {
}
return (
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute requirePermission="can_view_dashboard">
<Layout>
<Dashboard />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/hosts"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<Hosts />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/hosts/:hostId"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<HostDetail />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/packages"
element={
<ProtectedRoute requirePermission="can_view_packages">
<Layout>
<Packages />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/repositories"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<Repositories />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/repositories/:repositoryId"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<RepositoryDetail />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/users"
element={
<ProtectedRoute requirePermission="can_view_users">
<Layout>
<SettingsUsers />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/permissions"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsUsers />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/users"
element={
<ProtectedRoute requirePermission="can_view_users">
<Layout>
<SettingsUsers />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/roles"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsUsers />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/profile"
element={
<ProtectedRoute>
<Layout>
<SettingsLayout>
<Profile />
</SettingsLayout>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/host-groups"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsHostGroups />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/notifications"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsLayout>
<Notifications />
</SettingsLayout>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/agent-config"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsAgentConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/agent-config/management"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsAgentConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/server-config"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/server-config/version"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/alert-channels"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsLayout>
<AlertChannels />
</SettingsLayout>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/integrations"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<Integrations />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/patch-management"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<PatchManagement />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/server-url"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/server-version"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/branding"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/agent-version"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsAgentConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/options"
element={
<ProtectedRoute requirePermission="can_manage_hosts">
<Layout>
<SettingsHostGroups />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/packages/:packageId"
element={
<ProtectedRoute requirePermission="can_view_packages">
<Layout>
<PackageDetail />
</Layout>
</ProtectedRoute>
}
/>
</Routes>
<Suspense fallback={<LoadingFallback />}>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<ProtectedRoute requirePermission="can_view_dashboard">
<Layout>
<Dashboard />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/hosts"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<Hosts />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/hosts/:hostId"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<HostDetail />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/packages"
element={
<ProtectedRoute requirePermission="can_view_packages">
<Layout>
<Packages />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/repositories"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<Repositories />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/repositories/:repositoryId"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<RepositoryDetail />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/automation"
element={
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<Automation />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/docker"
element={
<ProtectedRoute requirePermission="can_view_reports">
<Layout>
<Docker />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/docker/containers/:id"
element={
<ProtectedRoute requirePermission="can_view_reports">
<Layout>
<DockerContainerDetail />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/docker/images/:id"
element={
<ProtectedRoute requirePermission="can_view_reports">
<Layout>
<DockerImageDetail />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/docker/hosts/:id"
element={
<ProtectedRoute requirePermission="can_view_reports">
<Layout>
<DockerHostDetail />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/users"
element={
<ProtectedRoute requirePermission="can_view_users">
<Layout>
<SettingsUsers />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/permissions"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsUsers />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/users"
element={
<ProtectedRoute requirePermission="can_view_users">
<Layout>
<SettingsUsers />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/roles"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsUsers />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/profile"
element={
<ProtectedRoute>
<Layout>
<SettingsLayout>
<Profile />
</SettingsLayout>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/host-groups"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsHostGroups />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/notifications"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsLayout>
<Notifications />
</SettingsLayout>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/agent-config"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsAgentConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/agent-config/management"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsAgentConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/server-config"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/server-config/version"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/alert-channels"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsLayout>
<AlertChannels />
</SettingsLayout>
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/integrations"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<Integrations />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/patch-management"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<PatchManagement />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/server-url"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/server-version"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/branding"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsServerConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/agent-version"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsAgentConfig />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/settings/metrics"
element={
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<SettingsMetrics />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/options"
element={
<ProtectedRoute requirePermission="can_manage_hosts">
<Layout>
<SettingsHostGroups />
</Layout>
</ProtectedRoute>
}
/>
<Route
path="/packages/:packageId"
element={
<ProtectedRoute requirePermission="can_view_packages">
<Layout>
<PackageDetail />
</Layout>
</ProtectedRoute>
}
/>
</Routes>
</Suspense>
);
}
function App() {
return (
<ThemeProvider>
<AuthProvider>
<UpdateNotificationProvider>
<LogoProvider>
<AppRoutes />
</LogoProvider>
</UpdateNotificationProvider>
</AuthProvider>
<ColorThemeProvider>
<AuthProvider>
<UpdateNotificationProvider>
<LogoProvider>
<AppRoutes />
</LogoProvider>
</UpdateNotificationProvider>
</AuthProvider>
</ColorThemeProvider>
</ThemeProvider>
);
}

View File

@@ -2,6 +2,7 @@ import { AlertCircle, CheckCircle, Shield, UserPlus } from "lucide-react";
import { useId, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { isCorsError } from "../utils/api";
const FirstTimeAdminSetup = () => {
const { login, setAuthState } = useAuth();
@@ -121,11 +122,39 @@ const FirstTimeAdminSetup = () => {
}, 2000);
}
} else {
setError(data.error || "Failed to create admin user");
// Handle HTTP error responses (like 500 CORS errors)
console.log("HTTP error response:", response.status, data);
// Check if this is a CORS error based on the response data
if (
data.message?.includes("Not allowed by CORS") ||
data.message?.includes("CORS") ||
data.error?.includes("CORS")
) {
setError(
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
);
} else {
setError(data.error || "Failed to create admin user");
}
}
} catch (error) {
console.error("Setup error:", error);
setError("Network error. Please try again.");
// Check for CORS/network errors first
if (isCorsError(error)) {
setError(
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
);
} else if (
error.name === "TypeError" &&
error.message?.includes("Failed to fetch")
) {
setError(
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
);
} else {
setError("Network error. Please try again.");
}
} finally {
setIsLoading(false);
}

View File

@@ -0,0 +1,283 @@
import { Check, ChevronDown, Edit2, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
const InlineMultiGroupEdit = ({
value = [], // Array of group IDs
onSave,
onCancel,
options = [],
className = "",
disabled = false,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [selectedValues, setSelectedValues] = useState(value);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({
top: 0,
left: 0,
width: 0,
});
const dropdownRef = useRef(null);
const buttonRef = useRef(null);
useEffect(() => {
if (isEditing && dropdownRef.current) {
dropdownRef.current.focus();
}
}, [isEditing]);
useEffect(() => {
setSelectedValues(value);
// Force re-render when value changes
if (!isEditing) {
setIsOpen(false);
}
}, [value, isEditing]);
// Calculate dropdown position
const calculateDropdownPosition = useCallback(() => {
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX,
width: rect.width,
});
}
}, []);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
if (isOpen) {
calculateDropdownPosition();
document.addEventListener("mousedown", handleClickOutside);
window.addEventListener("resize", calculateDropdownPosition);
window.addEventListener("scroll", calculateDropdownPosition);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
window.removeEventListener("resize", calculateDropdownPosition);
window.removeEventListener("scroll", calculateDropdownPosition);
};
}
}, [isOpen, calculateDropdownPosition]);
const handleEdit = () => {
if (disabled) return;
setIsEditing(true);
setSelectedValues(value);
setError("");
// Automatically open dropdown when editing starts
setTimeout(() => {
setIsOpen(true);
}, 0);
};
const handleCancel = () => {
setIsEditing(false);
setSelectedValues(value);
setError("");
setIsOpen(false);
if (onCancel) onCancel();
};
const handleSave = async () => {
if (disabled || isLoading) return;
// Check if values actually changed
const sortedCurrent = [...value].sort();
const sortedSelected = [...selectedValues].sort();
if (JSON.stringify(sortedCurrent) === JSON.stringify(sortedSelected)) {
setIsEditing(false);
setIsOpen(false);
return;
}
setIsLoading(true);
setError("");
try {
await onSave(selectedValues);
setIsEditing(false);
setIsOpen(false);
} catch (err) {
setError(err.message || "Failed to save");
} finally {
setIsLoading(false);
}
};
const handleKeyDown = (e) => {
if (e.key === "Enter") {
e.preventDefault();
handleSave();
} else if (e.key === "Escape") {
e.preventDefault();
handleCancel();
}
};
const toggleGroup = (groupId) => {
setSelectedValues((prev) => {
if (prev.includes(groupId)) {
return prev.filter((id) => id !== groupId);
} else {
return [...prev, groupId];
}
});
};
const _displayValue = useMemo(() => {
if (!value || value.length === 0) {
return "Ungrouped";
}
if (value.length === 1) {
const option = options.find((opt) => opt.id === value[0]);
return option ? option.name : "Unknown Group";
}
return `${value.length} groups`;
}, [value, options]);
const displayGroups = useMemo(() => {
if (!value || value.length === 0) {
return [];
}
return value
.map((groupId) => options.find((opt) => opt.id === groupId))
.filter(Boolean);
}, [value, options]);
if (isEditing) {
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<button
ref={buttonRef}
type="button"
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
disabled={isLoading}
className={`w-full px-3 py-1 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent flex items-center justify-between ${
error ? "border-red-500" : ""
} ${isLoading ? "opacity-50" : ""}`}
>
<span className="truncate">
{selectedValues.length === 0
? "Ungrouped"
: selectedValues.length === 1
? options.find((opt) => opt.id === selectedValues[0])
?.name || "Unknown Group"
: `${selectedValues.length} groups selected`}
</span>
<ChevronDown className="h-4 w-4 flex-shrink-0" />
</button>
{isOpen && (
<div
className="fixed z-50 bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-lg max-h-60 overflow-auto"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
minWidth: "200px",
}}
>
<div className="py-1">
{options.map((option) => (
<label
key={option.id}
className="w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center cursor-pointer"
>
<input
type="checkbox"
checked={selectedValues.includes(option.id)}
onChange={() => toggleGroup(option.id)}
className="mr-2 h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: option.color }}
>
{option.name}
</span>
</label>
))}
{options.length === 0 && (
<div className="px-3 py-2 text-sm text-secondary-500 dark:text-secondary-400">
No groups available
</div>
)}
</div>
</div>
)}
</div>
<button
type="button"
onClick={handleSave}
disabled={isLoading}
className="p-1 text-green-600 hover:text-green-700 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Save"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={handleCancel}
disabled={isLoading}
className="p-1 text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
title="Cancel"
>
<X className="h-4 w-4" />
</button>
</div>
{error && (
<span className="text-xs text-red-600 dark:text-red-400 mt-1 block">
{error}
</span>
)}
</div>
);
}
return (
<div className={`flex items-center gap-1 group ${className}`}>
{displayGroups.length === 0 ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
Ungrouped
</span>
) : (
<div className="flex items-center gap-1 flex-wrap">
{displayGroups.map((group) => (
<span
key={group.id}
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: group.color }}
>
{group.name}
</span>
))}
</div>
)}
{!disabled && (
<button
type="button"
onClick={handleEdit}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded transition-colors opacity-0 group-hover:opacity-100"
title="Edit groups"
>
<Edit2 className="h-3 w-3" />
</button>
)}
</div>
);
};
export default InlineMultiGroupEdit;

File diff suppressed because one or more lines are too long

View File

@@ -1,11 +1,16 @@
import { useQuery } from "@tanstack/react-query";
import { useEffect } from "react";
import { isAuthReady } from "../constants/authPhases";
import { useAuth } from "../contexts/AuthContext";
import { settingsAPI } from "../utils/api";
const LogoProvider = ({ children }) => {
const { authPhase, isAuthenticated } = useAuth();
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
enabled: isAuthReady(authPhase, isAuthenticated()),
});
useEffect(() => {

View File

@@ -1,4 +1,5 @@
import {
BarChart3,
Bell,
ChevronLeft,
ChevronRight,
@@ -82,6 +83,7 @@ const SettingsLayout = ({ children }) => {
name: "Alert Channels",
href: "/settings/alert-channels",
icon: Bell,
comingSoon: true,
},
{
name: "Notifications",
@@ -118,7 +120,6 @@ const SettingsLayout = ({ children }) => {
name: "Integrations",
href: "/settings/integrations",
icon: Wrench,
comingSoon: true,
},
],
});
@@ -141,6 +142,11 @@ const SettingsLayout = ({ children }) => {
href: "/settings/server-version",
icon: Code,
},
{
name: "Metrics",
href: "/settings/metrics",
icon: BarChart3,
},
],
});
}

View File

@@ -1,377 +1,453 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertCircle, Code, Download, Plus, Shield, X } from "lucide-react";
import { useId, useState } from "react";
import { agentFileAPI, settingsAPI } from "../../utils/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle,
Clock,
Download,
ExternalLink,
RefreshCw,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import api from "../../utils/api";
const AgentManagementTab = () => {
const scriptFileId = useId();
const scriptContentId = useId();
const [showUploadModal, setShowUploadModal] = useState(false);
const _queryClient = useQueryClient();
const [toast, setToast] = useState(null);
// Agent file queries and mutations
const {
data: agentFileInfo,
isLoading: agentFileLoading,
error: agentFileError,
refetch: refetchAgentFile,
} = useQuery({
queryKey: ["agentFile"],
queryFn: () => agentFileAPI.getInfo().then((res) => res.data),
});
// Auto-hide toast after 5 seconds
useEffect(() => {
if (toast) {
const timer = setTimeout(() => {
setToast(null);
}, 5000);
return () => clearTimeout(timer);
}
}, [toast]);
// Fetch settings for dynamic curl flags
const { data: settings } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Helper function to get curl flags based on settings
const getCurlFlags = () => {
return settings?.ignore_ssl_self_signed ? "-sk" : "-s";
const showToast = (message, type = "success") => {
setToast({ message, type });
};
const uploadAgentMutation = useMutation({
mutationFn: (scriptContent) =>
agentFileAPI.upload(scriptContent).then((res) => res.data),
// Agent version queries
const {
data: versionInfo,
isLoading: versionLoading,
error: versionError,
refetch: refetchVersion,
} = useQuery({
queryKey: ["agentVersion"],
queryFn: async () => {
try {
const response = await api.get("/agent/version");
console.log("🔍 Frontend received version info:", response.data);
return response.data;
} catch (error) {
console.error("Failed to fetch version info:", error);
throw error;
}
},
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
enabled: true, // Always enabled
retry: 3, // Retry failed requests
});
const {
data: _availableVersions,
isLoading: _versionsLoading,
error: _versionsError,
} = useQuery({
queryKey: ["agentVersions"],
queryFn: async () => {
try {
const response = await api.get("/agent/versions");
console.log("🔍 Frontend received available versions:", response.data);
return response.data;
} catch (error) {
console.error("Failed to fetch available versions:", error);
throw error;
}
},
enabled: true,
retry: 3,
});
const checkUpdatesMutation = useMutation({
mutationFn: async () => {
// First check GitHub for updates
await api.post("/agent/version/check");
// Then refresh current agent version detection
await api.post("/agent/version/refresh");
},
onSuccess: () => {
refetchAgentFile();
setShowUploadModal(false);
refetchVersion();
showToast("Successfully checked for updates", "success");
},
onError: (error) => {
console.error("Upload agent error:", error);
console.error("Check updates error:", error);
showToast(`Failed to check for updates: ${error.message}`, "error");
},
});
const downloadUpdateMutation = useMutation({
mutationFn: async () => {
// Download the latest binaries
const downloadResult = await api.post("/agent/version/download");
// Refresh current agent version detection after download
await api.post("/agent/version/refresh");
// Return the download result for success handling
return downloadResult;
},
onSuccess: (data) => {
console.log("Download completed:", data);
console.log("Download response data:", data.data);
refetchVersion();
// Show success message
const message =
data.data?.message || "Agent binaries downloaded successfully";
showToast(message, "success");
},
onError: (error) => {
console.error("Download update error:", error);
showToast(`Download failed: ${error.message}`, "error");
},
});
const getVersionStatus = () => {
console.log("🔍 getVersionStatus called with:", {
versionError,
versionInfo,
versionLoading,
});
if (versionError) {
console.log("❌ Version error detected:", versionError);
return {
status: "error",
message: "Failed to load version info",
Icon: AlertCircle,
color: "text-red-600",
};
}
if (!versionInfo || versionLoading) {
console.log("⏳ Loading state:", { versionInfo, versionLoading });
return {
status: "loading",
message: "Loading version info...",
Icon: RefreshCw,
color: "text-gray-600",
};
}
// Use the backend's updateStatus for proper semver comparison
switch (versionInfo.updateStatus) {
case "update-available":
return {
status: "update-available",
message: `Update available: ${versionInfo.latestVersion}`,
Icon: Clock,
color: "text-yellow-600",
};
case "newer-version":
return {
status: "newer-version",
message: `Newer version running: ${versionInfo.currentVersion}`,
Icon: CheckCircle,
color: "text-blue-600",
};
case "up-to-date":
return {
status: "up-to-date",
message: `Up to date: ${versionInfo.latestVersion}`,
Icon: CheckCircle,
color: "text-green-600",
};
case "no-agent":
return {
status: "no-agent",
message: "No agent binary found",
Icon: AlertCircle,
color: "text-orange-600",
};
case "github-unavailable":
return {
status: "github-unavailable",
message: `Agent running: ${versionInfo.currentVersion} (GitHub API unavailable)`,
Icon: CheckCircle,
color: "text-purple-600",
};
case "no-data":
return {
status: "no-data",
message: "No version data available",
Icon: AlertCircle,
color: "text-gray-600",
};
default:
return {
status: "unknown",
message: "Version status unknown",
Icon: AlertCircle,
color: "text-gray-600",
};
}
};
const versionStatus = getVersionStatus();
const StatusIcon = versionStatus.Icon;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<div className="flex items-center mb-2">
<Code className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Agent File Management
</h2>
{/* Toast Notification */}
{toast && (
<div
className={`fixed top-4 right-4 z-50 max-w-md rounded-lg shadow-lg border-2 p-4 flex items-start space-x-3 animate-in slide-in-from-top-5 ${
toast.type === "success"
? "bg-green-50 dark:bg-green-900/90 border-green-500 dark:border-green-600"
: "bg-red-50 dark:bg-red-900/90 border-red-500 dark:border-red-600"
}`}
>
<div
className={`flex-shrink-0 rounded-full p-1 ${
toast.type === "success"
? "bg-green-100 dark:bg-green-800"
: "bg-red-100 dark:bg-red-800"
}`}
>
{toast.type === "success" ? (
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
) : (
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
)}
</div>
<div className="flex-1">
<p
className={`text-sm font-medium ${
toast.type === "success"
? "text-green-800 dark:text-green-100"
: "text-red-800 dark:text-red-100"
}`}
>
{toast.message}
</p>
</div>
<p className="text-sm text-secondary-500 dark:text-secondary-300">
Manage the PatchMon agent script file used for installations and
updates
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => {
const url = "/api/v1/hosts/agent/download";
const link = document.createElement("a");
link.href = url;
link.download = "patchmon-agent.sh";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
className="btn-outline flex items-center gap-2"
onClick={() => setToast(null)}
className={`flex-shrink-0 rounded-lg p-1 transition-colors ${
toast.type === "success"
? "hover:bg-green-100 dark:hover:bg-green-800 text-green-600 dark:text-green-400"
: "hover:bg-red-100 dark:hover:bg-red-800 text-red-600 dark:text-red-400"
}`}
>
<Download className="h-4 w-4" />
Download
</button>
<button
type="button"
onClick={() => setShowUploadModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Replace Script
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Header */}
<div className="mb-6">
<h2 className="text-2xl font-bold text-secondary-900 dark:text-white mb-2">
Agent Version Management
</h2>
<p className="text-secondary-600 dark:text-secondary-400">
Monitor and manage agent versions across your infrastructure
</p>
</div>
{/* Content */}
{agentFileLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
) : agentFileError ? (
<div className="text-center py-8">
<p className="text-red-600 dark:text-red-400">
Error loading agent file: {agentFileError.message}
</p>
</div>
) : !agentFileInfo?.exists ? (
<div className="text-center py-8">
<Code className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
No agent script found
</p>
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
Upload an agent script to get started
</p>
</div>
) : (
<div className="space-y-6">
{/* Agent File Info */}
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Current Agent Script
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="flex items-center gap-2">
<Code className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Version:
</span>
<span className="text-sm text-secondary-900 dark:text-white font-mono">
{agentFileInfo.version}
</span>
</div>
<div className="flex items-center gap-2">
<Download className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Size:
</span>
<span className="text-sm text-secondary-900 dark:text-white">
{agentFileInfo.sizeFormatted}
</span>
</div>
<div className="flex items-center gap-2">
<Code className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Modified:
</span>
<span className="text-sm text-secondary-900 dark:text-white">
{new Date(agentFileInfo.lastModified).toLocaleDateString()}
</span>
</div>
</div>
</div>
{/* Usage Instructions */}
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div className="flex">
<Shield className="h-5 w-5 text-blue-400 dark:text-blue-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">
Agent Script Usage
</h3>
<div className="mt-2 text-sm text-blue-700 dark:text-blue-300">
<p className="mb-2">This script is used for:</p>
<ul className="list-disc list-inside space-y-1">
<li>New agent installations via the install script</li>
<li>
Agent downloads from the /api/v1/hosts/agent/download
endpoint
</li>
<li>Manual agent deployments and updates</li>
</ul>
</div>
</div>
</div>
</div>
{/* Uninstall Instructions */}
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<Shield 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">
Agent Uninstall Command
</h3>
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
<p className="mb-2">
To completely remove PatchMon from a host:
</p>
<div className="flex items-center gap-2">
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
curl {getCurlFlags()} {window.location.origin}
/api/v1/hosts/remove | sudo bash
</div>
<button
type="button"
onClick={() => {
const command = `curl ${getCurlFlags()} ${window.location.origin}/api/v1/hosts/remove | sudo bash`;
navigator.clipboard.writeText(command);
// You could add a toast notification here
}}
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
>
Copy
</button>
</div>
<p className="mt-2 text-xs">
This will remove all PatchMon files, configuration, and
crontab entries
</p>
</div>
</div>
</div>
</div>
</div>
)}
{/* Agent Upload Modal */}
{showUploadModal && (
<AgentUploadModal
isOpen={showUploadModal}
onClose={() => setShowUploadModal(false)}
onSubmit={uploadAgentMutation.mutate}
isLoading={uploadAgentMutation.isPending}
error={uploadAgentMutation.error}
scriptFileId={scriptFileId}
scriptContentId={scriptContentId}
/>
)}
</div>
);
};
// Agent Upload Modal Component
const AgentUploadModal = ({
isOpen,
onClose,
onSubmit,
isLoading,
error,
scriptFileId,
scriptContentId,
}) => {
const [scriptContent, setScriptContent] = useState("");
const [uploadError, setUploadError] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
setUploadError("");
if (!scriptContent.trim()) {
setUploadError("Script content is required");
return;
}
if (!scriptContent.trim().startsWith("#!/")) {
setUploadError(
"Script must start with a shebang (#!/bin/bash or #!/bin/sh)",
);
return;
}
onSubmit(scriptContent);
};
const handleFileUpload = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
setScriptContent(event.target.result);
setUploadError("");
};
reader.readAsText(file);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Replace Agent Script
</h3>
<button
type="button"
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
{/* Status Banner */}
<div
className={`rounded-xl shadow-sm p-6 border-2 ${
versionStatus.status === "up-to-date"
? "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800"
: versionStatus.status === "update-available"
? "bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800"
: versionStatus.status === "no-agent"
? "bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800"
: "bg-white dark:bg-secondary-800 border-secondary-200 dark:border-secondary-600"
}`}
>
<div className="flex items-start justify-between">
<div className="flex items-start space-x-4">
<div
className={`p-3 rounded-lg ${
versionStatus.status === "up-to-date"
? "bg-green-100 dark:bg-green-800"
: versionStatus.status === "update-available"
? "bg-yellow-100 dark:bg-yellow-800"
: versionStatus.status === "no-agent"
? "bg-orange-100 dark:bg-orange-800"
: "bg-secondary-100 dark:bg-secondary-700"
}`}
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="px-6 py-4">
<div className="space-y-4">
{StatusIcon && (
<StatusIcon className={`h-6 w-6 ${versionStatus.color}`} />
)}
</div>
<div>
<label
htmlFor={scriptFileId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Upload Script File
</label>
<input
id={scriptFileId}
type="file"
accept=".sh"
onChange={handleFileUpload}
className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Select a .sh file to upload, or paste the script content below
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-1">
{versionStatus.message}
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-400">
{versionStatus.status === "up-to-date" &&
"All agent binaries are current"}
{versionStatus.status === "update-available" &&
"A newer version is available for download"}
{versionStatus.status === "no-agent" &&
"Download agent binaries to get started"}
{versionStatus.status === "github-unavailable" &&
"Cannot check for updates at this time"}
{![
"up-to-date",
"update-available",
"no-agent",
"github-unavailable",
].includes(versionStatus.status) &&
"Version information unavailable"}
</p>
</div>
<div>
<label
htmlFor={scriptContentId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Script Content *
</label>
<textarea
id={scriptContentId}
value={scriptContent}
onChange={(e) => {
setScriptContent(e.target.value);
setUploadError("");
}}
rows={15}
className="block w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
placeholder="#!/bin/bash&#10;&#10;# PatchMon Agent Script&#10;VERSION=&quot;1.0.0&quot;&#10;&#10;# Your script content here..."
/>
</div>
{(uploadError || error) && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-3">
<p className="text-sm text-red-800 dark:text-red-200">
{uploadError ||
error?.response?.data?.error ||
error?.message}
</p>
</div>
)}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-md p-3">
<div className="flex">
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400 mr-2 mt-0.5" />
<div className="text-sm text-yellow-800 dark:text-yellow-200">
<p className="font-medium">Important:</p>
<ul className="mt-1 list-disc list-inside space-y-1">
<li>This will replace the current agent script file</li>
<li>A backup will be created automatically</li>
<li>All new installations will use this script</li>
<li>
Existing agents will download this version on their next
update
</li>
</ul>
</div>
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button type="button" onClick={onClose} className="btn-outline">
Cancel
</button>
<button
type="submit"
disabled={isLoading || !scriptContent.trim()}
className="btn-primary"
>
{isLoading ? "Uploading..." : "Replace Script"}
</button>
</div>
</form>
<button
type="button"
onClick={() => checkUpdatesMutation.mutate()}
disabled={checkUpdatesMutation.isPending}
className="flex items-center px-4 py-2 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 rounded-lg hover:bg-secondary-50 dark:hover:bg-secondary-600 border border-secondary-300 dark:border-secondary-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-sm hover:shadow"
>
<RefreshCw
className={`h-4 w-4 mr-2 ${checkUpdatesMutation.isPending ? "animate-spin" : ""}`}
/>
{checkUpdatesMutation.isPending
? "Checking..."
: "Check for Updates"}
</button>
</div>
</div>
{/* Version Information Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Current Version Card */}
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-6 border border-secondary-200 dark:border-secondary-600 hover:shadow-md transition-shadow duration-200">
<h4 className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
Current Version
</h4>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
{versionInfo?.currentVersion || (
<span className="text-lg text-secondary-400 dark:text-secondary-500">
Not detected
</span>
)}
</p>
</div>
{/* Latest Version Card */}
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-6 border border-secondary-200 dark:border-secondary-600 hover:shadow-md transition-shadow duration-200">
<h4 className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
Latest Available
</h4>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">
{versionInfo?.latestVersion || (
<span className="text-lg text-secondary-400 dark:text-secondary-500">
Unknown
</span>
)}
</p>
</div>
{/* Last Checked Card */}
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-6 border border-secondary-200 dark:border-secondary-600 hover:shadow-md transition-shadow duration-200">
<h4 className="text-sm font-medium text-secondary-500 dark:text-secondary-400 mb-2">
Last Checked
</h4>
<p className="text-lg font-semibold text-secondary-900 dark:text-white">
{versionInfo?.lastChecked
? new Date(versionInfo.lastChecked).toLocaleString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})
: "Never"}
</p>
</div>
</div>
{/* Download Updates Section */}
<div className="bg-gradient-to-br from-primary-50 to-blue-50 dark:from-secondary-800 dark:to-secondary-800 rounded-xl shadow-sm p-8 border border-primary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-xl font-bold text-secondary-900 dark:text-white mb-3">
{!versionInfo?.currentVersion
? "Get Started with Agent Binaries"
: versionStatus.status === "update-available"
? "New Agent Version Available"
: "Agent Binaries"}
</h3>
<p className="text-secondary-700 dark:text-secondary-300 mb-4">
{!versionInfo?.currentVersion
? "No agent binaries detected. Download from GitHub to begin managing your agents."
: versionStatus.status === "update-available"
? `A new agent version (${versionInfo.latestVersion}) is available. Download the latest binaries from GitHub.`
: "Download or redownload agent binaries from GitHub."}
</p>
<div className="flex items-center space-x-4">
<button
type="button"
onClick={() => downloadUpdateMutation.mutate()}
disabled={downloadUpdateMutation.isPending}
className="flex items-center px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 shadow-md hover:shadow-lg font-medium"
>
{downloadUpdateMutation.isPending ? (
<>
<RefreshCw className="h-5 w-5 mr-2 animate-spin" />
Downloading...
</>
) : (
<>
<Download className="h-5 w-5 mr-2" />
{!versionInfo?.currentVersion
? "Download Binaries"
: versionStatus.status === "update-available"
? "Download New Agent Version"
: "Redownload Binaries"}
</>
)}
</button>
<a
href="https://github.com/PatchMon/PatchMon-agent/releases"
target="_blank"
rel="noopener noreferrer"
className="flex items-center px-4 py-3 text-secondary-700 dark:text-secondary-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors duration-200 font-medium"
>
<ExternalLink className="h-4 w-4 mr-2" />
View on GitHub
</a>
</div>
</div>
</div>
</div>
{/* Supported Architectures */}
{versionInfo?.supportedArchitectures &&
versionInfo.supportedArchitectures.length > 0 && (
<div className="bg-white dark:bg-secondary-800 rounded-xl shadow-sm p-6 border border-secondary-200 dark:border-secondary-600">
<h4 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
Supported Architectures
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{versionInfo.supportedArchitectures.map((arch) => (
<div
key={arch}
className="flex items-center justify-center px-4 py-3 bg-secondary-50 dark:bg-secondary-700 rounded-lg border border-secondary-200 dark:border-secondary-600"
>
<code className="text-sm font-mono text-secondary-700 dark:text-secondary-300">
{arch}
</code>
</div>
))}
</div>
</div>
)}
</div>
);
};

View File

@@ -446,6 +446,53 @@ const AgentUpdatesTab = () => {
</div>
)}
</form>
{/* Uninstall Instructions */}
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<Shield 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">
Agent Uninstall Command
</h3>
<div className="mt-2 text-sm text-red-700 dark:text-red-300">
<p className="mb-3">To completely remove PatchMon from a host:</p>
{/* Go Agent Uninstall */}
<div className="mb-3">
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="bg-red-100 dark:bg-red-800 rounded p-2 font-mono text-xs flex-1">
sudo patchmon-agent uninstall
</div>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(
"sudo patchmon-agent uninstall",
);
}}
className="px-2 py-1 bg-red-200 dark:bg-red-700 text-red-800 dark:text-red-200 rounded text-xs hover:bg-red-300 dark:hover:bg-red-600 transition-colors"
>
Copy
</button>
</div>
<div className="text-xs text-red-600 dark:text-red-400">
Options: <code>--remove-config</code>,{" "}
<code>--remove-logs</code>, <code>--remove-all</code>,{" "}
<code>--force</code>
</div>
</div>
</div>
<p className="mt-2 text-xs">
This command will remove all PatchMon files, configuration,
and crontab entries
</p>
</div>
</div>
</div>
</div>
</div>
);
};

View File

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

View File

@@ -319,9 +319,8 @@ const UsersTab = () => {
user={editingUser}
isOpen={!!editingUser}
onClose={() => setEditingUser(null)}
onUserUpdated={() => {
queryClient.invalidateQueries(["users"]);
}}
onUpdateUser={updateUserMutation.mutate}
isLoading={updateUserMutation.isPending}
roles={roles}
/>
)}
@@ -591,7 +590,14 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
};
// Edit User Modal Component
const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
const EditUserModal = ({
user,
isOpen,
onClose,
onUpdateUser,
isLoading,
roles,
}) => {
const editUsernameId = useId();
const editEmailId = useId();
const editFirstNameId = useId();
@@ -607,7 +613,6 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
role: user?.role || "user",
is_active: user?.is_active ?? true,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
@@ -635,22 +640,18 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
setSuccess(false);
try {
await adminUsersAPI.update(user.id, formData);
await onUpdateUser({ id: user.id, data: formData });
setSuccess(true);
onUserUpdated();
// Auto-close after 1.5 seconds
setTimeout(() => {
onClose();
}, 1500);
} catch (err) {
setError(err.response?.data?.error || "Failed to update user");
} finally {
setIsLoading(false);
}
};

View File

@@ -128,12 +128,14 @@ const VersionUpdateTab = () => {
<span className="text-lg font-mono text-secondary-900 dark:text-white">
{versionInfo.github.latestRelease.tagName}
</span>
<div className="text-xs text-secondary-500 dark:text-secondary-400">
Published:{" "}
{new Date(
versionInfo.github.latestRelease.publishedAt,
).toLocaleDateString()}
</div>
{versionInfo.github.latestRelease.publishedAt && (
<div className="text-xs text-secondary-500 dark:text-secondary-400">
Published:{" "}
{new Date(
versionInfo.github.latestRelease.publishedAt,
).toLocaleDateString()}
</div>
)}
</div>
</div>
)}

View File

@@ -7,6 +7,7 @@ import {
} from "react";
import { flushSync } from "react-dom";
import { AUTH_PHASES, isAuthPhase } from "../constants/authPhases";
import { isCorsError } from "../utils/api";
const AuthContext = createContext();
@@ -120,9 +121,50 @@ export const AuthProvider = ({ children }) => {
return { success: true };
} else {
// Handle HTTP error responses (like 500 CORS errors)
console.log("HTTP error response:", response.status, data);
// Check if this is a CORS error based on the response data
if (
data.message?.includes("Not allowed by CORS") ||
data.message?.includes("CORS") ||
data.error?.includes("CORS")
) {
return {
success: false,
error:
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
};
}
return { success: false, error: data.error || "Login failed" };
}
} catch {
} catch (error) {
console.log("Login error:", error);
console.log("Error response:", error.response);
console.log("Error message:", error.message);
// Check for CORS/network errors first
if (isCorsError(error)) {
return {
success: false,
error:
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
};
}
// Check for other network errors
if (
error.name === "TypeError" &&
error.message?.includes("Failed to fetch")
) {
return {
success: false,
error:
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
};
}
return { success: false, error: "Network error occurred" };
}
};
@@ -167,9 +209,46 @@ export const AuthProvider = ({ children }) => {
localStorage.setItem("user", JSON.stringify(data.user));
return { success: true, user: data.user };
} else {
// Handle HTTP error responses (like 500 CORS errors)
console.log("HTTP error response:", response.status, data);
// Check if this is a CORS error based on the response data
if (
data.message?.includes("Not allowed by CORS") ||
data.message?.includes("CORS") ||
data.error?.includes("CORS")
) {
return {
success: false,
error:
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
};
}
return { success: false, error: data.error || "Update failed" };
}
} catch {
} catch (error) {
// Check for CORS/network errors first
if (isCorsError(error)) {
return {
success: false,
error:
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
};
}
// Check for other network errors
if (
error.name === "TypeError" &&
error.message?.includes("Failed to fetch")
) {
return {
success: false,
error:
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
};
}
return { success: false, error: "Network error occurred" };
}
};
@@ -190,12 +269,49 @@ export const AuthProvider = ({ children }) => {
if (response.ok) {
return { success: true };
} else {
// Handle HTTP error responses (like 500 CORS errors)
console.log("HTTP error response:", response.status, data);
// Check if this is a CORS error based on the response data
if (
data.message?.includes("Not allowed by CORS") ||
data.message?.includes("CORS") ||
data.error?.includes("CORS")
) {
return {
success: false,
error:
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
};
}
return {
success: false,
error: data.error || "Password change failed",
};
}
} catch {
} catch (error) {
// Check for CORS/network errors first
if (isCorsError(error)) {
return {
success: false,
error:
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
};
}
// Check for other network errors
if (
error.name === "TypeError" &&
error.message?.includes("Failed to fetch")
) {
return {
success: false,
error:
"CORS_ORIGIN mismatch - please set your URL in your environment variable",
};
}
return { success: false, error: "Network error occurred" };
}
};

View File

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

View File

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

View File

@@ -0,0 +1,666 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
ArrowDown,
ArrowUp,
ArrowUpDown,
CheckCircle,
Clock,
Play,
Settings,
XCircle,
Zap,
} from "lucide-react";
import { useState } from "react";
import api from "../utils/api";
const Automation = () => {
const [activeTab, setActiveTab] = useState("overview");
const [sortField, setSortField] = useState("nextRunTimestamp");
const [sortDirection, setSortDirection] = useState("asc");
// Fetch automation overview data
const { data: overview, isLoading: overviewLoading } = useQuery({
queryKey: ["automation-overview"],
queryFn: async () => {
const response = await api.get("/automation/overview");
return response.data.data;
},
refetchInterval: 30000, // Refresh every 30 seconds
});
// Fetch queue statistics
useQuery({
queryKey: ["automation-stats"],
queryFn: async () => {
const response = await api.get("/automation/stats");
return response.data.data;
},
refetchInterval: 30000,
});
// Fetch recent jobs
useQuery({
queryKey: ["automation-jobs"],
queryFn: async () => {
const jobs = await Promise.all([
api
.get("/automation/jobs/github-update-check?limit=5")
.then((r) => r.data.data || []),
api
.get("/automation/jobs/session-cleanup?limit=5")
.then((r) => r.data.data || []),
]);
return {
githubUpdate: jobs[0],
sessionCleanup: jobs[1],
};
},
refetchInterval: 30000,
});
const _getStatusIcon = (status) => {
switch (status) {
case "completed":
return <CheckCircle className="h-4 w-4 text-green-500" />;
case "failed":
return <XCircle className="h-4 w-4 text-red-500" />;
case "active":
return <Activity className="h-4 w-4 text-blue-500 animate-pulse" />;
default:
return <Clock className="h-4 w-4 text-gray-500" />;
}
};
const _getStatusColor = (status) => {
switch (status) {
case "completed":
return "bg-green-100 text-green-800";
case "failed":
return "bg-red-100 text-red-800";
case "active":
return "bg-blue-100 text-blue-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const _formatDate = (dateString) => {
if (!dateString) return "N/A";
return new Date(dateString).toLocaleString();
};
const _formatDuration = (ms) => {
if (!ms) return "N/A";
return `${ms}ms`;
};
const getStatusBadge = (status) => {
switch (status) {
case "Success":
return (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800">
Success
</span>
);
case "Failed":
return (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-red-100 text-red-800">
Failed
</span>
);
case "Never run":
return (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-800">
Never run
</span>
);
default:
return (
<span className="px-2 py-1 text-xs font-medium rounded-full bg-gray-100 text-gray-800">
{status}
</span>
);
}
};
const getNextRunTime = (schedule, _lastRun) => {
if (schedule === "Manual only") return "Manual trigger only";
if (schedule.includes("Agent-driven")) return "Agent-driven (automatic)";
if (schedule === "Daily at midnight") {
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
return tomorrow.toLocaleString([], {
hour12: true,
hour: "numeric",
minute: "2-digit",
day: "numeric",
month: "numeric",
year: "numeric",
});
}
if (schedule === "Daily at 2 AM") {
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(2, 0, 0, 0);
return tomorrow.toLocaleString([], {
hour12: true,
hour: "numeric",
minute: "2-digit",
day: "numeric",
month: "numeric",
year: "numeric",
});
}
if (schedule === "Daily at 3 AM") {
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(3, 0, 0, 0);
return tomorrow.toLocaleString([], {
hour12: true,
hour: "numeric",
minute: "2-digit",
day: "numeric",
month: "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") {
const now = new Date();
const nextHour = new Date(now);
nextHour.setHours(nextHour.getHours() + 1, 0, 0, 0);
return nextHour.toLocaleString([], {
hour12: true,
hour: "numeric",
minute: "2-digit",
day: "numeric",
month: "numeric",
year: "numeric",
});
}
return "Unknown";
};
const getNextRunTimestamp = (schedule) => {
if (schedule === "Manual only") return Number.MAX_SAFE_INTEGER; // Manual tasks go to bottom
if (schedule.includes("Agent-driven")) return Number.MAX_SAFE_INTEGER - 1; // Agent-driven tasks near bottom but above manual
if (schedule === "Daily at midnight") {
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(0, 0, 0, 0);
return tomorrow.getTime();
}
if (schedule === "Daily at 2 AM") {
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(2, 0, 0, 0);
return tomorrow.getTime();
}
if (schedule === "Daily at 3 AM") {
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(3, 0, 0, 0);
return tomorrow.getTime();
}
if (schedule === "Daily at 4 AM") {
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(4, 0, 0, 0);
return tomorrow.getTime();
}
if (schedule === "Every hour") {
const now = new Date();
const nextHour = new Date(now);
nextHour.setHours(nextHour.getHours() + 1, 0, 0, 0);
return nextHour.getTime();
}
return Number.MAX_SAFE_INTEGER; // Unknown schedules go to bottom
};
const openBullBoard = () => {
const token = localStorage.getItem("token");
if (!token) {
alert("Please log in to access the Queue Monitor");
return;
}
// Use the proxied URL through the frontend (port 3000)
// This avoids CORS issues as everything goes through the same origin
const url = `/bullboard?token=${encodeURIComponent(token)}`;
// Open in a new tab instead of a new window
const bullBoardWindow = window.open(url, "_blank");
// Add a message listener to handle authentication failures
if (bullBoardWindow) {
// Listen for authentication failures and refresh with token
const checkAuth = () => {
try {
// Check if the Bull Board window is still open
if (bullBoardWindow.closed) return;
// Inject a script to handle authentication failures
bullBoardWindow.postMessage(
{
type: "BULL_BOARD_TOKEN",
token: token,
},
window.location.origin,
);
} catch (e) {
console.log("Could not communicate with Bull Board window:", e);
}
};
// Send token after a short delay to ensure Bull Board is loaded
setTimeout(checkAuth, 1000);
}
};
const triggerManualJob = async (jobType, data = {}) => {
try {
let endpoint;
if (jobType === "github") {
endpoint = "/automation/trigger/github-update";
} else if (jobType === "sessions") {
endpoint = "/automation/trigger/session-cleanup";
} else if (jobType === "orphaned-repos") {
endpoint = "/automation/trigger/orphaned-repo-cleanup";
} else if (jobType === "orphaned-packages") {
endpoint = "/automation/trigger/orphaned-package-cleanup";
} else if (jobType === "docker-inventory") {
endpoint = "/automation/trigger/docker-inventory-cleanup";
} else if (jobType === "agent-collection") {
endpoint = "/automation/trigger/agent-collection";
}
const _response = await api.post(endpoint, data);
// Refresh data
window.location.reload();
} catch (error) {
console.error("Error triggering job:", error);
alert(
"Failed to trigger job: " +
(error.response?.data?.error || error.message),
);
}
};
const handleSort = (field) => {
if (sortField === field) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc");
} else {
setSortField(field);
setSortDirection("asc");
}
};
const getSortIcon = (field) => {
if (sortField !== field) return <ArrowUpDown className="h-4 w-4" />;
return sortDirection === "asc" ? (
<ArrowUp className="h-4 w-4" />
) : (
<ArrowDown className="h-4 w-4" />
);
};
// Sort automations based on current sort settings
const sortedAutomations = overview?.automations
? [...overview.automations].sort((a, b) => {
let aValue, bValue;
switch (sortField) {
case "name":
aValue = a.name.toLowerCase();
bValue = b.name.toLowerCase();
break;
case "schedule":
aValue = a.schedule.toLowerCase();
bValue = b.schedule.toLowerCase();
break;
case "lastRun":
// Convert "Never" to empty string for proper sorting
aValue = a.lastRun === "Never" ? "" : a.lastRun;
bValue = b.lastRun === "Never" ? "" : b.lastRun;
break;
case "lastRunTimestamp":
aValue = a.lastRunTimestamp || 0;
bValue = b.lastRunTimestamp || 0;
break;
case "nextRunTimestamp":
aValue = getNextRunTimestamp(a.schedule);
bValue = getNextRunTimestamp(b.schedule);
break;
case "status":
aValue = a.status.toLowerCase();
bValue = b.status.toLowerCase();
break;
default:
aValue = a[sortField];
bValue = b[sortField];
}
if (aValue < bValue) return sortDirection === "asc" ? -1 : 1;
if (aValue > bValue) return sortDirection === "asc" ? 1 : -1;
return 0;
})
: [];
const tabs = [{ id: "overview", name: "Overview", icon: Settings }];
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
Automation Management
</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Monitor and manage automated server operations, agent
communications, and patch deployments
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={openBullBoard}
className="btn-outline flex items-center gap-2"
title="Open Bull Board Queue Monitor"
>
<svg
className="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 36 36"
role="img"
aria-label="Bull Board"
>
<circle fill="#DD2E44" cx="18" cy="18" r="18" />
<circle fill="#FFF" cx="18" cy="18" r="13.5" />
<circle fill="#DD2E44" cx="18" cy="18" r="10" />
<circle fill="#FFF" cx="18" cy="18" r="6" />
<circle fill="#DD2E44" cx="18" cy="18" r="3" />
<path
opacity=".2"
d="M18.24 18.282l13.144 11.754s-2.647 3.376-7.89 5.109L17.579 18.42l.661-.138z"
/>
<path
fill="#FFAC33"
d="M18.294 19a.994.994 0 01-.704-1.699l.563-.563a.995.995 0 011.408 1.407l-.564.563a.987.987 0 01-.703.292z"
/>
<path
fill="#55ACEE"
d="M24.016 6.981c-.403 2.079 0 4.691 0 4.691l7.054-7.388c.291-1.454-.528-3.932-1.718-4.238-1.19-.306-4.079.803-5.336 6.935zm5.003 5.003c-2.079.403-4.691 0-4.691 0l7.388-7.054c1.454-.291 3.932.528 4.238 1.718.306 1.19-.803 4.079-6.935 5.336z"
/>
<path
fill="#3A87C2"
d="M32.798 4.485L21.176 17.587c-.362.362-1.673.882-2.51.046-.836-.836-.419-2.08-.057-2.443L31.815 3.501s.676-.635 1.159-.152-.176 1.136-.176 1.136z"
/>
</svg>
Queue Monitor
</button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Scheduled Tasks Card */}
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Clock className="h-5 w-5 text-warning-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Scheduled Tasks
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{overviewLoading ? "..." : overview?.scheduledTasks || 0}
</p>
</div>
</div>
</div>
{/* Running Tasks Card */}
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Play className="h-5 w-5 text-success-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Running Tasks
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{overviewLoading ? "..." : overview?.runningTasks || 0}
</p>
</div>
</div>
</div>
{/* Failed Tasks Card */}
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<XCircle className="h-5 w-5 text-red-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Failed Tasks
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{overviewLoading ? "..." : overview?.failedTasks || 0}
</p>
</div>
</div>
</div>
{/* Total Task Runs Card */}
<div className="card p-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<Zap className="h-5 w-5 text-secondary-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">
Total Task Runs
</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{overviewLoading ? "..." : overview?.totalAutomations || 0}
</p>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="mb-6">
<div className="border-b border-gray-200 dark:border-gray-700">
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => (
<button
type="button"
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`py-2 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
activeTab === tab.id
? "border-blue-500 text-blue-600 dark:text-blue-400"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300"
}`}
>
<tab.icon className="h-4 w-4" />
{tab.name}
</button>
))}
</nav>
</div>
</div>
{/* Tab Content */}
{activeTab === "overview" && (
<div className="card p-6">
{overviewLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-2 text-sm text-secondary-500">
Loading automations...
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Run
</th>
<th
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-600"
onClick={() => handleSort("name")}
>
<div className="flex items-center gap-1">
Task
{getSortIcon("name")}
</div>
</th>
<th
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-600"
onClick={() => handleSort("schedule")}
>
<div className="flex items-center gap-1">
Frequency
{getSortIcon("schedule")}
</div>
</th>
<th
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-600"
onClick={() => handleSort("lastRunTimestamp")}
>
<div className="flex items-center gap-1">
Last Run
{getSortIcon("lastRunTimestamp")}
</div>
</th>
<th
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-600"
onClick={() => handleSort("nextRunTimestamp")}
>
<div className="flex items-center gap-1">
Next Run
{getSortIcon("nextRunTimestamp")}
</div>
</th>
<th
className="px-4 py-2 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider cursor-pointer hover:bg-secondary-100 dark:hover:bg-secondary-600"
onClick={() => handleSort("status")}
>
<div className="flex items-center gap-1">
Status
{getSortIcon("status")}
</div>
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{sortedAutomations.map((automation) => (
<tr
key={automation.queue}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
<td className="px-4 py-2 whitespace-nowrap">
{automation.schedule !== "Manual only" ? (
<button
type="button"
onClick={() => {
if (automation.queue.includes("github")) {
triggerManualJob("github");
} else if (automation.queue.includes("session")) {
triggerManualJob("sessions");
} else if (
automation.queue.includes("orphaned-repo")
) {
triggerManualJob("orphaned-repos");
} else if (
automation.queue.includes("orphaned-package")
) {
triggerManualJob("orphaned-packages");
} else if (
automation.queue.includes("docker-inventory")
) {
triggerManualJob("docker-inventory");
} else if (
automation.queue.includes("agent-commands")
) {
triggerManualJob("agent-collection");
}
}}
className="inline-flex items-center justify-center w-6 h-6 border border-transparent rounded text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 transition-colors duration-200"
title="Run Now"
>
<Play className="h-3 w-3" />
</button>
) : (
<span className="text-gray-400 text-xs">Manual</span>
)}
</td>
<td className="px-4 py-2 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{automation.name}
</div>
<div className="text-xs text-secondary-500 dark:text-secondary-400">
{automation.description}
</div>
</div>
</td>
<td className="px-4 py-2 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
{automation.schedule}
</td>
<td className="px-4 py-2 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
{automation.lastRun}
</td>
<td className="px-4 py-2 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
{getNextRunTime(
automation.schedule,
automation.lastRun,
)}
</td>
<td className="px-4 py-2 whitespace-nowrap">
{getStatusBadge(automation.status)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
};
export default Automation;

View File

@@ -6,6 +6,8 @@ import {
Chart as ChartJS,
Legend,
LinearScale,
LineElement,
PointElement,
Title,
Tooltip,
} from "chart.js";
@@ -23,7 +25,7 @@ import {
WifiOff,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Bar, Doughnut, Pie } from "react-chartjs-2";
import { Bar, Doughnut, Line, Pie } from "react-chartjs-2";
import { useNavigate } from "react-router-dom";
import DashboardSettingsModal from "../components/DashboardSettingsModal";
import { useAuth } from "../contexts/AuthContext";
@@ -43,12 +45,16 @@ ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
Title,
);
const Dashboard = () => {
const [showSettingsModal, setShowSettingsModal] = useState(false);
const [cardPreferences, setCardPreferences] = useState([]);
const [packageTrendsPeriod, setPackageTrendsPeriod] = useState("1"); // days
const [packageTrendsHost, setPackageTrendsHost] = useState("all"); // host filter
const navigate = useNavigate();
const { isDark } = useTheme();
const { user } = useAuth();
@@ -91,7 +97,7 @@ const Dashboard = () => {
navigate("/repositories");
};
const handleOSDistributionClick = () => {
const _handleOSDistributionClick = () => {
navigate("/hosts?showFilters=true", { replace: true });
};
@@ -99,7 +105,7 @@ const Dashboard = () => {
navigate("/hosts?filter=needsUpdates", { replace: true });
};
const handlePackagePriorityClick = () => {
const _handlePackagePriorityClick = () => {
navigate("/packages?filter=security");
};
@@ -144,8 +150,8 @@ const Dashboard = () => {
// Map priority names to filter parameters
if (priorityName.toLowerCase().includes("security")) {
navigate("/packages?filter=security", { replace: true });
} else if (priorityName.toLowerCase().includes("outdated")) {
navigate("/packages?filter=outdated", { replace: true });
} else if (priorityName.toLowerCase().includes("regular")) {
navigate("/packages?filter=regular", { replace: true });
}
}
};
@@ -189,6 +195,28 @@ const Dashboard = () => {
refetchOnWindowFocus: false, // Don't refetch when window regains focus
});
// Package trends data query
const {
data: packageTrendsData,
isLoading: packageTrendsLoading,
error: _packageTrendsError,
refetch: refetchPackageTrends,
isFetching: packageTrendsFetching,
} = useQuery({
queryKey: ["packageTrends", packageTrendsPeriod, packageTrendsHost],
queryFn: () => {
const params = {
days: packageTrendsPeriod,
};
if (packageTrendsHost !== "all") {
params.hostId = packageTrendsHost;
}
return dashboardAPI.getPackageTrends(params).then((res) => res.data);
},
staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false,
});
// Fetch recent users (permission protected server-side)
const { data: recentUsers } = useQuery({
queryKey: ["dashboardRecentUsers"],
@@ -299,6 +327,8 @@ const Dashboard = () => {
].includes(cardId)
) {
return "charts";
} else if (["packageTrends"].includes(cardId)) {
return "charts";
} else if (["erroredHosts", "quickStats"].includes(cardId)) {
return "fullwidth";
}
@@ -312,6 +342,8 @@ const Dashboard = () => {
return "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4";
case "charts":
return "grid grid-cols-1 lg:grid-cols-3 gap-6";
case "widecharts":
return "grid grid-cols-1 lg:grid-cols-3 gap-6";
case "fullwidth":
return "space-y-6";
default:
@@ -651,17 +683,7 @@ const Dashboard = () => {
case "osDistribution":
return (
<button
type="button"
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
onClick={handleOSDistributionClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleOSDistributionClick();
}
}}
>
<div className="card p-6 w-full">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
OS Distribution
</h3>
@@ -670,22 +692,12 @@ const Dashboard = () => {
<Pie data={osChartData} options={chartOptions} />
</div>
</div>
</button>
</div>
);
case "osDistributionDoughnut":
return (
<button
type="button"
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
onClick={handleOSDistributionClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleOSDistributionClick();
}
}}
>
<div className="card p-6 w-full">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
OS Distribution
</h3>
@@ -694,29 +706,19 @@ const Dashboard = () => {
<Doughnut data={osChartData} options={doughnutChartOptions} />
</div>
</div>
</button>
</div>
);
case "osDistributionBar":
return (
<button
type="button"
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
onClick={handleOSDistributionClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleOSDistributionClick();
}
}}
>
<div className="card p-6 w-full">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
OS Distribution
</h3>
<div className="h-64">
<Bar data={osBarChartData} options={barChartOptions} />
</div>
</button>
</div>
);
case "updateStatus":
@@ -748,19 +750,9 @@ const Dashboard = () => {
case "packagePriority":
return (
<button
type="button"
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 w-full text-left"
onClick={handlePackagePriorityClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handlePackagePriorityClick();
}
}}
>
<div className="card p-6 w-full">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Package Priority
Outdated Packages by Priority
</h3>
<div className="h-64 w-full flex items-center justify-center">
<div className="w-full h-full max-w-sm">
@@ -770,7 +762,86 @@ const Dashboard = () => {
/>
</div>
</div>
</button>
</div>
);
case "packageTrends":
return (
<div className="card p-6 w-full">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Package Trends Over Time
</h3>
<div className="flex items-center gap-3">
{/* Refresh Button */}
<button
type="button"
onClick={() => refetchPackageTrends()}
disabled={packageTrendsFetching}
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-2 focus:ring-primary-500 focus:border-primary-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
title="Refresh data"
>
<RefreshCw
className={`h-4 w-4 ${packageTrendsFetching ? "animate-spin" : ""}`}
/>
Refresh
</button>
{/* Period Selector */}
<select
value={packageTrendsPeriod}
onChange={(e) => setPackageTrendsPeriod(e.target.value)}
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="1">Last 24 hours</option>
<option value="7">Last 7 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
<option value="180">Last 6 months</option>
<option value="365">Last year</option>
</select>
{/* Host Selector */}
<select
value={packageTrendsHost}
onChange={(e) => setPackageTrendsHost(e.target.value)}
className="px-3 py-1.5 text-sm border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="all">All Hosts</option>
{packageTrendsData?.hosts?.length > 0 ? (
packageTrendsData.hosts.map((host) => (
<option key={host.id} value={host.id}>
{host.friendly_name || host.hostname}
</option>
))
) : (
<option disabled>
{packageTrendsLoading
? "Loading hosts..."
: "No hosts available"}
</option>
)}
</select>
</div>
</div>
<div className="h-64 w-full">
{packageTrendsLoading ? (
<div className="flex items-center justify-center h-full">
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
</div>
) : packageTrendsData?.chartData ? (
<Line
data={packageTrendsData.chartData}
options={packageTrendsChartOptions}
/>
) : (
<div className="flex items-center justify-center h-full text-secondary-500 dark:text-secondary-400">
No data available
</div>
)}
</div>
</div>
);
case "quickStats": {
@@ -1068,6 +1139,174 @@ const Dashboard = () => {
onClick: handlePackagePriorityChartClick,
};
const packageTrendsChartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: "top",
labels: {
color: isDark ? "#ffffff" : "#374151",
font: {
size: 12,
},
padding: 20,
usePointStyle: true,
pointStyle: "circle",
},
},
tooltip: {
mode: "index",
intersect: false,
backgroundColor: isDark ? "#374151" : "#ffffff",
titleColor: isDark ? "#ffffff" : "#374151",
bodyColor: isDark ? "#ffffff" : "#374151",
borderColor: isDark ? "#4B5563" : "#E5E7EB",
borderWidth: 1,
callbacks: {
title: (context) => {
const label = context[0].label;
// Handle empty or invalid labels
if (!label || typeof label !== "string") {
return "Unknown Date";
}
// Format hourly labels (e.g., "2025-10-07T14" -> "Oct 7, 2:00 PM")
if (label.includes("T")) {
try {
const date = new Date(`${label}:00:00`);
// Check if date is valid
if (Number.isNaN(date.getTime())) {
return label; // Return original label if date is invalid
}
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
});
} catch (_error) {
return label; // Return original label if parsing fails
}
}
// Format daily labels (e.g., "2025-10-07" -> "Oct 7")
try {
const date = new Date(label);
// Check if date is valid
if (Number.isNaN(date.getTime())) {
return label; // Return original label if date is invalid
}
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
} catch (_error) {
return label; // Return original label if parsing fails
}
},
label: (context) => {
const value = context.parsed.y;
if (value === null || value === undefined) {
return `${context.dataset.label}: No data`;
}
return `${context.dataset.label}: ${value}`;
},
},
},
},
scales: {
x: {
display: true,
title: {
display: true,
text: packageTrendsPeriod === "1" ? "Time (Hours)" : "Date",
color: isDark ? "#ffffff" : "#374151",
},
ticks: {
color: isDark ? "#ffffff" : "#374151",
font: {
size: 11,
},
callback: function (value, _index, _ticks) {
const label = this.getLabelForValue(value);
// Handle empty or invalid labels
if (!label || typeof label !== "string") {
return "Unknown";
}
// Format hourly labels (e.g., "2025-10-07T14" -> "2 PM")
if (label.includes("T")) {
try {
const hour = label.split("T")[1];
const hourNum = parseInt(hour, 10);
// Validate hour number
if (Number.isNaN(hourNum) || hourNum < 0 || hourNum > 23) {
return hour; // Return original hour if invalid
}
return hourNum === 0
? "12 AM"
: hourNum < 12
? `${hourNum} AM`
: hourNum === 12
? "12 PM"
: `${hourNum - 12} PM`;
} catch (_error) {
return label; // Return original label if parsing fails
}
}
// Format daily labels (e.g., "2025-10-07" -> "Oct 7")
try {
const date = new Date(label);
// Check if date is valid
if (Number.isNaN(date.getTime())) {
return label; // Return original label if date is invalid
}
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
} catch (_error) {
return label; // Return original label if parsing fails
}
},
},
grid: {
color: isDark ? "#374151" : "#E5E7EB",
},
},
y: {
display: true,
title: {
display: true,
text: "Number of Packages",
color: isDark ? "#ffffff" : "#374151",
},
ticks: {
color: isDark ? "#ffffff" : "#374151",
font: {
size: 11,
},
beginAtZero: true,
},
grid: {
color: isDark ? "#374151" : "#E5E7EB",
},
},
},
interaction: {
mode: "nearest",
axis: "x",
intersect: false,
},
};
const barChartOptions = {
responsive: true,
indexAxis: "y", // Make the chart horizontal
@@ -1100,6 +1339,7 @@ const Dashboard = () => {
},
},
},
onClick: handleOSChartClick,
};
const osChartData = {
@@ -1194,7 +1434,6 @@ const Dashboard = () => {
title="Customize dashboard layout"
>
<Settings className="h-4 w-4" />
Customize Dashboard
</button>
<button
type="button"
@@ -1206,7 +1445,6 @@ const Dashboard = () => {
<RefreshCw
className={`h-4 w-4 ${isFetching ? "animate-spin" : ""}`}
/>
{isFetching ? "Refreshing..." : "Refresh"}
</button>
</div>
</div>
@@ -1245,7 +1483,12 @@ const Dashboard = () => {
className={getGroupClassName(group.type)}
>
{group.cards.map((card, cardIndex) => (
<div key={`card-${card.cardId}-${groupIndex}-${cardIndex}`}>
<div
key={`card-${card.cardId}-${groupIndex}-${cardIndex}`}
className={
card.cardId === "packageTrends" ? "lg:col-span-2" : ""
}
>
{renderCard(card.cardId)}
</div>
))}

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More