Compare commits

...

271 Commits

Author SHA1 Message Date
Muhammad Ibrahim
2c44f9bf2c fix: Remove -u flag from set command to prevent unbound variable errors 2025-10-03 22:25:25 +01:00
Muhammad Ibrahim
373ef8f468 feat: Add interactive dpkg error recovery with automatic retry 2025-10-02 15:19:49 +01:00
Muhammad Ibrahim
513c268b36 fix: Reset install_exit_code per container and detect success via output message 2025-10-02 15:14:50 +01:00
Muhammad Ibrahim
13c4342135 feat: Remove 'proxmox-' prefix from friendly names, use hostname only 2025-10-02 14:39:36 +01:00
Muhammad Ibrahim
bbb97dbfda fix: Remove EXIT from error trap to prevent false failures on successful completion 2025-10-02 14:37:38 +01:00
Muhammad Ibrahim
e0eb544205 fix: Make all counter increments safe with || true to prevent set -e exit 2025-10-02 13:42:09 +01:00
Muhammad Ibrahim
51982010db fix: Pass API credentials directly to curl instead of via env vars 2025-10-02 13:41:12 +01:00
Muhammad Ibrahim
dc68afcb87 fix: Prevent set -e exit on agent install failure and show output 2025-10-02 13:39:28 +01:00
Muhammad Ibrahim
bec09b9457 fix: Download agent installer to file before executing to prevent stdin pipe hang 2025-10-02 13:37:55 +01:00
Muhammad Ibrahim
55c8f74b73 chore: Bump debug version to 1.0.0-debug.6 2025-10-02 13:16:09 +01:00
Muhammad Ibrahim
16ea1dc743 fix: Make logging functions always return 0 to prevent set -e exit 2025-10-02 13:15:57 +01:00
Muhammad Ibrahim
8c326c8fe2 debug: Add version echo at script start for verification 2025-10-02 13:14:38 +01:00
Muhammad Ibrahim
2abc9b1f8a debug: Add strict error handling and exit trap to diagnose silent exit 2025-10-02 13:12:29 +01:00
Muhammad Ibrahim
e5f3b0ed26 debug: Add detailed logging to diagnose where script hangs 2025-10-02 13:10:51 +01:00
Muhammad Ibrahim
bfc5db11da fix: Close stdin before while loop to prevent hang when piped from curl 2025-10-02 13:09:13 +01:00
Muhammad Ibrahim
a0bea9b6e5 fix: Remove global exec stdin redirect that breaks curl pipe 2025-10-02 13:03:50 +01:00
Muhammad Ibrahim
ebda7331a9 fix: Detach stdin globally to prevent curl pipe hangs in Proxmox script 2025-10-02 12:55:52 +01:00
Muhammad Ibrahim
9963cfa417 fix: Add timeouts and stdin redirection to prevent pct exec hanging 2025-10-02 08:28:07 +01:00
Muhammad Ibrahim
4e6a9829cf chore: Add migration file for auto_enrollment_tokens table 2025-10-02 08:13:04 +01:00
Muhammad Ibrahim
b99f4aad4e feat: Add Proxmox LXC auto-enrollment integration
- Add auto_enrollment_tokens table with rate limiting and IP whitelisting
- Create backend API routes for token management and enrollment
- Build frontend UI for token creation and management
- Add one-liner curl command for easy Proxmox deployment
- Include Proxmox LXC discovery and enrollment script
- Support future integrations with /proxmox-lxc endpoint pattern
- Add comprehensive documentation

Security features:
- Hashed token secrets
- Per-day rate limits
- IP whitelist support
- Token expiration
- Separate enrollment vs host API credentials
2025-10-02 07:50:10 +01:00
Muhammad Ibrahim
7a8e9d95a0 Added a global search bar 2025-10-02 07:03:10 +01:00
9 Technology Group LTD
65025b50cf Merge pull request #113 from PatchMon/dev-agent
Removed crontab insertion by installer
2025-10-01 21:12:24 +01:00
9 Technology Group LTD
de76836ba0 Create app_build.yml 2025-10-01 21:09:10 +01:00
Muhammad Ibrahim
fe448d0111 Removed crontab insertion as the initial update by the agent configures crontab anyway 2025-10-01 20:43:37 +01:00
9 Technology Group LTD
1b08be8864 Merge pull request #112 from 9technologygroup/dev-agent
Fixed selinux detection issue
2025-10-01 13:30:35 +01:00
Muhammad Ibrahim
28124f5fba Fixed selinux detection issue 2025-10-01 13:13:39 +01:00
9 Technology Group LTD
f789c1cebe Merge pull request #111 from 9technologygroup/dev-agent
Fixed dnf and issues for almalinux / rhel dervied systems, added bc as a pre-requisite
2025-10-01 12:57:17 +01:00
Muhammad Ibrahim
d13469ce33 Fixed dnf and issues for almalinux / rhel dervied systems, added bc as a prerequisite 2025-10-01 12:50:55 +01:00
9 Technology Group LTD
443ec145e1 Merge pull request #110 from 9technologygroup/dev-agent
Made installation script output the install for jq and curl if theres issues with it
2025-10-01 10:58:48 +01:00
Muhammad Ibrahim
42f882c1c6 Made installation script output the install for jq and curl if theres any issues with repos rathet than just exiting 2025-10-01 10:52:08 +01:00
9 Technology Group LTD
69acd1726c Merge pull request #109 from 9technologygroup/dev-agent
Made changes to the host details area to add notes
2025-10-01 09:35:57 +01:00
Muhammad Ibrahim
02f9899b23 Added pages for coming soon features 2025-10-01 09:33:34 +01:00
Muhammad Ibrahim
0742c4b05c fixed TFA setting up and login redirect issue 2025-10-01 09:08:27 +01:00
Muhammad Ibrahim
5d8a1e71d6 Made changes to the host details area to add notes
Reconfigured JWT session timeouts
2025-10-01 08:38:40 +01:00
9 Technology Group LTD
84c26054b2 Merge pull request #108 from 9technologygroup/dev-agent
Agent and Settings pages updates
2025-09-30 23:04:40 +01:00
Muhammad Ibrahim
f254b54404 Fix notifications route pointing to wrong component
- Create proper Notifications component with comprehensive layout
- Update routing to use SettingsLayout and Notifications component
- Remove incorrect dependency on SettingsHostGroups for notifications
- Add placeholder content for notification rules and settings
2025-09-30 22:52:25 +01:00
Muhammad Ibrahim
7682d2fffd Remove unused Settings import from App.jsx 2025-09-30 22:51:20 +01:00
Muhammad Ibrahim
d6db557d87 Replace old settings page with new SettingsLayout for alert-channels route
- Create new AlertChannels component with proper layout
- Update routing to use SettingsLayout instead of old Settings component
- Add placeholder content for alert channels functionality
- Remove dependency on old settings server page
2025-09-30 22:51:15 +01:00
Muhammad Ibrahim
af62a466c8 Remove unused settings query from main HostDetail component 2025-09-30 22:49:45 +01:00
Muhammad Ibrahim
434fa86941 Fix unused getCurlFlags function by moving it to CredentialsModal component scope 2025-09-30 22:49:40 +01:00
Muhammad Ibrahim
a61a8681e0 Remove test files and update .gitignore to prevent future test file commits 2025-09-30 22:48:14 +01:00
Muhammad Ibrahim
8eb75fba7d Fixed agent outputs and improved crontab chancing logics, added timedatectl functionality 2025-09-30 22:46:00 +01:00
Muhammad Ibrahim
b3d7e49961 Fixed <1GB Ram issue with it throwing integer error 2025-09-30 21:53:30 +01:00
Muhammad Ibrahim
8be25283dc Relaid out settings page and configured agent and other communication to use curl flags which can be configured to ignore ssl cert if self-hosted. 2025-09-30 21:38:13 +01:00
Muhammad Ibrahim
ed0cf79b53 Added settings pages to bring all the settings together from patchmon options, profile page and server settings. 2025-09-30 19:48:28 +01:00
Muhammad Ibrahim
8ca22dc7ab Fixed crontab syntax issue with timing 2025-09-30 09:31:12 +01:00
Muhammad Ibrahim
3466c0c7fb made the setup script download from the right area 2025-09-30 08:53:38 +01:00
Muhammad Ibrahim
21d6a3b763 fixed lefthook error on setup.sh 2025-09-30 08:06:03 +01:00
Muhammad Ibrahim
33b2b4b0fe fixed syntax issue in setup.sh 2025-09-30 08:02:30 +01:00
Muhammad Ibrahim
61ca05526b fixed setup installer file 2025-09-30 07:54:50 +01:00
9 Technology Group LTD
a765b58868 Merge pull request #91 from 9technologygroup/chore/docker_dev
Docker improvements
2025-09-30 00:07:19 +01:00
tigattack
654943a00c refactor(docker): use relative paths 2025-09-30 00:02:33 +01:00
tigattack
b54900aaed docs(docker): add update info 2025-09-30 00:02:33 +01:00
tigattack
cdaba97232 chore: update Docker setup for development 2025-09-30 00:02:33 +01:00
9 Technology Group LTD
45ec71c387 Merge pull request #90 from 9technologygroup/fix/locks_and_docker
fix: Revert Dockerfile edits and lockfile changes
2025-09-29 23:58:08 +01:00
tigattack
823ae7f30a feat: add workflow_dispatch input for Docker image push 2025-09-29 22:36:27 +01:00
tigattack
8553f717e2 fix: regenerate lockfile 2025-09-29 22:00:42 +01:00
tigattack
841b6e41ff fix: remove frontend pkg lock 2025-09-29 21:53:23 +01:00
tigattack
d626493100 fix: Revert Dockerfile edits and lockfile changes
This reverts commits 8409b71857 and 78eb2b183e
2025-09-29 21:53:13 +01:00
Muhammad Ibrahim
12a82a8522 Merge branch 'dev' of github.com:9technologygroup/patchmon.net into dev 2025-09-29 21:14:56 +01:00
Muhammad Ibrahim
44f90edcd2 Suppress false positive linting warnings for useId variables 2025-09-29 21:08:42 +01:00
Muhammad Ibrahim
1cc5254331 Fix missing useId variables in Settings component
- Restored all missing useId() variables that were accidentally removed
- Fixed ReferenceError: protocolId is not defined
- Settings page should now work correctly
2025-09-29 21:08:32 +01:00
9 Technology Group LTD
5bf6283a1c Merge pull request #88 from 9technologygroup/v1-2-7-agent
Agent modification
2025-09-29 20:55:55 +01:00
Muhammad Ibrahim
e9843f80f8 Fix linting warnings for unused variables 2025-09-29 20:42:29 +01:00
Muhammad Ibrahim
b49ea6b197 Update PatchMon version to 1.2.7
- Updated agent script version to 1.2.7
- Updated all package.json files to version 1.2.7
- Updated backend version references
- Updated setup script version references
- Fixed agent file path issues in API endpoints
- Fixed linting issues (Node.js imports, unused variables, accessibility)
- Created comprehensive version update guide in patchmon-admin/READMEs/
2025-09-29 20:42:14 +01:00
Muhammad Ibrahim
49c02a54dc Changed Discord Link 2025-09-29 17:57:13 +01:00
Muhammad Ibrahim
c7b177d5cb removed 44 file 2025-09-29 17:31:34 +01:00
Muhammad Ibrahim
8409b71857 Changed node_modules app dependancy folder 2025-09-29 17:26:15 +01:00
Muhammad Ibrahim
78eb2b183e Docker package-lock.json generation for each backend and frontend folder. 2025-09-29 17:20:13 +01:00
Muhammad Ibrahim
b49d225e32 Modified curl so that it ignores SSL errors, more for those who self host.
Modified installer to not replace exisitng crontab entries
2025-09-29 17:08:44 +01:00
Muhammad Ibrahim
470948165c Fix renovate.json formatting 2025-09-29 15:41:57 +01:00
Muhammad Ibrahim
20df1eceb1 Resolve merge conflict - use v1-2-7 version 2025-09-29 15:38:07 +01:00
Muhammad Ibrahim
372bb42fc5 Merge branch 'v1-2-7' into dev 2025-09-29 15:34:58 +01:00
Muhammad Ibrahim
4a6b486ba1 package-lock.json rebuild 2025-09-29 15:28:27 +01:00
Muhammad Ibrahim
1f5b33eb73 Package-lock.json fixes 2025-09-29 15:22:35 +01:00
Muhammad Ibrahim
aca8b300dd Regenerated package-lock.json 2025-09-29 15:10:21 +01:00
9 Technology Group LTD
c6459a965f Merge branch 'main' into dev 2025-09-29 14:56:48 +01:00
Muhammad Ibrahim
3b72794307 made the crontab installation safe as opposed to nuking it - spotted by thespad 2025-09-29 12:06:13 +01:00
9 Technology Group LTD
b5b110fed2 Update README.md
Released Website
2025-09-29 09:24:15 +01:00
tigattack
40bf8747b1 fix(frontend.dockerfile): remove unused line 2025-09-28 18:24:02 +01:00
tigattack
178f871582 chore: remove unnecessary use of npx 2025-09-28 18:23:29 +01:00
tigattack
840664c39e chore: fixup package-lock, remove unused deps 2025-09-28 18:20:06 +01:00
Muhammad Ibrahim
c18696f772 Fixed syntax in npm commands 2025-09-28 16:09:50 +01:00
Muhammad Ibrahim
6adbbca439 changed Docker flags to RUN npm ci --ignore-scripts && \
npm install --only=optional

So that Vite can install dependancies it needs for multi Arch builds
2025-09-28 16:04:26 +01:00
Muhammad Ibrahim
edfd82a86d Added --ignore-scripts to npm ci commands for lefthook script to not takeplace upon build 2025-09-28 15:58:01 +01:00
Muhammad Ibrahim
bed52a04b2 Merge remote-tracking branch 'origin/v1-2-7' into v1-2-7 2025-09-28 15:46:02 +01:00
9 Technology Group LTD
7369e23061 Merge pull request #65 from 9technologygroup/renovate/vitejs-plugin-react-5.x
chore(deps): update dependency @vitejs/plugin-react to v5
2025-09-28 15:25:28 +01:00
9 Technology Group LTD
271d2c0df1 Merge pull request #64 from 9technologygroup/renovate/lucide-monorepo
fix(deps): update dependency lucide-react to ^0.544.0
2025-09-28 15:25:13 +01:00
renovate[bot]
518b08895e chore(deps): update dependency @vitejs/plugin-react to v5 2025-09-28 14:19:33 +00:00
renovate[bot]
aba8ec5d01 fix(deps): update dependency lucide-react to ^0.544.0 2025-09-28 14:19:20 +00:00
9 Technology Group LTD
630949b7b9 Merge pull request #62 from 9technologygroup/renovate/configure
chore: Configure Renovate
2025-09-28 15:18:14 +01:00
renovate[bot]
82d0ff315f Add renovate.json 2025-09-27 11:34:14 +00:00
9 Technology Group LTD
df04770113 Merge pull request #60 from tigattack/more_fixes
More fixes
2025-09-27 09:34:12 +01:00
9 Technology Group LTD
038d4c515b Update README.md
Fixed discord link to a redirect URL
2025-09-27 09:32:37 +01:00
tigattack
f99e01a120 refactor(frontend): don't store current host tab in localstorage 2025-09-27 02:26:25 +01:00
tigattack
175042690e refactor(frontend): don't store permissions in localstorage 2025-09-27 02:26:21 +01:00
tigattack
102546e45d fix(frontend): eliminate duplicate API calls during log in/out 2025-09-27 01:50:03 +01:00
tigattack
751a202fec fix(frontend): actually fix login after signup 2025-09-27 01:49:53 +01:00
tigattack
c886b812d6 chore: clarify auto-update feature 2025-09-27 00:52:04 +01:00
tigattack
be3fe52aea fix(api): resolve duplicate key constraint errors in host package updates 2025-09-27 00:52:04 +01:00
9 Technology Group LTD
d85920669d Merge pull request #58 from tigattack/post_lint_fixes
Post-lint fixes
2025-09-26 20:28:39 +01:00
tigattack
c4e056711b fix(frontend): use React Router navigation to open add host modal
Fixes page reload on add host button click.
2025-09-26 20:17:09 +01:00
tigattack
60fa598803 fix(frontend): solve missing imports 2025-09-26 20:17:09 +01:00
tigattack
5c66887732 refactor(frontend): optimise auth process
- Stops frontend trying to make calls that require auth before auth has occured
- Stops frontend making calls that aren't necessary before auth has occured
- Implements state machine to better handle auth phases
2025-09-26 20:17:09 +01:00
tigattack
ba087eb23e chore: add types/bcryptjs 2025-09-26 20:17:09 +01:00
tigattack
e3aa28a8d9 fix: login after signup
Also resolves entire user object being return to client, including password_hash... ⚠️
2025-09-26 20:17:09 +01:00
tigattack
71d9884a86 fix(frontend): imports are unused 2025-09-26 20:16:41 +01:00
9 Technology Group LTD
2c47999cb4 Update README.md 2025-09-26 17:54:34 +01:00
9 Technology Group LTD
6bf2a21f48 Update README.md
Updated discord link
2025-09-26 12:27:19 +01:00
Muhammad Ibrahim
a76a722364 Fixed inline toggle changes when viewing the list of hosts in table 2025-09-26 09:09:02 +01:00
9 Technology Group LTD
40a9003e6f Merge pull request #52 from tigattack/style/fmt
Lint & format code, add rules, hooks, and workflow
2025-09-26 09:00:07 +01:00
tigattack
e9bac06526 Merge pull request #2 from 9technologygroup/iby-suggestions-for-pr-52 2025-09-26 08:03:22 +01:00
Muhammad Ibrahim
0c0446ad69 Added a few dashboard enhancements with Doughnut charts 2025-09-26 02:10:55 +01:00
Muhammad Ibrahim
dbebb866b9 Removed formreturn data to be outputted in console log when host is created 2025-09-26 01:09:08 +01:00
Muhammad Ibrahim
eb3f3599f9 Fixed group selection to be in effect when selecting the group upon host creation page 2025-09-26 01:06:56 +01:00
Muhammad Ibrahim
527b0ccc3c Fixed auto-update toggle to refresh upon save 2025-09-26 01:05:49 +01:00
tigattack
1ff3da0a21 fix(frontend): add missing useId imports 2025-09-26 01:02:29 +01:00
Muhammad Ibrahim
641272dfb8 Put in more env variables and fallback to localhost if BACKEND_URL and BACKEND_PORT is not found
Fixed imports for react imports for useMemo, useEffect etc from hosts and HostDetail files
2025-09-26 00:53:44 +01:00
tigattack
3c01c4bfb2 chore: add lefthook commit hook 2025-09-26 00:24:32 +01:00
tigattack
35eb9303b1 style: fmt 2025-09-26 00:24:32 +01:00
tigattack
469107c149 fix: incorrect cardId 2025-09-26 00:24:32 +01:00
tigattack
22f6befc89 fix(backend): lint errors 2025-09-26 00:24:32 +01:00
tigattack
03802daf13 fix(frontend): bad key 2025-09-26 00:24:32 +01:00
tigattack
17509cbf3c fix(frontend): fix missing icons 2025-09-26 00:24:32 +01:00
tigattack
ffbf5f12e5 fix(frontend): add missing import 2025-09-26 00:24:32 +01:00
tigattack
3bdf3d1843 ci: add code quality workflow 2025-09-26 00:24:32 +01:00
tigattack
ea550259ff fix(frontend): unused imports 2025-09-25 23:54:24 +01:00
tigattack
047fdb4bd1 fix(frontend): unused parameters 2025-09-25 23:54:24 +01:00
tigattack
adc142fd85 fix(frontend): Missing radix parameter 2025-09-25 23:54:24 +01:00
tigattack
42f6971da7 fix(frontend): variable unused 2025-09-25 23:54:24 +01:00
tigattack
0414ea39d0 fix(frontend): Use Date.now() instead of new Date() 2025-09-25 23:54:24 +01:00
tigattack
6357839619 fix(frontend): Change to an optional chain 2025-09-25 23:54:24 +01:00
tigattack
c840a3fdcc fix(frontend): isNaN is unsafe 2025-09-25 23:54:24 +01:00
tigattack
a1bf2df59d fix(frontend): unused vars/params 2025-09-25 23:54:24 +01:00
tigattack
67a5462a25 fix(frontend): imports are unused 2025-09-25 23:54:24 +01:00
tigattack
a32007f56b fix(frontend): Static Elements should not be interactive 2025-09-25 23:54:24 +01:00
tigattack
6e1ec0d031 fix(frontend): elements with button role can be changed to button 2025-09-25 23:54:23 +01:00
tigattack
ce2ba0face fix(frontend): hook does not specify its dependency 2025-09-25 23:54:23 +01:00
tigattack
53f8471d75 fix(frontend): Avoid using the index of an array as key property in an element 2025-09-25 23:54:23 +01:00
tigattack
74f42b5bee fix(frontend): A form label must be associated with an input 2025-09-25 23:54:23 +01:00
tigattack
a84da7c731 fix(frontend): Static Elements should not be interactive 2025-09-25 23:54:23 +01:00
tigattack
83ce7c64fd style(frontend): fmt 2025-09-25 23:54:23 +01:00
tigattack
15902da87c fix(frontend): use useId() for input IDs 2025-09-25 23:54:23 +01:00
tigattack
a11f180d23 fix(frontend): add missing explicit types for button elems 2025-09-25 23:54:23 +01:00
tigattack
35bf858977 fix(frontend): use useId() for input IDs 2025-09-25 23:54:23 +01:00
tigattack
330f80478d chore: add biome 2025-09-25 23:54:23 +01:00
tigattack
b43b20fbe9 style(frontend): fmt 2025-09-25 23:14:35 +01:00
tigattack
591389a91f style(backend): fmt 2025-09-25 23:13:49 +01:00
tigattack
6d70a67a49 docs(docker): link to images in GHCR 2025-09-25 23:13:49 +01:00
tigattack
0cca6607d7 chore(docker): tweak db healthcheck
Make the stack faster to start.
2025-09-25 23:13:49 +01:00
tigattack
5f0ce7f26a chore(docker): unpin alpine version 2025-09-25 23:13:49 +01:00
tigattack
38d0dcb3c4 docs(docker): add volumes info 2025-09-25 23:13:49 +01:00
tigattack
03d6ebb43a chore(compose): move agent_files to docker vol 2025-09-25 23:13:49 +01:00
tigattack
b7ce2a3f54 docs(docker): move password update docs 2025-09-25 23:13:49 +01:00
Muhammad Ibrahim
783f8d73fe Replaced older dependancies with newer ones 2025-09-25 15:40:13 +01:00
9 Technology Group LTD
9f72690f82 Merge pull request #48 from 9technologygroup/docker_tweaks 2025-09-25 08:40:35 +01:00
tigattack
48d2a656e5 docs(docker): link to images in GHCR 2025-09-24 21:38:54 +01:00
tigattack
e9402dbf32 chore(docker): tweak db healthcheck
Make the stack faster to start.
2025-09-24 21:36:51 +01:00
tigattack
dc1ad6882c chore(docker): unpin alpine version 2025-09-24 21:35:36 +01:00
9 Technology Group LTD
22f616e110 Merge pull request #47 from 9technologygroup/docs/docker_backend_agents_vol 2025-09-24 21:21:41 +01:00
tigattack
3af269ee47 docs(docker): add volumes info 2025-09-24 21:06:05 +01:00
tigattack
f4ece11636 chore(compose): move agent_files to docker vol 2025-09-24 21:06:05 +01:00
tigattack
da6bd2c098 docs(docker): move password update docs 2025-09-24 21:00:19 +01:00
Muhammad Ibrahim
a479003ba9 fix: return first_name and last_name in setup-admin and signup responses
- Add first_name and last_name to select clause in setup-admin endpoint
- Add first_name and last_name to select clause in signup endpoint
- Ensures frontend receives the name fields after user creation
- Fixes issue where first/last names don't populate in UI after setup

The data was being saved to database correctly but not returned in the API response, causing frontend to not display the names properly.
2025-09-24 20:17:36 +01:00
Muhammad Ibrahim
78f4eff375 fix: correct package versions and npm flags
Frontend package.json fixes:
- react-router-dom: ^6.31.0 → ^6.30.1 (6.31.0 doesn't exist)
- postcss: ^8.5.1 → ^8.5.6 (use latest stable version)

Setup script fixes:
- Replace deprecated --production flag with --omit=dev
- Resolves npm warning: 'npm WARN config production Use --omit=dev instead'

Fixes npm install errors:
- 'No matching version found for react-router-dom@^6.31.0'
- Deprecated npm configuration warnings
2025-09-24 20:07:36 +01:00
Muhammad Ibrahim
c4376d35c9 setup: add intelligent package manager detection for Debian 13+
- Add detect_package_manager() function that prefers 'apt' over 'apt-get'
- Use PKG_MANAGER variables throughout script for consistency
- Support both modern Debian 13+ (apt) and older systems (apt-get)
- Automatically detect and use the best available package manager
- Better error handling when no package manager is found

Fixes compatibility with:
- Debian 13 (Trixie) and newer
- Ubuntu 22.04+ (prefers apt)
- Older Debian/Ubuntu systems (fallback to apt-get)

All package operations now use the detected package manager:
- System updates and upgrades
- Prerequisites installation
- Node.js, PostgreSQL, nginx, certbot installation
2025-09-24 19:55:42 +01:00
Muhammad Ibrahim
9f3016be57 deps: update all dependencies to latest non-deprecated versions
Frontend:
- ESLint: 8.53.0 → 9.17.0 (fixes deprecated warnings)
- React/React-DOM: 18.2.0 → 18.3.1
- Axios: 1.6.2 → 1.7.9
- Chart.js: 4.4.0 → 4.4.7
- Date-fns: 2.30.0 → 4.1.0 (major update)
- Express: 4.18.2 → 4.21.2
- HTTP-proxy-middleware: 2.0.6 → 3.0.3
- Lucide-react: 0.294.0 → 0.468.0
- React-router-dom: 6.20.1 → 6.31.0
- Add ESLint v9 flat config (eslint.config.js)
- Add required @eslint/js and globals dependencies

Backend:
- Prisma: 5.7.0 → 6.1.0
- Express: 4.18.2 → 4.21.2
- Dotenv: 16.3.1 → 16.4.7
- Express-rate-limit: 7.1.5 → 7.5.0
- Express-validator: 7.0.1 → 7.2.0
- Helmet: 7.1.0 → 8.0.0
- UUID: 9.0.1 → 11.0.3
- Winston: 3.11.0 → 3.17.0
- Nodemon: 3.0.2 → 3.1.9

Fixes npm warnings:
- rimraf < v4 deprecated
- inflight memory leaks
- glob < v9 deprecated
- @humanwhocodes packages deprecated
- ESLint v8 no longer supported
2025-09-24 19:50:50 +01:00
Muhammad Ibrahim
c3013cccd3 Added min specs of VM / LXC 2025-09-24 19:45:18 +01:00
Muhammad Ibrahim
456184d327 setup: improve sudo handling and add root checks
- Add root permission check at startup
- Install sudo automatically if not present (for Debian systems)
- Add run_as_user() helper function with better error handling
- Provide fallback PostgreSQL setup using 'su' when sudo unavailable
- Replace all sudo -u commands with the new helper function
- Better error messages for missing users/sudo

This ensures compatibility with minimal Debian systems that don't have sudo by default.
2025-09-24 19:25:49 +01:00
Muhammad Ibrahim
a9c579bdd0 setup: fix IP address handling for database/service names
- Detect when FQDN starts with digit (IP address)
- Generate 2 random letter prefix for database/service names
- Use prefixed names for DB_NAME, DB_USER, INSTANCE_USER
- Keep original FQDN for nginx configs and URLs
- Prevents PostgreSQL errors with numeric database names

Example: 192.168.1.50 -> xy192_168_1_50 for services
2025-09-24 19:22:32 +01:00
Muhammad Ibrahim
13fe8a0bc5 agents: use apt-get simulation for Ubuntu 18 compatibility; fix installer log path 2025-09-24 15:21:22 +01:00
9 Technology Group LTD
1d8742ccad Update README.md 2025-09-24 14:18:41 +01:00
9 Technology Group LTD
bccfc0876f Update README.md 2025-09-24 10:31:27 +01:00
9 Technology Group LTD
43a42aa931 Add files via upload 2025-09-24 10:30:11 +01:00
9 Technology Group LTD
ad5627362b Merge pull request #33 from 9technologygroup/docs/readme
docs: move header to top, add docker info
2025-09-24 10:15:56 +01:00
tigattack
61db61e1ba docs: move getting started above architecture/comms model 2025-09-24 10:07:43 +01:00
tigattack
8dc702e7fc docs: move header to top, add docker info 2025-09-24 10:04:14 +01:00
9 Technology Group LTD
45f5ccc638 Version 1.2.6 latest release 2025-09-24 09:45:57 +01:00
Muhammad Ibrahim
3fbe1369cf Ammended installation command 2025-09-24 09:38:30 +01:00
Muhammad Ibrahim
e62a4fed56 Merge branch 'dev' of github.com:9technologygroup/patchmon.net into dev 2025-09-24 09:20:29 +01:00
Muhammad Ibrahim
be549d4b34 Added README.md file -- finally !
Added self-hosting easy installer script -- finally !
2025-09-24 09:08:20 +01:00
Muhammad Ibrahim
99aa79a6a4 Fixed authentication redirect upon signin using states
Finally fixed dashboard defaults for new user signups
2025-09-24 07:42:15 +01:00
Muhammad Ibrahim
73761d8927 fixed dashboard defaults via server.js config 2025-09-24 07:07:38 +01:00
Muhammad Ibrahim
9889083900 reverted the removal of schema migration files to make it production save in order to not mess up previous instances 2025-09-24 03:05:22 +01:00
Muhammad Ibrahim
acb30f22bd Removed migration settings from earlier whic hwere messing with the logic of new dashboard layout system. Now has a single source of truth based on what the defaults should be in server.js 2025-09-24 02:54:44 +01:00
Muhammad Ibrahim
3a0b564a6f Fixed permissions issues
Created default user role
modified server.js to check if roles of admin/user is present
modified server.js to check dashboard cards
set up default dashboard cards to show
2025-09-24 02:33:26 +01:00
9 Technology Group LTD
e536a5b706 Merge pull request #29 from 9technologygroup/ci/build
Docker: Build workflow tweaks
2025-09-24 02:07:22 +01:00
tigattack
0a3e4ad5ee ci(build): add PAT note 2025-09-24 00:44:24 +01:00
tigattack
abcf88b8b9 ci(build): use PAT 2025-09-24 00:02:51 +01:00
tigattack
94ec14f08b ci(build): move permissions to higher scope 2025-09-23 17:55:46 +01:00
tigattack
f25834b4ba ci(build): simplify matrix 2025-09-23 17:41:37 +01:00
tigattack
f85464ad26 ci(build): bump actions 2025-09-23 17:41:16 +01:00
9 Technology Group LTD
db0ba201a4 Merge pull request #24 from tigattack/fix/docker_agents 2025-09-23 10:47:27 +01:00
tigattack
676082a967 chore: add docker/agents bind path to gitignore 2025-09-23 08:07:27 +01:00
tigattack
30bb29c9f4 fix(docker): copy agent files in docker bind mount if empty 2025-09-23 08:04:13 +01:00
tigattack
968d9f964b docs(docker): add more build instructions 2025-09-23 08:03:10 +01:00
9 Technology Group LTD
3e413e71e4 Merge pull request #21 from tigattack/ci/build_push
Add Docker build pipeline, rework Docker images, misc other tweaks
2025-09-23 04:18:02 +01:00
tigattack
e25baf0f55 ci(docker): build on PRs to dev 2025-09-23 00:04:49 +01:00
tigattack
2869d4e850 ci: add docker build workflow 2025-09-22 23:42:00 +01:00
tigattack
e459d8b378 docs(docker): add docker README 2025-09-22 23:42:00 +01:00
tigattack
31583716c8 refactor(docker): tweak compose setup and envs 2025-09-22 23:42:00 +01:00
tigattack
e645124356 refactor(docker): rework compose, add dev compose 2025-09-22 23:42:00 +01:00
tigattack
c3aa5534f3 fix: improve proxy config 2025-09-22 23:42:00 +01:00
tigattack
bf2ea908f4 refactor(docker): rework docker
- Move Docker files to own directory (tidier since I added several more files)
- Optimise images and reduce size
  - Uses multi-stage builds
  - Optimises layer efficiency
  - Uses NGINX as base for frontend
- Sets default env vars
- Uses tini for proper signal handling
2025-09-22 23:42:00 +01:00
tigattack
43ce146987 fix(frontend): fix login page icons 2025-09-22 23:42:00 +01:00
tigattack
69a121cdde fix(frontend): don't poll for updates when user not authorised 2025-09-22 23:42:00 +01:00
tigattack
001b234ecc fix(backend): set imported agent versions as current
Fixes agent installation script failing with "No agent version available" error

- Auto-imported agent versions now marked as current/default appropriately
- First imported version becomes both current and default
- Newer versions become current (but not default)
- Added concurrent database updates with Promise.all()
- Fixed agent download endpoint fallback to filesystem when no DB versions exist
2025-09-22 23:41:53 +01:00
tigattack
d300922312 refactor(backend): make /agent/download route more resilient
* Fixes early 404 return
* Fixes filename when agent version undefined
* Correctly returns 500 when error occurs
2025-09-22 22:23:54 +01:00
tigattack
20ff5b5b72 refactor(backend): update DB wait env var names 2025-09-22 22:23:54 +01:00
tigattack
5e6a2d863c fix(backend): update settings log msgs 2025-09-22 22:23:54 +01:00
tigattack
ab46b0138b feat(backend): strip default ports from serverUrl 2025-09-22 22:23:54 +01:00
tigattack
5ca0f086d4 fix(frontend): don't force HTTPS port 2025-09-22 22:23:54 +01:00
tigattack
9cb5cd380b feat(backend): add settings service with env var handling
New features:
* Settings initialised on startup rather than first request
* Settings are cached in memory for performance
* Reduction of code duplication/defensive coding practices
2025-09-22 22:23:54 +01:00
tigattack
517b5cd7cb feat(backend): make requests to health endpoint debug log level
avoids spam in docker
2025-09-22 22:23:54 +01:00
tigattack
5dafe34322 feat(backend): add option to log to console
Enabled by `PM_LOG_TO_CONSOLE=true`
2025-09-22 22:23:54 +01:00
tigattack
677d3b4df1 feat(backend): wait for DB on start 2025-09-22 22:23:54 +01:00
tigattack
c3365fedb2 fix: conflate frontend_url and server_url 2025-09-22 22:23:54 +01:00
Muhammad Ibrahim
f23f075e41 Added more dashboard cards
Fixed permissions roles creation bug
On initial deployment, made it so the agent being populated will be set as default and current
Fixed host detail to include package numbers
Added ability to add full name
- fixed loads of other bugs caused by camelcase to snake_Case migration
2025-09-22 21:31:14 +01:00
Muhammad Ibrahim
9b76d9f81a don't think the setting of agent update is neede din the env.example as its all done via the server itself 2025-09-22 02:43:58 +01:00
Muhammad Ibrahim
64d9c14002 Sorted the issue out with installation file not being found upon deployment. Ensured jq dependancy is installing in version 1.2.5 and above of the installation commands 2025-09-22 02:39:22 +01:00
Muhammad Ibrahim
9a01d27d8b Removed populate-agent-version.js as now agent is being imported if newer than existing upon service start 2025-09-22 02:30:12 +01:00
Muhammad Ibrahim
d72f96b598 Improved Agent version import upon service start 2025-09-22 02:22:25 +01:00
Muhammad Ibrahim
8f8b23ccf1 Fixed agent version display issue 2025-09-22 01:46:35 +01:00
Muhammad Ibrahim
1392976a7b Fixed agent version display issue 2025-09-22 01:45:27 +01:00
Muhammad Ibrahim
797be20c45 Created toggle for enable / disable user signup flow with user role
Fixed numbers mismatching in host cards
Fixed issues with the settings file
Fixed layouts on hosts/packages/repos
Added ability to delete multiple hosts at once
Fixed Dark mode styling in areas
Removed console debugging messages
Done some other stuff ...
2025-09-22 01:06:18 +01:00
Muhammad Ibrahim
a268f6b8f1 Fixed isAuthenticated function 2025-09-21 22:55:38 +01:00
Muhammad Ibrahim
a4770e5106 Addeed detailed logging to track first time admin setup 2025-09-21 22:52:23 +01:00
Muhammad Ibrahim
523756cef2 Fix AuthContext useEffect dependencies and add comprehensive debug logging for first-time setup flow 2025-09-21 22:48:29 +01:00
Muhammad Ibrahim
697da088d4 Fixed admin count endpoint 2025-09-21 22:42:47 +01:00
Muhammad Ibrahim
739ca6486a Fix React error #301 2025-09-21 22:37:53 +01:00
Muhammad Ibrahim
38d299701d Added security restrictions to admin count endpoint and force admin setup for testing 2025-09-21 22:31:07 +01:00
Muhammad Ibrahim
5d35abe496 Implemented first-time admin registration flow if no admin present 2025-09-21 22:09:37 +01:00
Muhammad Ibrahim
7ff051be3e Implemented first-time admin registration flow if no admin present 2025-09-21 22:08:07 +01:00
Muhammad Ibrahim
2de80f0c06 Updated frontend to snake_case and fixed bugs with some pages that were not showing. Fixed authentication side. 2025-09-21 20:27:47 +01:00
9 Technology Group LTD
875ab31317 Merge pull request #2 from AdamT20054/dev
Adam Made this with love (and a bit of hate) :D
Whilst he worked until stupid o'clock to get it completed.
2025-09-21 18:49:16 +01:00
AdamT20054
a96439596d Script actually downloads on deb systems (i only use deb, sm1 else will have to test the other distros) 2025-09-21 07:54:43 +01:00
AdamT20054
d2bf201f1e Fix script path in hostRoutes to correct installation script location. **Might break non docker installs?** 2025-09-21 07:35:10 +01:00
AdamT20054
b2d3181ffe This is MY docker config, just using it to test 2025-09-21 07:31:51 +01:00
AdamT20054
5a0229cef4 Should fix host configs generating with default params.
Another issue, caused by the wack prisma migration.
2025-09-21 07:28:12 +01:00
AdamT20054
f73c10f309 fml 2025-09-21 07:18:04 +01:00
AdamT20054
8722bd170f Fix updating the server URL in the dashboard not updatinng. 2025-09-21 07:11:58 +01:00
AdamT20054
fd76a9efd2 Refactor authentication and routing code to use consistent naming conventions for database fields.
Do NOT update the schema like that again for the love of god.
2025-09-21 06:59:39 +01:00
AdamT20054
584e5ed52b Refactor database model references to use consistent naming conventions and update related queries 2025-09-21 06:13:05 +01:00
AdamT20054
c5ff4b346a Refactor agent version handling to use consistent naming and add download URL 2025-09-21 05:19:09 +01:00
AdamT20054
cc9f0af1ac Fixed my cp fuckup. 2025-09-21 04:30:54 +01:00
Adam O'neill
d7460068d7 Merge branch 'dev' into dev 2025-09-21 04:18:07 +01:00
AdamT20054
9135fa93b3 Update Dockerfile to copy backend files and include agent version population script 2025-09-21 04:14:36 +01:00
AdamT20054
662a8d665a Add script to populate agent version from Docker container initialization 2025-09-21 04:13:40 +01:00
Muhammad Ibrahim
f3351d577d fixed rate limits into env 2025-09-21 03:58:22 +01:00
AdamT20054
e1b8e4458a Add signup functionality to Login component with email support and proxy middleware for API requests 2025-09-21 03:32:41 +01:00
AdamT20054
976ca79f57 Add public signup endpoint for user registration with validation 2025-09-21 03:28:39 +01:00
AdamT20054
01a8bd6c77 Add signup API endpoint for user registration 2025-09-21 03:23:55 +01:00
AdamT20054
d210d6adde Update Dockerfile to install OpenSSL and simplify startup command 2025-09-21 03:23:33 +01:00
AdamT20054
229ba4f7be Add Docker configuration for PostgreSQL, backend, and frontend services 2025-09-21 01:43:34 +01:00
Muhammad Ibrahim
9a3827dced Fix hardcoded 1.2.4 values - make frontend load current version from API on mount and update User-Agent dynamically 2025-09-20 14:49:05 +01:00
Muhammad Ibrahim
d687ec4e45 Make version detection dynamic - read from package.json instead of hardcoded values 2025-09-20 14:44:42 +01:00
Muhammad Ibrahim
bbd7769b8c Fix hardcoded version numbers in backend - update to 1.2.5 2025-09-20 13:48:25 +01:00
Muhammad Ibrahim
8245c6b90d Remove manage-patchmon.sh from git tracking
- manage-patchmon.sh is in .gitignore and should not be tracked
- Both management scripts should remain local only
- Scripts are generated during deployment process
2025-09-20 13:28:07 +01:00
Muhammad Ibrahim
1afb9c1ed3 Remove manage-patchmon-dev.sh from git tracking
- manage-patchmon-dev.sh should remain local only
- Only manage-patchmon.sh should be tracked in git
- Dev script is for local development use
2025-09-20 13:27:28 +01:00
Muhammad Ibrahim
417942f674 Add automatic branch switching for updates
- Added branch detection and switching logic to both scripts
- manage-patchmon-dev.sh: automatically switches instances to dev branch
- manage-patchmon.sh: automatically switches instances to main branch
- Handles local changes by stashing before branch switch
- Creates local branch from origin if it doesn't exist locally
- Fixes issue where main branch instances couldn't be updated with dev script
2025-09-20 13:26:51 +01:00
Muhammad Ibrahim
75a4b4a912 Add jq installation to essential tools
- Added jq to essential tools installation in both manage-patchmon.sh and manage-patchmon-dev.sh
- jq is useful for JSON processing and API interactions
- Updated installation check to include jq command availability
- Enhanced status message to show all installed tools
2025-09-20 13:20:17 +01:00
Muhammad Ibrahim
4576781900 Improve agent version logic in manage-patchmon.sh
- Added priority-based version detection (agent script > codebase > package.json > database)
- Always update agent script content during updates, even if version exists
- Improved logging and fallback to 1.2.5
- Consistent behavior with manage-patchmon-dev.sh
2025-09-20 13:07:02 +01:00
Muhammad Ibrahim
0d10d7ee9b Bump version to 1.2.5
- Updated root package.json to 1.2.5
- Updated backend package.json to 1.2.5
- Updated frontend package.json to 1.2.5
- Agent script already has AGENT_VERSION=1.2.5
2025-09-20 13:02:24 +01:00
Muhammad Ibrahim
1cdd6eba6d Added better view on host details and improved filtering 2025-09-20 12:40:31 +01:00
Muhammad Ibrahim
adb207fef9 improved table views and added more host information 2025-09-20 10:56:59 +01:00
Muhammad Ibrahim
216c9dbefa improved duplicate repo handling 2025-09-19 16:07:29 +01:00
Muhammad Ibrahim
52d6d46ea3 Merge branch 'main' of github.com:9technologygroup/patchmon.net 2025-09-19 16:01:45 +01:00
Muhammad Ibrahim
6bc4316fbc fixed duplicated repo url issue 2025-09-19 15:59:45 +01:00
9 Technology Group LTD
b1470f57a8 Create README.md 2025-09-19 11:36:45 +01:00
Muhammad Ibrahim
51d6dd63b1 added server.js for frontend when not using nginx in deployment 2025-09-18 22:59:50 +01:00
Muhammad Ibrahim
2d7a3c3103 Added mfa and css enhancements 2025-09-18 20:14:54 +01:00
119 changed files with 39388 additions and 19980 deletions

17
.github/workflows/app_build.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Build on Merge
on:
push:
branches:
- main
- dev
jobs:
deploy:
runs-on: self-hosted
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Run rebuild script
run: /root/patchmon/platform/scripts/app_build.sh ${{ github.ref_name }}

24
.github/workflows/code_quality.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
name: Code quality
on:
push:
pull_request:
jobs:
check:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v5
with:
persist-credentials: false
- name: Setup Biome
uses: biomejs/setup-biome@v2
with:
version: latest
- name: Run Biome
run: biome ci .

72
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,72 @@
name: Build and Push Docker Images
on:
pull_request:
branches:
- main
- dev
release:
types:
- published
workflow_dispatch:
inputs:
push:
description: Push images to registry
required: false
type: boolean
default: false
env:
REGISTRY: ghcr.io
permissions:
contents: read
packages: write
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
image: [backend, frontend]
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'workflow_dispatch' || github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
# Using PAT as a hack due to issues with GITHUB_TOKEN and package permissions
# This should be reverted to use GITHUB_TOKEN once a solution is discovered.
password: ${{ secrets.GHCR_PAT }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/patchmon-${{ matrix.image }}
tags: |
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push ${{ matrix.image }} image
if: github.event_name != 'workflow_dispatch' || github.event_name == 'workflow_dispatch' && github.event.inputs.push == 'true'
uses: docker/build-push-action@v6
with:
context: .
file: docker/${{ matrix.image }}.Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.image }}
cache-to: type=gha,mode=max,scope=${{ matrix.image }}

5
.gitignore vendored
View File

@@ -130,6 +130,8 @@ agents/*.log
test-results/
playwright-report/
test-results.xml
test_*.sh
test-*.sh
# Package manager lock files (uncomment if you want to ignore them)
# package-lock.json
@@ -140,6 +142,9 @@ test-results.xml
deploy-patchmon.sh
manage-instances.sh
manage-patchmon.sh
manage-patchmon-dev.sh
setup-installer-site.sh
install-server.*
notify-clients-upgrade.sh
debug-agent.sh
docker/compose_dev_data

287
README.md Normal file
View File

@@ -0,0 +1,287 @@
# PatchMon - Linux Patch Monitoring made Simple
[![Website](https://img.shields.io/badge/Website-patchmon.net-blue?style=for-the-badge)](https://patchmon.net)
[![Discord](https://img.shields.io/badge/Discord-Join%20Server-blue?style=for-the-badge&logo=discord)](https://patchmon.net/discord)
[![GitHub](https://img.shields.io/badge/GitHub-Repository-black?style=for-the-badge&logo=github)](https://github.com/9technologygroup/patchmon.net)
[![Roadmap](https://img.shields.io/badge/Roadmap-View%20Progress-green?style=for-the-badge&logo=github)](https://github.com/users/9technologygroup/projects/1)
---
## Please STAR this repo :D
## Purpose
PatchMon provides centralized patch management across diverse server environments. Agents communicate outbound-only to the PatchMon server, eliminating inbound ports on monitored hosts while delivering comprehensive visibility and safe automation.
![Dashboard Screenshot](https://raw.githubusercontent.com/9technologygroup/patchmon.net/main/dashboard.jpeg)
## Features
### Dashboard
- Customisable dashboard with peruser card layout and ordering
### Users & Authentication
- Multi-user accounts (admin and standard users)
- Roles, Permissions & RBAC
### Hosts & Inventory
- Host inventory/groups with key attributes and OS details
- Host grouping (create and manage host groups)
### Packages & Updates
- Package inventory across hosts
- Outdated packages overview and counts
- Repositories per host tracking
### Agent & Data Collection
- Agent version management and script content stored in DB
### Settings & Configuration
- Server URL/protocol/host/port
- Signup toggle and default user role selection
### 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))
### Security
- Rate limiting for general, auth, and agent endpoints
- Outboundonly agent model reduces attack surface
### Deployment & Operations
- Docker installation & Oneline selfhost installer (Ubuntu/Debian)
- systemd service for backend lifecycle
- nginx vhost for frontend + API proxy; optional Lets Encrypt integration
## Getting Started
### PatchMon Cloud (coming soon)
Managed, zero-maintenance PatchMon hosting. Stay tuned.
### Self-hosted Installation
#### Docker (preferred)
For getting started with Docker, see the [Docker documentation](https://github.com/9technologygroup/patchmon.net/blob/main/docker/README.md)
#### Native Install (advanced/non-docker)
Run on a clean Ubuntu/Debian server with internet access:
#### Debian:
```bash
apt update -y
apt upgrade -y
apt install curl -y
```
#### Ubuntu:
```bash
apt-get update -y
apt-get upgrade -y
apt install curl -y
```
#### Script
```bash
curl -fsSL -o setup.sh https://raw.githubusercontent.com/9technologygroup/patchmon.net/refs/heads/main/setup.sh && chmod +x setup.sh && bash setup.sh
```
#### Minimum specs for building : #####
CPU : 2 vCPU
RAM : 2GB
Disk : 15GB
During setup youll be asked:
- Domain/IP: public DNS or local IP (default: `patchmon.internal`)
- SSL/HTTPS: `y` for public deployments with a public IP, `n` for internal networks
- Email: only if SSL is enabled (for Lets Encrypt)
- Git Branch: default is `main` (press Enter)
The script will:
- Install prerequisites (Node.js, PostgreSQL, nginx)
- Clone the repo, install dependencies, build the frontend, run migrations
- Create a systemd service and nginx site vhost config
- Start the service and write a consolidated info file at:
- `/opt/<your-domain>/deployment-info.txt`
- Copies the full installer log to `/opt/<your-domain>/patchmon-install.log` from /var/log/patchmon-install.log
After installation:
- Visit `http(s)://<your-domain>` and complete first-time admin setup
- See all useful info in `deployment-info.txt`
## Communication Model
- Outbound-only agents: servers initiate communication to PatchMon
- No inbound connections required on monitored servers
- Secure server-side API with JWT authentication and rate limiting
## Architecture
- Backend: Node.js/Express + Prisma + PostgreSQL
- Frontend: Vite + React
- Reverse proxy: nginx
- Database: PostgreSQL
- System service: systemd-managed backend
```
+----------------------+ HTTPS +--------------------+ HTTP +------------------------+ TCP +---------------+
| End Users (Browser) | ---------> | nginx | --------> | Backend (Node/Express) | ------> | PostgreSQL |
| Admin UI / Frontend | | serve FE, proxy API| | /api, auth, Prisma | | Database |
+----------------------+ +--------------------+ +------------------------+ +---------------+
Agents (Outbound Only)
+---------------------------+ HTTPS +------------------------+
| Agents on your servers | ----------> | Backend API (/api/v1) |
+---------------------------+ +------------------------+
Operational
- systemd manages backend service
- certbot/nginx for TLS (public)
- setup.sh bootstraps OS, app, DB, config
```
## Support
- Discord: [https://patchmon.net/discord](https://patchmon.net/discord)
- Email: support@patchmon.net
## Roadmap
- Roadmap board: https://github.com/users/9technologygroup/projects/1
## License
- AGPLv3 (More information on this soon)
---
## 🤝 Contributing
We welcome contributions from the community! Here's how you can get involved:
### Development Setup
1. **Fork the Repository**
```bash
# Click the "Fork" button on GitHub, then clone your fork
git clone https://github.com/YOUR_USERNAME/patchmon.net.git
cd patchmon.net
```
2. **Create a Feature Branch**
```bash
git checkout -b feature/your-feature-name
# or
git checkout -b fix/your-bug-fix
```
4. **Install Dependencies and Setup Hooks**
```bash
npm install
npm run prepare
```
5. **Make Your Changes**
- Write clean, well-documented code
- Follow existing code style and patterns
- Add tests for new functionality
- Update documentation as needed
6. **Test Your Changes**
```bash
# Run backend tests
cd backend
npm test
# Run frontend tests
cd ../frontend
npm test
```
7. **Commit and Push**
```bash
git add .
git commit -m "Add: descriptive commit message"
git push origin feature/your-feature-name
```
8. **Create a Pull Request**
- Go to your fork on GitHub
- Click "New Pull Request"
- Provide a clear description of your changes
- Link any related issues
### Contribution Guidelines
- **Code Style**: Follow the existing code patterns and Biome configuration
- **Commits**: Use conventional commit messages (feat:, fix:, docs:, etc.)
- **Testing**: Ensure all tests pass and add tests for new features
- **Documentation**: Update README and code comments as needed
- **Issues**: Check existing issues before creating new ones
---
## 🏢 Enterprise & Custom Solutions
### PatchMon Cloud
- **Fully Managed**: We handle all infrastructure and maintenance
- **Scalable**: Grows with your organization
- **Secure**: Enterprise-grade security and compliance
- **Support**: Dedicated support team
### Custom Integrations
- **API Development**: Custom endpoints for your specific needs
- **Third-Party Integrations**: Connect with your existing tools
- **Custom Dashboards**: Tailored reporting and visualization
- **White-Label Solutions**: Brand PatchMon as your own
### Enterprise Deployment
- **On-Premises**: Deploy in your own data center
- **Air-Gapped**: Support for isolated environments
- **Compliance**: Meet industry-specific requirements
- **Training**: Comprehensive team training and onboarding
*Contact us at support@patchmon.net for enterprise inquiries*
---
---
## 🙏 Acknowledgments
### Special Thanks
- **Jonathan Higson** - For inspiration, ideas, and valuable feedback
- **@Adam20054** - For working on Docker Compose deployment
- **@tigattack** - For working on GitHub CI/CD pipelines
- **Cloud X** and **Crazy Dead** - For moderating our Discord server and keeping the community awesome
- **Beta Testers** - For keeping me awake at night
- **My family** - For understanding my passion
### Contributors
Thank you to all our contributors who help make PatchMon better every day!
## 🔗 Links
- **Website**: [patchmon.net](https://patchmon.net)
- **Discord**: [https://patchmon.net/discord](https://patchmon.net/discord)
- **Roadmap**: [GitHub Projects](https://github.com/users/9technologygroup/projects/1)
- **Documentation**: [Coming Soon]
- **Support**: support@patchmon.net
---
<div align="center">
**Made with ❤️ by the PatchMon Team**
[![Discord](https://img.shields.io/badge/Discord-Join%20Server-blue?style=for-the-badge&logo=discord)](https://patchmon.net/discord)
[![GitHub](https://img.shields.io/badge/GitHub-Repository-black?style=for-the-badge&logo=github)](https://github.com/9technologygroup/patchmon.net)
</div>

View File

@@ -1,16 +1,21 @@
#!/bin/bash
# PatchMon Agent Script
# PatchMon Agent Script v1.2.7
# This script sends package update information to the PatchMon server using API credentials
# Configuration
PATCHMON_SERVER="${PATCHMON_SERVER:-http://localhost:3001}"
API_VERSION="v1"
AGENT_VERSION="1.2.4"
AGENT_VERSION="1.2.7"
CONFIG_FILE="/etc/patchmon/agent.conf"
CREDENTIALS_FILE="/etc/patchmon/credentials"
LOG_FILE="/var/log/patchmon-agent.log"
# This placeholder will be dynamically replaced by the server when serving this
# script based on the "ignore SSL self-signed" setting. If set to -k, curl will
# ignore certificate validation. Otherwise, it will be empty for secure default.
CURL_FLAGS=""
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -20,7 +25,10 @@ NC='\033[0m' # No Color
# Logging function
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
# Try to write to log file, but don't fail if we can't
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
@@ -30,21 +38,21 @@ error() {
exit 1
}
# Info logging
# Info logging (cleaner output - only stdout, no duplicate logging)
info() {
echo -e "${BLUE}INFO: $1${NC}"
echo -e "${BLUE} $1${NC}"
log "INFO: $1"
}
# Success logging
# Success logging (cleaner output - only stdout, no duplicate logging)
success() {
echo -e "${GREEN}SUCCESS: $1${NC}"
echo -e "${GREEN} $1${NC}"
log "SUCCESS: $1"
}
# Warning logging
# Warning logging (cleaner output - only stdout, no duplicate logging)
warning() {
echo -e "${YELLOW}WARNING: $1${NC}"
echo -e "${YELLOW}⚠️ $1${NC}"
log "WARNING: $1"
}
@@ -55,6 +63,31 @@ check_root() {
fi
}
# Verify system datetime and timezone
verify_datetime() {
info "Verifying system datetime and timezone..."
# Get current system time
local system_time=$(date)
local timezone="Unknown"
# Try to get timezone with timeout protection
if command -v timedatectl >/dev/null 2>&1; then
timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
fi
# Log datetime info (non-blocking)
log "System datetime check - time: $system_time, timezone: $timezone" 2>/dev/null || true
# Simple check - just log the info, don't block execution
if [[ "$timezone" == "Unknown" ]] || [[ -z "$timezone" ]]; then
warning "System timezone not configured: $timezone"
log "WARNING: System timezone not configured - timezone: $timezone" 2>/dev/null || true
fi
return 0
}
# Create necessary directories
setup_directories() {
mkdir -p /etc/patchmon
@@ -144,7 +177,7 @@ EOF
test_credentials() {
load_credentials
local response=$(curl -s -X POST \
local response=$(curl $CURL_FLAGS -X POST \
-H "Content-Type: application/json" \
-H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \
@@ -550,33 +583,36 @@ get_apt_packages() {
local -n packages_ref=$1
local -n first_ref=$2
# Update package lists
# Update package lists (use apt-get for older distros; quieter output)
apt-get update -qq
# Get upgradable packages
local upgradable=$(apt list --upgradable 2>/dev/null | grep -v "WARNING")
# Determine upgradable packages using apt-get simulation (compatible with Ubuntu 18.04)
# Example line format:
# Inst bash [4.4.18-2ubuntu1] (4.4.18-2ubuntu1.2 Ubuntu:18.04/bionic-updates [amd64])
local upgradable_sim=$(apt-get -s -o Debug::NoLocking=1 upgrade 2>/dev/null | grep "^Inst ")
while IFS= read -r line; do
if [[ "$line" =~ ^([^/]+)/([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+.*[[:space:]]([^[:space:]]+)[[:space:]]*(\[.*\])? ]]; then
# Extract package name, current version (in brackets), and available version (first token inside parentheses)
if [[ "$line" =~ ^Inst[[:space:]]+([^[:space:]]+)[[:space:]]+\[([^\]]+)\][[:space:]]+\(([^[:space:]]+) ]]; then
local package_name="${BASH_REMATCH[1]}"
local current_version="${BASH_REMATCH[4]}"
local current_version="${BASH_REMATCH[2]}"
local available_version="${BASH_REMATCH[3]}"
local is_security_update=false
# Check if it's a security update
if echo "$line" | grep -q "security"; then
# Mark as security update if the line references a security pocket
if echo "$line" | grep -qiE "(-|/)security"; then
is_security_update=true
fi
if [[ "$first_ref" == true ]]; then
first_ref=false
else
packages_ref+=","
packages_ref+=","
fi
packages_ref+="{\"name\":\"$package_name\",\"currentVersion\":\"$current_version\",\"availableVersion\":\"$available_version\",\"needsUpdate\":true,\"isSecurityUpdate\":$is_security_update}"
fi
done <<< "$upgradable"
done <<< "$upgradable_sim"
# Get installed packages that are up to date
local installed=$(dpkg-query -W -f='${Package} ${Version}\n' | head -100)
@@ -608,16 +644,31 @@ get_yum_packages() {
fi
# Get upgradable packages
local upgradable=$($package_manager check-update 2>/dev/null | grep -v "^$" | grep -v "^Loaded" | grep -v "^Last metadata" | tail -n +2)
local upgradable=$($package_manager check-update 2>/dev/null | grep -v "^$" | grep -v "^Loaded" | grep -v "^Last metadata" | grep -v "^Security" | tail -n +2)
while IFS= read -r line; do
# Skip empty lines and lines with special characters
[[ -z "$line" ]] && continue
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
if [[ "$line" =~ ^([^[:space:]]+)[[:space:]]+([^[:space:]]+)[[:space:]]+([^[:space:]]+) ]]; then
local package_name="${BASH_REMATCH[1]}"
local available_version="${BASH_REMATCH[2]}"
local repo="${BASH_REMATCH[3]}"
# Sanitize package name and versions (remove any control characters)
package_name=$(echo "$package_name" | tr -d '[:cntrl:]' | sed 's/[^a-zA-Z0-9._+-]//g')
available_version=$(echo "$available_version" | tr -d '[:cntrl:]' | sed 's/[^a-zA-Z0-9._+-]//g')
repo=$(echo "$repo" | tr -d '[:cntrl:]')
# Skip if package name is empty after sanitization
[[ -z "$package_name" ]] && continue
# Get current version
local current_version=$($package_manager list installed "$package_name" 2>/dev/null | grep "^$package_name" | awk '{print $2}')
local current_version=$($package_manager list installed "$package_name" 2>/dev/null | grep "^$package_name" | awk '{print $2}' | tr -d '[:cntrl:]' | sed 's/[^a-zA-Z0-9._+-]//g')
# Skip if we couldn't get current version
[[ -z "$current_version" ]] && current_version="unknown"
local is_security_update=false
if echo "$repo" | grep -q "security"; then
@@ -638,10 +689,22 @@ get_yum_packages() {
local installed=$($package_manager list installed 2>/dev/null | grep -v "^Loaded" | grep -v "^Installed" | head -100)
while IFS= read -r line; do
# Skip empty lines
[[ -z "$line" ]] && continue
[[ "$line" =~ ^[[:space:]]*$ ]] && continue
if [[ "$line" =~ ^([^[:space:]]+)[[:space:]]+([^[:space:]]+) ]]; then
local package_name="${BASH_REMATCH[1]}"
local version="${BASH_REMATCH[2]}"
# Sanitize package name and version
package_name=$(echo "$package_name" | tr -d '[:cntrl:]' | sed 's/[^a-zA-Z0-9._+-]//g')
version=$(echo "$version" | tr -d '[:cntrl:]' | sed 's/[^a-zA-Z0-9._+-]//g')
# Skip if package name is empty after sanitization
[[ -z "$package_name" ]] && continue
[[ -z "$version" ]] && version="unknown"
# Check if this package is not in the upgrade list
if ! echo "$upgradable" | grep -q "^$package_name "; then
if [[ "$first_ref" == true ]]; then
@@ -656,24 +719,160 @@ get_yum_packages() {
done <<< "$installed"
}
# Get hardware information
get_hardware_info() {
local cpu_model=""
local cpu_cores=0
local ram_installed=0
local swap_size=0
local disk_details="[]"
# CPU Information
if command -v lscpu >/dev/null 2>&1; then
cpu_model=$(lscpu | grep "Model name" | cut -d':' -f2 | xargs)
cpu_cores=$(lscpu | grep "^CPU(s):" | cut -d':' -f2 | xargs)
elif [[ -f /proc/cpuinfo ]]; then
cpu_model=$(grep "model name" /proc/cpuinfo | head -1 | cut -d':' -f2 | xargs)
cpu_cores=$(grep -c "^processor" /proc/cpuinfo)
fi
# Memory Information
if command -v free >/dev/null 2>&1; then
# Use free -m to get MB, then convert to GB with decimal precision
ram_installed=$(free -m | grep "^Mem:" | awk '{printf "%.2f", $2/1024}')
swap_size=$(free -m | grep "^Swap:" | awk '{printf "%.2f", $2/1024}')
elif [[ -f /proc/meminfo ]]; then
# Convert KB to GB with decimal precision
ram_installed=$(grep "MemTotal" /proc/meminfo | awk '{printf "%.2f", $2/1048576}')
swap_size=$(grep "SwapTotal" /proc/meminfo | awk '{printf "%.2f", $2/1048576}')
fi
# Ensure minimum value of 0.01GB to prevent 0 values
if (( $(echo "$ram_installed < 0.01" | bc -l) )); then
ram_installed="0.01"
fi
if (( $(echo "$swap_size < 0" | bc -l) )); then
swap_size="0"
fi
# Disk Information
if command -v lsblk >/dev/null 2>&1; then
disk_details=$(lsblk -J -o NAME,SIZE,TYPE,MOUNTPOINT | jq -c '[.blockdevices[] | select(.type == "disk") | {name: .name, size: .size, mountpoint: .mountpoint}]')
elif command -v df >/dev/null 2>&1; then
disk_details=$(df -h | grep -E "^/dev/" | awk '{print "{\"name\":\""$1"\",\"size\":\""$2"\",\"mountpoint\":\""$6"\"}"}' | jq -s .)
fi
echo "{\"cpuModel\":\"$cpu_model\",\"cpuCores\":$cpu_cores,\"ramInstalled\":$ram_installed,\"swapSize\":$swap_size,\"diskDetails\":$disk_details}"
}
# Get network information
get_network_info() {
local gateway_ip=""
local dns_servers="[]"
local network_interfaces="[]"
# Gateway IP
if command -v ip >/dev/null 2>&1; then
gateway_ip=$(ip route | grep default | head -1 | awk '{print $3}')
elif command -v route >/dev/null 2>&1; then
gateway_ip=$(route -n | grep '^0.0.0.0' | head -1 | awk '{print $2}')
fi
# DNS Servers
if [[ -f /etc/resolv.conf ]]; then
dns_servers=$(grep "nameserver" /etc/resolv.conf | awk '{print $2}' | jq -R . | jq -s .)
fi
# Network Interfaces
if command -v ip >/dev/null 2>&1; then
network_interfaces=$(ip -j addr show | jq -c '[.[] | {name: .ifname, type: .link_type, addresses: [.addr_info[]? | {address: .local, family: .family}]}]')
elif command -v ifconfig >/dev/null 2>&1; then
network_interfaces=$(ifconfig -a | grep -E "^[a-zA-Z]" | awk '{print $1}' | jq -R . | jq -s .)
fi
echo "{\"gatewayIp\":\"$gateway_ip\",\"dnsServers\":$dns_servers,\"networkInterfaces\":$network_interfaces}"
}
# Get system information
get_system_info() {
local kernel_version=""
local selinux_status=""
local system_uptime=""
local load_average="[]"
# Kernel Version
if [[ -f /proc/version ]]; then
kernel_version=$(cat /proc/version | awk '{print $3}')
elif command -v uname >/dev/null 2>&1; then
kernel_version=$(uname -r)
fi
# SELinux Status
if command -v getenforce >/dev/null 2>&1; then
selinux_status=$(getenforce 2>/dev/null | tr '[:upper:]' '[:lower:]')
# Map "enforcing" to "enabled" for server validation
if [[ "$selinux_status" == "enforcing" ]]; then
selinux_status="enabled"
fi
elif [[ -f /etc/selinux/config ]]; then
selinux_status=$(grep "^SELINUX=" /etc/selinux/config | cut -d'=' -f2 | tr '[:upper:]' '[:lower:]')
# Map "enforcing" to "enabled" for server validation
if [[ "$selinux_status" == "enforcing" ]]; then
selinux_status="enabled"
fi
else
selinux_status="disabled"
fi
# System Uptime
if [[ -f /proc/uptime ]]; then
local uptime_seconds=$(cat /proc/uptime | awk '{print int($1)}')
local days=$((uptime_seconds / 86400))
local hours=$(((uptime_seconds % 86400) / 3600))
local minutes=$(((uptime_seconds % 3600) / 60))
system_uptime="${days}d ${hours}h ${minutes}m"
elif command -v uptime >/dev/null 2>&1; then
system_uptime=$(uptime | awk -F'up ' '{print $2}' | awk -F', load' '{print $1}')
fi
# Load Average
if [[ -f /proc/loadavg ]]; then
load_average=$(cat /proc/loadavg | awk '{print "["$1","$2","$3"]"}')
elif command -v uptime >/dev/null 2>&1; then
load_average=$(uptime | awk -F'load average: ' '{print "["$2"]"}' | tr -d ' ')
fi
echo "{\"kernelVersion\":\"$kernel_version\",\"selinuxStatus\":\"$selinux_status\",\"systemUptime\":\"$system_uptime\",\"loadAverage\":$load_average}"
}
# Send package update to server
send_update() {
load_credentials
info "Collecting package information..."
local packages_json=$(get_package_info)
# Verify datetime before proceeding
if ! verify_datetime; then
warning "Datetime verification failed, but continuing with update..."
fi
info "Collecting repository information..."
info "Collecting system information..."
local packages_json=$(get_package_info)
local repositories_json=$(get_repository_info)
local hardware_json=$(get_hardware_info)
local network_json=$(get_network_info)
local system_json=$(get_system_info)
info "Sending update to PatchMon server..."
local payload=$(cat <<EOF
# Merge all JSON objects into one
local merged_json=$(echo "$hardware_json $network_json $system_json" | jq -s '.[0] * .[1] * .[2]')
# Create the base payload and merge with system info
local base_payload=$(cat <<EOF
{
"packages": $packages_json,
"repositories": $repositories_json,
"osType": "$OS_TYPE",
"osVersion": "$OS_VERSION",
"hostname": "$HOSTNAME",
"ip": "$IP_ADDRESS",
"architecture": "$ARCHITECTURE",
"agentVersion": "$AGENT_VERSION"
@@ -681,7 +880,11 @@ send_update() {
EOF
)
local response=$(curl -s -X POST \
# Merge the base payload with the system information
local payload=$(echo "$base_payload $merged_json" | jq -s '.[0] * .[1]')
local response=$(curl $CURL_FLAGS -X POST \
-H "Content-Type: application/json" \
-H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \
@@ -690,52 +893,35 @@ EOF
if [[ $? -eq 0 ]]; then
if echo "$response" | grep -q "success"; then
success "Update sent successfully"
echo "$response" | grep -o '"packagesProcessed":[0-9]*' | cut -d':' -f2 | xargs -I {} info "Processed {} packages"
local packages_count=$(echo "$response" | grep -o '"packagesProcessed":[0-9]*' | cut -d':' -f2)
success "Update sent successfully (${packages_count} packages processed)"
# Check for auto-update instructions (look specifically in autoUpdate section)
if echo "$response" | grep -q '"autoUpdate":{'; then
local auto_update_section=$(echo "$response" | grep -o '"autoUpdate":{[^}]*}')
local should_update=$(echo "$auto_update_section" | grep -o '"shouldUpdate":true' | cut -d':' -f2)
if [[ "$should_update" == "true" ]]; then
local latest_version=$(echo "$auto_update_section" | grep -o '"latestVersion":"[^"]*' | cut -d'"' -f4)
local current_version=$(echo "$auto_update_section" | grep -o '"currentVersion":"[^"]*' | cut -d'"' -f4)
local update_message=$(echo "$auto_update_section" | grep -o '"message":"[^"]*' | cut -d'"' -f4)
info "Auto-update detected: $update_message"
info "Current version: $current_version, Latest version: $latest_version"
# Automatically run update-agent command
info "Automatically updating agent to latest version..."
# Check if auto-update is enabled and check for agent updates locally
if check_auto_update_enabled; then
info "Checking for agent updates..."
if check_agent_update_needed; then
info "Agent update available, updating..."
if "$0" update-agent; then
success "Agent auto-update completed successfully"
success "Agent updated successfully"
else
warning "Agent auto-update failed, but data was sent successfully"
warning "Agent update failed, but data was sent successfully"
fi
else
info "Agent is up to date"
fi
fi
# Check for crontab update instructions (look specifically in crontabUpdate section)
if echo "$response" | grep -q '"crontabUpdate":{'; then
local crontab_update_section=$(echo "$response" | grep -o '"crontabUpdate":{[^}]*}')
local should_update_crontab=$(echo "$crontab_update_section" | grep -o '"shouldUpdate":true' | cut -d':' -f2)
if [[ "$should_update_crontab" == "true" ]]; then
local crontab_message=$(echo "$crontab_update_section" | grep -o '"message":"[^"]*' | cut -d'"' -f4)
local crontab_command=$(echo "$crontab_update_section" | grep -o '"command":"[^"]*' | cut -d'"' -f4)
if [[ -n "$crontab_message" ]]; then
info "Crontab update detected: $crontab_message"
fi
if [[ "$crontab_command" == "update-crontab" ]]; then
info "Automatically updating crontab with new interval..."
if "$0" update-crontab; then
success "Crontab updated successfully"
else
warning "Crontab update failed, but data was sent successfully"
fi
fi
fi
# Automatically check if crontab needs updating based on server settings
info "Checking crontab configuration..."
"$0" update-crontab
local crontab_exit_code=$?
if [[ $crontab_exit_code -eq 0 ]]; then
success "Crontab updated successfully"
elif [[ $crontab_exit_code -eq 2 ]]; then
# Already up to date - no additional message needed
true
else
warning "Crontab update failed, but data was sent successfully"
fi
else
error "Update failed: $response"
@@ -749,7 +935,7 @@ EOF
ping_server() {
load_credentials
local response=$(curl -s -X POST \
local response=$(curl $CURL_FLAGS -X POST \
-H "Content-Type: application/json" \
-H "X-API-ID: $API_ID" \
-H "X-API-KEY: $API_KEY" \
@@ -762,7 +948,7 @@ ping_server() {
info "Connected as host: $hostname"
fi
# Check for crontab update trigger
# Check for crontab update instructions
local should_update_crontab=$(echo "$response" | grep -o '"shouldUpdate":true' | cut -d':' -f2)
if [[ "$should_update_crontab" == "true" ]]; then
local message=$(echo "$response" | grep -o '"message":"[^"]*' | cut -d'"' -f4)
@@ -773,11 +959,16 @@ ping_server() {
fi
if [[ "$command" == "update-crontab" ]]; then
info "Automatically updating crontab with new interval..."
if "$0" update-crontab; then
info "Updating crontab with new interval..."
"$0" update-crontab
local crontab_exit_code=$?
if [[ $crontab_exit_code -eq 0 ]]; then
success "Crontab updated successfully"
elif [[ $crontab_exit_code -eq 2 ]]; then
# Already up to date - no additional message needed
true
else
warning "Crontab update failed, but ping was successful"
warning "Crontab update failed, but data was sent successfully"
fi
fi
fi
@@ -792,7 +983,7 @@ check_version() {
info "Checking for agent updates..."
local response=$(curl -s -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/version")
local response=$(curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/version")
if [[ $? -eq 0 ]]; then
local current_version=$(echo "$response" | grep -o '"currentVersion":"[^"]*' | cut -d'"' -f4)
@@ -821,59 +1012,147 @@ check_version() {
fi
}
# Check if auto-update is enabled (both globally and for this host)
check_auto_update_enabled() {
# Get settings from server using API credentials
local response=$(curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/settings" 2>/dev/null)
if [[ $? -ne 0 ]]; then
return 1
fi
# Check if both global and host auto-update are enabled
local global_auto_update=$(echo "$response" | grep -o '"auto_update":true' | cut -d':' -f2)
local host_auto_update=$(echo "$response" | grep -o '"host_auto_update":true' | cut -d':' -f2)
if [[ "$global_auto_update" == "true" && "$host_auto_update" == "true" ]]; then
return 0
else
return 1
fi
}
# Check if agent update is needed (internal function for auto-update)
check_agent_update_needed() {
# Get current agent timestamp
local current_timestamp=0
if [[ -f "$0" ]]; then
current_timestamp=$(stat -c %Y "$0" 2>/dev/null || stat -f %m "$0" 2>/dev/null || echo "0")
fi
# Get server agent info using API credentials
local response=$(curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/timestamp" 2>/dev/null)
if [[ $? -eq 0 ]]; then
local server_version=$(echo "$response" | grep -o '"version":"[^"]*' | cut -d'"' -f4)
local server_timestamp=$(echo "$response" | grep -o '"timestamp":[0-9]*' | cut -d':' -f2)
local server_exists=$(echo "$response" | grep -o '"exists":true' | cut -d':' -f2)
if [[ "$server_exists" != "true" ]]; then
return 1
fi
# Check if update is needed
if [[ "$server_version" != "$AGENT_VERSION" ]]; then
return 0 # Update needed due to version mismatch
elif [[ "$server_timestamp" -gt "$current_timestamp" ]]; then
return 0 # Update needed due to newer timestamp
else
return 1 # No update needed
fi
else
return 1 # Failed to check
fi
}
# Check for agent updates based on version and timestamp (interactive command)
check_agent_update() {
load_credentials
info "Checking for agent updates..."
# Get current agent timestamp
local current_timestamp=0
if [[ -f "$0" ]]; then
current_timestamp=$(stat -c %Y "$0" 2>/dev/null || stat -f %m "$0" 2>/dev/null || echo "0")
fi
# Get server agent info using API credentials
local response=$(curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/timestamp")
if [[ $? -eq 0 ]]; then
local server_version=$(echo "$response" | grep -o '"version":"[^"]*' | cut -d'"' -f4)
local server_timestamp=$(echo "$response" | grep -o '"timestamp":[0-9]*' | cut -d':' -f2)
local server_exists=$(echo "$response" | grep -o '"exists":true' | cut -d':' -f2)
if [[ "$server_exists" != "true" ]]; then
warning "No agent script found on server"
return 1
fi
info "Current agent version: $AGENT_VERSION (timestamp: $current_timestamp)"
info "Server agent version: $server_version (timestamp: $server_timestamp)"
# Check if update is needed
if [[ "$server_version" != "$AGENT_VERSION" ]]; then
info "Version mismatch detected - update needed"
return 0
elif [[ "$server_timestamp" -gt "$current_timestamp" ]]; then
info "Server script is newer - update needed"
return 0
else
info "Agent is up to date"
return 1
fi
else
error "Failed to check agent timestamp from server"
return 1
fi
}
# Update agent script
update_agent() {
load_credentials
info "Updating agent script..."
local response=$(curl -s -X GET "$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/version")
local download_url="$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/download"
if [[ $? -eq 0 ]]; then
local download_url=$(echo "$response" | grep -o '"downloadUrl":"[^"]*' | cut -d'"' -f4)
if [[ -z "$download_url" ]]; then
download_url="$PATCHMON_SERVER/api/$API_VERSION/hosts/agent/download"
elif [[ "$download_url" =~ ^/ ]]; then
# If download_url is relative, prepend the server URL
download_url="$PATCHMON_SERVER$download_url"
fi
info "Downloading latest agent from: $download_url"
# Create backup of current script
cp "$0" "$0.backup.$(date +%Y%m%d_%H%M%S)"
# Download new version
if curl -s -o "/tmp/patchmon-agent-new.sh" "$download_url"; then
# Verify the downloaded script is valid
if bash -n "/tmp/patchmon-agent-new.sh" 2>/dev/null; then
# Replace current script
mv "/tmp/patchmon-agent-new.sh" "$0"
chmod +x "$0"
success "Agent updated successfully"
info "Backup saved as: $0.backup.$(date +%Y%m%d_%H%M%S)"
# Get the new version number
local new_version=$(grep '^AGENT_VERSION=' "$0" | cut -d'"' -f2)
info "Updated to version: $new_version"
# Automatically run update to send new information to PatchMon
info "Sending updated information to PatchMon..."
if "$0" update; then
success "Successfully sent updated information to PatchMon"
else
warning "Failed to send updated information to PatchMon (this is not critical)"
fi
info "Downloading latest agent from: $download_url"
# Clean up old backups (keep only last 3)
ls -t "$0.backup."* 2>/dev/null | tail -n +4 | xargs -r rm -f
# Create backup of current script
local backup_file="$0.backup.$(date +%Y%m%d_%H%M%S)"
cp "$0" "$backup_file"
# Download new version using API credentials
if curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -o "/tmp/patchmon-agent-new.sh" "$download_url"; then
# Verify the downloaded script is valid
if bash -n "/tmp/patchmon-agent-new.sh" 2>/dev/null; then
# Replace current script
mv "/tmp/patchmon-agent-new.sh" "$0"
chmod +x "$0"
success "Agent updated successfully"
info "Backup saved as: $backup_file"
# Get the new version number
local new_version=$(grep '^AGENT_VERSION=' "$0" | cut -d'"' -f2)
info "Updated to version: $new_version"
# Automatically run update to send new information to PatchMon
info "Sending updated information to PatchMon..."
if "$0" update; then
success "Successfully sent updated information to PatchMon"
else
error "Downloaded script is invalid"
rm -f "/tmp/patchmon-agent-new.sh"
warning "Failed to send updated information to PatchMon (this is not critical)"
fi
else
error "Failed to download new agent script"
error "Downloaded script is invalid"
rm -f "/tmp/patchmon-agent-new.sh"
fi
else
error "Failed to get update information"
error "Failed to download new agent script"
fi
}
@@ -881,32 +1160,59 @@ update_agent() {
update_crontab() {
load_credentials
info "Updating crontab with current policy..."
local response=$(curl -s -X GET "$PATCHMON_SERVER/api/$API_VERSION/settings/update-interval")
local response=$(curl $CURL_FLAGS -H "X-API-ID: $API_ID" -H "X-API-KEY: $API_KEY" -X GET "$PATCHMON_SERVER/api/$API_VERSION/settings/update-interval")
if [[ $? -eq 0 ]]; then
local update_interval=$(echo "$response" | grep -o '"updateInterval":[0-9]*' | cut -d':' -f2)
# Fallback if not found
if [[ -z "$update_interval" ]]; then
update_interval=60
fi
# Normalize interval: 5-59 valid, otherwise snap to hour presets
if [[ $update_interval -lt 5 ]]; then
update_interval=5
elif [[ $update_interval -gt 1440 ]]; then
update_interval=1440
fi
if [[ -n "$update_interval" ]]; then
# Generate the expected crontab entry
local expected_crontab=""
if [[ $update_interval -eq 60 ]]; then
# Hourly updates
expected_crontab="0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1"
else
# Custom interval updates
if [[ $update_interval -lt 60 ]]; then
# Every N minutes (5-59)
expected_crontab="*/$update_interval * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1"
else
# Hour-based schedules
if [[ $update_interval -eq 60 ]]; then
# Hourly updates starting at current minute to spread load
local current_minute=$(date +%M)
expected_crontab="$current_minute * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1"
else
# For 120, 180, 360, 720, 1440 -> every H hours at minute 0
local hours=$((update_interval / 60))
expected_crontab="0 */$hours * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1"
fi
fi
# Get current crontab
local current_crontab=$(crontab -l 2>/dev/null | grep "patchmon-agent.sh update" | head -1)
# Get current crontab (without patchmon entries)
local current_crontab_without_patchmon=$(crontab -l 2>/dev/null | grep -v "/usr/local/bin/patchmon-agent.sh update" || true)
local current_patchmon_entry=$(crontab -l 2>/dev/null | grep "/usr/local/bin/patchmon-agent.sh update" | head -1)
# Check if crontab needs updating
if [[ "$current_crontab" == "$expected_crontab" ]]; then
if [[ "$current_patchmon_entry" == "$expected_crontab" ]]; then
info "Crontab is already up to date (interval: $update_interval minutes)"
return 0
return 2 # Special return code for "already up to date"
fi
info "Setting update interval to $update_interval minutes"
echo "$expected_crontab" | crontab -
success "Crontab updated successfully"
# Combine existing cron (without patchmon entries) + new patchmon entry
{
if [[ -n "$current_crontab_without_patchmon" ]]; then
echo "$current_crontab_without_patchmon"
fi
echo "$expected_crontab"
} | crontab -
success "Crontab updated successfully (duplicates removed)"
else
error "Could not determine update interval from server"
fi
@@ -1018,7 +1324,7 @@ main() {
check_root
setup_directories
load_config
configure_credentials "$2" "$3"
configure_credentials "$2" "$3" "$4"
;;
"test")
check_root
@@ -1049,6 +1355,11 @@ main() {
load_config
check_version
;;
"check-agent-update")
setup_directories
load_config
check_agent_update
;;
"update-agent")
check_root
setup_directories
@@ -1066,22 +1377,23 @@ main() {
;;
*)
echo "PatchMon Agent v$AGENT_VERSION - API Credential Based"
echo "Usage: $0 {configure|test|update|ping|config|check-version|update-agent|update-crontab|diagnostics}"
echo "Usage: $0 {configure|test|update|ping|config|check-version|check-agent-update|update-agent|update-crontab|diagnostics}"
echo ""
echo "Commands:"
echo " configure <API_ID> <API_KEY> - Configure API credentials for this host"
echo " configure <API_ID> <API_KEY> [SERVER_URL] - Configure API credentials for this host"
echo " test - Test API credentials connectivity"
echo " update - Send package update information to server"
echo " ping - Test connectivity to server"
echo " config - Show current configuration"
echo " check-version - Check for agent updates"
echo " check-agent-update - Check for agent updates using timestamp comparison"
echo " update-agent - Update agent to latest version"
echo " update-crontab - Update crontab with current policy"
echo " diagnostics - Show detailed system diagnostics"
echo ""
echo "Setup Process:"
echo " 1. Contact your PatchMon administrator to create a host entry"
echo " 2. Run: $0 configure <API_ID> <API_KEY> (provided by admin)"
echo " 2. Run: $0 configure <API_ID> <API_KEY> [SERVER_URL] (provided by admin)"
echo " 3. Run: $0 test (to verify connection)"
echo " 4. Run: $0 update (to send initial package data)"
echo ""
@@ -1094,4 +1406,4 @@ main() {
}
# Run main function
main "$@"
main "$@"

View File

@@ -1,10 +1,15 @@
#!/bin/bash
# PatchMon Agent Installation Script
# Usage: curl -sSL {PATCHMON_URL}/api/v1/hosts/install | bash -s -- {PATCHMON_URL} {API_ID} {API_KEY}
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/install -H "X-API-ID: {API_ID}" -H "X-API-KEY: {API_KEY}" | bash
set -e
# This placeholder will be dynamically replaced by the server when serving this
# script based on the "ignore SSL self-signed" setting. If set to -k, curl will
# ignore certificate validation. Otherwise, it will be empty for secure default.
# CURL_FLAGS is now set via environment variables by the backend
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
@@ -35,69 +40,179 @@ if [[ $EUID -ne 0 ]]; then
error "This script must be run as root (use sudo)"
fi
# Default server URL (will be replaced by backend with configured URL)
PATCHMON_URL="http://localhost:3001"
# Parse arguments
if [[ $# -ne 3 ]]; then
echo "Usage: curl -sSL {PATCHMON_URL}/api/v1/hosts/install | bash -s -- {PATCHMON_URL} {API_ID} {API_KEY}"
# Verify system datetime and timezone
verify_datetime() {
info "🕐 Verifying system datetime and timezone..."
# Get current system time
local system_time=$(date)
local timezone=$(timedatectl show --property=Timezone --value 2>/dev/null || echo "Unknown")
# Display current datetime info
echo ""
echo "Example:"
echo "curl -sSL http://patchmon.example.com/api/v1/hosts/install | bash -s -- http://patchmon.example.com patchmon_1a2b3c4d abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
echo -e "${BLUE}📅 Current System Date/Time:${NC}"
echo " • Date/Time: $system_time"
echo " • Timezone: $timezone"
echo ""
echo "Contact your PatchMon administrator to get your API credentials."
exit 1
fi
PATCHMON_URL="$1"
API_ID="$2"
API_KEY="$3"
# Validate inputs
if [[ ! "$PATCHMON_URL" =~ ^https?:// ]]; then
error "Invalid URL format. Must start with http:// or https://"
fi
if [[ ! "$API_ID" =~ ^patchmon_[a-f0-9]{16}$ ]]; then
error "Invalid API ID format. API ID should be in format: patchmon_xxxxxxxxxxxxxxxx"
fi
if [[ ! "$API_KEY" =~ ^[a-f0-9]{64}$ ]]; then
error "Invalid API Key format. API Key should be 64 hexadecimal characters."
fi
info "🚀 Installing PatchMon Agent..."
info " Server: $PATCHMON_URL"
info " API ID: $API_ID"
# Create patchmon directory
info "📁 Creating configuration directory..."
mkdir -p /etc/patchmon
# Download the agent script
info "📥 Downloading PatchMon agent script..."
curl -sSL "$PATCHMON_URL/api/v1/hosts/agent/download" -o /usr/local/bin/patchmon-agent.sh
chmod +x /usr/local/bin/patchmon-agent.sh
# Get the agent version from the downloaded script
AGENT_VERSION=$(grep '^AGENT_VERSION=' /usr/local/bin/patchmon-agent.sh | cut -d'"' -f2)
info "📋 Agent version: $AGENT_VERSION"
# Get expected agent version from server
EXPECTED_VERSION=$(curl -s "$PATCHMON_URL/api/v1/hosts/agent/version" | grep -o '"currentVersion":"[^"]*' | cut -d'"' -f4 2>/dev/null || echo "Unknown")
if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then
info "📋 Expected version: $EXPECTED_VERSION"
if [[ "$AGENT_VERSION" != "$EXPECTED_VERSION" ]]; then
warning "⚠️ Agent version mismatch! Installed: $AGENT_VERSION, Expected: $EXPECTED_VERSION"
# Check if we can read from stdin (interactive terminal)
if [[ -t 0 ]]; then
# Interactive terminal - ask user
read -p "Does this date/time look correct to you? (y/N): " -r response
if [[ "$response" =~ ^[Yy]$ ]]; then
success "✅ Date/time verification passed"
echo ""
return 0
else
echo ""
echo -e "${RED}❌ Date/time verification failed${NC}"
echo ""
echo -e "${YELLOW}💡 Please fix the date/time and re-run the installation script:${NC}"
echo " sudo timedatectl set-time 'YYYY-MM-DD HH:MM:SS'"
echo " sudo timedatectl set-timezone 'America/New_York' # or your timezone"
echo " sudo timedatectl list-timezones # to see available timezones"
echo ""
echo -e "${BLUE} After fixing the date/time, re-run this installation script.${NC}"
error "Installation cancelled - please fix date/time and re-run"
fi
else
# Non-interactive (piped from curl) - show warning and continue
echo -e "${YELLOW}⚠️ Non-interactive installation detected${NC}"
echo ""
echo "Please verify the date/time shown above is correct."
echo "If the date/time is incorrect, it may cause issues with:"
echo " • Logging timestamps"
echo " • Scheduled updates"
echo " • Data synchronization"
echo ""
echo -e "${GREEN}✅ Continuing with installation...${NC}"
success "✅ Date/time verification completed (assumed correct)"
echo ""
fi
}
# Run datetime verification
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
# Clean up old agent backups
ls -t /usr/local/bin/patchmon-agent.sh.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
}
# Run cleanup at start
cleanup_old_files
# Parse arguments from environment (passed via HTTP headers)
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
# Get update interval policy from server
UPDATE_INTERVAL=$(curl -s "$PATCHMON_URL/api/v1/settings/update-interval" | grep -o '"updateInterval":[0-9]*' | cut -d':' -f2 2>/dev/null || echo "60")
info "📋 Update interval: $UPDATE_INTERVAL minutes"
info "🚀 Starting PatchMon Agent Installation..."
info "📋 Server: $PATCHMON_URL"
info "🔑 API ID: ${API_ID:0:16}..."
# Display diagnostic information
echo ""
echo -e "${BLUE}🔧 Installation Diagnostics:${NC}"
echo " • URL: $PATCHMON_URL"
echo " • CURL FLAGS: $CURL_FLAGS"
echo " • API ID: ${API_ID:0:16}..."
echo " • API Key: ${API_KEY:0:16}..."
echo ""
# Install required dependencies
info "📦 Installing required dependencies..."
echo ""
# Detect package manager and install jq and curl
if command -v apt-get >/dev/null 2>&1; then
# Debian/Ubuntu
info "Detected apt-get (Debian/Ubuntu)"
echo ""
info "Updating package lists..."
apt-get update
echo ""
info "Installing jq, curl, and bc..."
apt-get install jq curl bc -y
elif command -v yum >/dev/null 2>&1; then
# CentOS/RHEL 7
info "Detected yum (CentOS/RHEL 7)"
echo ""
info "Installing jq, curl, and bc..."
yum install -y jq curl bc
elif command -v dnf >/dev/null 2>&1; then
# CentOS/RHEL 8+/Fedora
info "Detected dnf (CentOS/RHEL 8+/Fedora)"
echo ""
info "Installing jq, curl, and bc..."
dnf install -y jq curl bc
elif command -v zypper >/dev/null 2>&1; then
# openSUSE
info "Detected zypper (openSUSE)"
echo ""
info "Installing jq, curl, and bc..."
zypper install -y jq curl bc
elif command -v pacman >/dev/null 2>&1; then
# Arch Linux
info "Detected pacman (Arch Linux)"
echo ""
info "Installing jq, curl, and bc..."
pacman -S --noconfirm jq curl bc
elif command -v apk >/dev/null 2>&1; then
# Alpine Linux
info "Detected apk (Alpine Linux)"
echo ""
info "Installing jq, curl, and bc..."
apk add --no-cache jq curl bc
else
warning "Could not detect package manager. Please ensure 'jq', 'curl', and 'bc' are installed manually."
fi
echo ""
success "Dependencies installation completed"
echo ""
# Step 1: Handle existing configuration directory
info "📁 Setting up configuration directory..."
# Check if configuration directory already exists
if [[ -d "/etc/patchmon" ]]; then
warning "⚠️ Configuration directory already exists at /etc/patchmon"
warning "⚠️ Preserving existing configuration files"
# List existing files for user awareness
info "📋 Existing files in /etc/patchmon:"
ls -la /etc/patchmon/ 2>/dev/null | grep -v "^total" | while read -r line; do
echo " $line"
done
else
info "📁 Creating new configuration directory..."
mkdir -p /etc/patchmon
fi
# Step 2: Create credentials file
info "🔐 Creating API credentials file..."
# Check if credentials file already exists
if [[ -f "/etc/patchmon/credentials" ]]; then
warning "⚠️ Credentials file already exists at /etc/patchmon/credentials"
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
# 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)"
fi
# Create credentials file
info "🔐 Setting up API credentials..."
cat > /etc/patchmon/credentials << EOF
# PatchMon API Credentials
# Generated on $(date)
@@ -105,52 +220,90 @@ PATCHMON_URL="$PATCHMON_URL"
API_ID="$API_ID"
API_KEY="$API_KEY"
EOF
chmod 600 /etc/patchmon/credentials
# Test the configuration
info "🧪 Testing configuration..."
# Step 3: Download the agent script using API credentials
info "📥 Downloading PatchMon agent script..."
# 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"
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
# 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)"
fi
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
chmod +x /usr/local/bin/patchmon-agent.sh
# 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")
info "📋 Agent version: $AGENT_VERSION"
# Handle existing log files
if [[ -f "/var/log/patchmon-agent.log" ]]; then
warning "⚠️ Existing log file found at /var/log/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)"
fi
# Step 4: Test the configuration
info "🧪 Testing API credentials and connectivity..."
if /usr/local/bin/patchmon-agent.sh test; then
success "Configuration test passed!"
success "✅ TEST: API credentials are valid and server is reachable"
else
error "Configuration test failed. Please check your credentials."
error "❌ Failed to validate API credentials or reach server"
fi
# Send initial update
info "📊 Sending initial package data..."
# Step 5: Send initial data and setup automated updates
info "📊 Sending initial package data to server..."
if /usr/local/bin/patchmon-agent.sh update; then
success "Initial package data sent successfully!"
success "✅ UPDATE: Initial package data sent successfully"
info "✅ Automated updates configured by agent"
else
warning "Initial package data failed, but agent is configured. You can run 'patchmon-agent.sh update' manually."
warning "⚠️ Failed to send initial data. You can retry later with: /usr/local/bin/patchmon-agent.sh update"
fi
# Setup crontab for automatic updates
info "⏰ Setting up automatic updates every $UPDATE_INTERVAL minutes..."
if [[ $UPDATE_INTERVAL -eq 60 ]]; then
# Hourly updates
echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | crontab -
else
# Custom interval updates
echo "*/$UPDATE_INTERVAL * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | crontab -
fi
success "🎉 PatchMon Agent installation complete!"
# Installation complete
success "🎉 PatchMon Agent installation completed successfully!"
echo ""
echo "📋 Installation Summary:"
echo -e "${GREEN}📋 Installation Summary:${NC}"
echo " • Configuration directory: /etc/patchmon"
echo " • Agent installed: /usr/local/bin/patchmon-agent.sh"
echo "Agent version: $AGENT_VERSION"
if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then
echo "Expected version: $EXPECTED_VERSION"
fi
echo " • Config directory: /etc/patchmon/"
echo " • Credentials file: /etc/patchmon/credentials"
echo " • Automatic updates: Every $UPDATE_INTERVAL minutes via crontab"
echo " • View logs: tail -f /var/log/patchmon-agent.sh"
echo ""
echo "🔧 Manual commands:"
echo " • Test connection: patchmon-agent.sh test"
echo " • Send update: patchmon-agent.sh update"
echo " • Check status: patchmon-agent.sh ping"
echo ""
success "Your host is now connected to PatchMon!"
echo " • Dependencies installed: jq, curl, bc"
echo " • Automated updates configured via crontab"
echo " • API credentials configured and tested"
echo " • Update schedule managed by agent"
# 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)
if [[ -n "$MOVED_FILES" ]]; then
echo ""
echo -e "${YELLOW}📋 Files Moved for Fresh Installation:${NC}"
echo "$MOVED_FILES" | while read -r moved_file; do
echo "$moved_file"
done
echo ""
echo -e "${BLUE}💡 Note: Old files are automatically cleaned up (keeping last 3)${NC}"
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 ""
success "✅ Your system is now being monitored by PatchMon!"

222
agents/patchmon_remove.sh Executable file
View File

@@ -0,0 +1,222 @@
#!/bin/bash
# PatchMon Agent Removal Script
# Usage: curl -s {PATCHMON_URL}/api/v1/hosts/remove | bash
# This script completely removes PatchMon from the system
set -e
# This placeholder will be dynamically replaced by the server when serving this
# script based on the "ignore SSL self-signed" setting for any curl calls in
# future (left for consistency with install script).
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
# Functions
error() {
echo -e "${RED}❌ ERROR: $1${NC}" >&2
exit 1
}
info() {
echo -e "${BLUE} $1${NC}"
}
success() {
echo -e "${GREEN}$1${NC}"
}
warning() {
echo -e "${YELLOW}⚠️ $1${NC}"
}
# Check if running as root
if [[ $EUID -ne 0 ]]; then
error "This script must be run as root (use sudo)"
fi
info "🗑️ Starting PatchMon Agent Removal..."
echo ""
# Step 1: Stop any running PatchMon processes
info "🛑 Stopping PatchMon processes..."
if pgrep -f "patchmon-agent.sh" >/dev/null; then
warning "Found running PatchMon processes, stopping them..."
pkill -f "patchmon-agent.sh" || true
sleep 2
success "PatchMon processes stopped"
else
info "No running PatchMon processes found"
fi
# Step 2: Remove crontab entries
info "📅 Removing PatchMon crontab entries..."
if crontab -l 2>/dev/null | grep -q "patchmon-agent.sh"; then
warning "Found PatchMon crontab entries, removing them..."
crontab -l 2>/dev/null | grep -v "patchmon-agent.sh" | crontab -
success "Crontab entries removed"
else
info "No PatchMon crontab entries found"
fi
# Step 3: Remove agent script
info "📄 Removing agent script..."
if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then
warning "Removing agent script: /usr/local/bin/patchmon-agent.sh"
rm -f /usr/local/bin/patchmon-agent.sh
success "Agent script removed"
else
info "Agent script not found"
fi
# Step 4: Remove configuration directory and files
info "📁 Removing configuration files..."
if [[ -d "/etc/patchmon" ]]; then
warning "Removing configuration directory: /etc/patchmon"
# Show what's being removed
info "📋 Files in /etc/patchmon:"
ls -la /etc/patchmon/ 2>/dev/null | grep -v "^total" | while read -r line; do
echo " $line"
done
# Remove the directory
rm -rf /etc/patchmon
success "Configuration directory removed"
else
info "Configuration directory not found"
fi
# Step 5: Remove log files
info "📝 Removing log files..."
if [[ -f "/var/log/patchmon-agent.log" ]]; then
warning "Removing log file: /var/log/patchmon-agent.log"
rm -f /var/log/patchmon-agent.log
success "Log file removed"
else
info "Log file not found"
fi
# Step 6: Clean up backup files (optional)
info "🧹 Cleaning up backup files..."
BACKUP_COUNT=0
# Count credential backups
CRED_BACKUPS=$(ls /etc/patchmon/credentials.backup.* 2>/dev/null | wc -l || echo "0")
if [[ $CRED_BACKUPS -gt 0 ]]; then
BACKUP_COUNT=$((BACKUP_COUNT + CRED_BACKUPS))
fi
# Count agent backups
AGENT_BACKUPS=$(ls /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | wc -l || echo "0")
if [[ $AGENT_BACKUPS -gt 0 ]]; then
BACKUP_COUNT=$((BACKUP_COUNT + AGENT_BACKUPS))
fi
# Count log backups
LOG_BACKUPS=$(ls /var/log/patchmon-agent.log.old.* 2>/dev/null | wc -l || echo "0")
if [[ $LOG_BACKUPS -gt 0 ]]; then
BACKUP_COUNT=$((BACKUP_COUNT + LOG_BACKUPS))
fi
if [[ $BACKUP_COUNT -gt 0 ]]; then
warning "Found $BACKUP_COUNT backup files"
echo ""
echo -e "${YELLOW}📋 Backup files found:${NC}"
# Show credential backups
if [[ $CRED_BACKUPS -gt 0 ]]; then
echo " Credential backups:"
ls /etc/patchmon/credentials.backup.* 2>/dev/null | while read -r file; do
echo "$file"
done
fi
# Show agent backups
if [[ $AGENT_BACKUPS -gt 0 ]]; then
echo " Agent script backups:"
ls /usr/local/bin/patchmon-agent.sh.backup.* 2>/dev/null | while read -r file; do
echo "$file"
done
fi
# Show log backups
if [[ $LOG_BACKUPS -gt 0 ]]; then
echo " Log file backups:"
ls /var/log/patchmon-agent.log.old.* 2>/dev/null | while read -r file; do
echo "$file"
done
fi
echo ""
echo -e "${BLUE}💡 Note: Backup files are preserved for safety${NC}"
echo -e "${BLUE}💡 You can remove them manually if not needed${NC}"
else
info "No backup files found"
fi
# Step 7: Remove dependencies (optional)
info "📦 Checking for PatchMon-specific dependencies..."
if command -v jq >/dev/null 2>&1; then
warning "jq is installed (used by PatchMon)"
echo -e "${BLUE}💡 Note: jq may be used by other applications${NC}"
echo -e "${BLUE}💡 Consider keeping it unless you're sure it's not needed${NC}"
else
info "jq not found"
fi
if command -v curl >/dev/null 2>&1; then
warning "curl is installed (used by PatchMon)"
echo -e "${BLUE}💡 Note: curl is commonly used by many applications${NC}"
echo -e "${BLUE}💡 Consider keeping it unless you're sure it's not needed${NC}"
else
info "curl not found"
fi
# Step 8: Final verification
info "🔍 Verifying removal..."
REMAINING_FILES=0
if [[ -f "/usr/local/bin/patchmon-agent.sh" ]]; then
REMAINING_FILES=$((REMAINING_FILES + 1))
fi
if [[ -d "/etc/patchmon" ]]; then
REMAINING_FILES=$((REMAINING_FILES + 1))
fi
if [[ -f "/var/log/patchmon-agent.log" ]]; then
REMAINING_FILES=$((REMAINING_FILES + 1))
fi
if crontab -l 2>/dev/null | grep -q "patchmon-agent.sh"; then
REMAINING_FILES=$((REMAINING_FILES + 1))
fi
if [[ $REMAINING_FILES -eq 0 ]]; then
success "✅ PatchMon has been completely removed from the system!"
else
warning "⚠️ Some PatchMon files may still remain ($REMAINING_FILES items)"
echo -e "${BLUE}💡 You may need to remove them manually${NC}"
fi
echo ""
echo -e "${GREEN}📋 Removal Summary:${NC}"
echo " • Agent script: Removed"
echo " • Configuration files: Removed"
echo " • Log files: Removed"
echo " • Crontab entries: Removed"
echo " • Running processes: Stopped"
echo " • Backup files: Preserved (if any)"
echo ""
echo -e "${BLUE}🔧 Manual cleanup (if needed):${NC}"
echo " • Remove backup files: rm /etc/patchmon/credentials.backup.* /usr/local/bin/patchmon-agent.sh.backup.* /var/log/patchmon-agent.log.old.*"
echo " • Remove dependencies: apt remove jq curl (if not needed by other apps)"
echo ""
success "🎉 PatchMon removal completed!"

349
agents/proxmox_auto_enroll.sh Executable file
View File

@@ -0,0 +1,349 @@
#!/bin/bash
set -eo pipefail # Exit on error, pipe failures (removed -u as we handle unset vars explicitly)
# Trap to catch errors only (not normal exits)
trap 'echo "[ERROR] Script failed at line $LINENO with exit code $?"' ERR
SCRIPT_VERSION="1.1.1"
echo "[DEBUG] Script Version: $SCRIPT_VERSION ($(date +%Y-%m-%d\ %H:%M:%S))"
# =============================================================================
# PatchMon Proxmox LXC Auto-Enrollment Script
# =============================================================================
# This script discovers LXC containers on a Proxmox host and automatically
# enrolls them into PatchMon for patch management.
#
# Usage:
# 1. Set environment variables or edit configuration below
# 2. Run: bash proxmox_auto_enroll.sh
#
# Requirements:
# - Must run on Proxmox host (requires 'pct' command)
# - Auto-enrollment token from PatchMon
# - Network access to PatchMon server
# =============================================================================
# ===== CONFIGURATION =====
PATCHMON_URL="${PATCHMON_URL:-https://patchmon.example.com}"
AUTO_ENROLLMENT_KEY="${AUTO_ENROLLMENT_KEY:-}"
AUTO_ENROLLMENT_SECRET="${AUTO_ENROLLMENT_SECRET:-}"
CURL_FLAGS="${CURL_FLAGS:--s}"
DRY_RUN="${DRY_RUN:-false}"
HOST_PREFIX="${HOST_PREFIX:-}"
SKIP_STOPPED="${SKIP_STOPPED:-true}"
PARALLEL_INSTALL="${PARALLEL_INSTALL:-false}"
MAX_PARALLEL="${MAX_PARALLEL:-5}"
# ===== COLOR OUTPUT =====
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# ===== LOGGING FUNCTIONS =====
info() { echo -e "${GREEN}[INFO]${NC} $1"; return 0; }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; return 0; }
error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; return 0; }
debug() { [[ "${DEBUG:-false}" == "true" ]] && echo -e "${BLUE}[DEBUG]${NC} $1" || true; return 0; }
# ===== BANNER =====
cat << "EOF"
╔═══════════════════════════════════════════════════════════════╗
║ ║
║ ____ _ _ __ __ ║
| _ \ __ _| |_ ___| |__ | \/ | ___ _ __ ║
| |_) / _` | __/ __| '_ \| |\/| |/ _ \| '_ \
| __/ (_| | || (__| | | | | | | (_) | | | |
|_| \__,_|\__\___|_| |_|_| |_|\___/|_| |_|
║ ║
║ Proxmox LXC Auto-Enrollment Script ║
║ ║
╚═══════════════════════════════════════════════════════════════╝
EOF
echo ""
# ===== VALIDATION =====
info "Validating configuration..."
if [[ -z "$AUTO_ENROLLMENT_KEY" ]] || [[ -z "$AUTO_ENROLLMENT_SECRET" ]]; then
error "AUTO_ENROLLMENT_KEY and AUTO_ENROLLMENT_SECRET must be set"
fi
if [[ -z "$PATCHMON_URL" ]]; then
error "PATCHMON_URL must be set"
fi
# Check if running on Proxmox
if ! command -v pct &> /dev/null; then
error "This script must run on a Proxmox host (pct command not found)"
fi
# Check for required commands
for cmd in curl jq; do
if ! command -v $cmd &> /dev/null; then
error "Required command '$cmd' not found. Please install it first."
fi
done
info "Configuration validated successfully"
info "PatchMon Server: $PATCHMON_URL"
info "Dry Run Mode: $DRY_RUN"
info "Skip Stopped Containers: $SKIP_STOPPED"
echo ""
# ===== DISCOVER LXC CONTAINERS =====
info "Discovering LXC containers..."
lxc_list=$(pct list | tail -n +2) # Skip header
if [[ -z "$lxc_list" ]]; then
warn "No LXC containers found on this Proxmox host"
exit 0
fi
# Count containers
total_containers=$(echo "$lxc_list" | wc -l)
info "Found $total_containers LXC container(s)"
echo ""
info "Initializing statistics..."
# ===== STATISTICS =====
enrolled_count=0
skipped_count=0
failed_count=0
# Track containers with dpkg errors for later recovery
declare -A dpkg_error_containers
info "Statistics initialized"
# ===== PROCESS CONTAINERS =====
info "Starting container processing loop..."
while IFS= read -r line; do
info "[DEBUG] Read line from lxc_list"
vmid=$(echo "$line" | awk '{print $1}')
status=$(echo "$line" | awk '{print $2}')
name=$(echo "$line" | awk '{print $3}')
info "Processing LXC $vmid: $name (status: $status)"
# Skip stopped containers if configured
if [[ "$status" != "running" ]] && [[ "$SKIP_STOPPED" == "true" ]]; then
warn " Skipping $name - container not running"
((skipped_count++)) || true
echo ""
continue
fi
# Check if container is stopped
if [[ "$status" != "running" ]]; then
warn " Container $name is stopped - cannot gather info or install agent"
((skipped_count++)) || true
echo ""
continue
fi
# Get container details
debug " Gathering container information..."
hostname=$(timeout 5 pct exec "$vmid" -- hostname 2>/dev/null </dev/null || echo "$name")
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")
friendly_name="${HOST_PREFIX}${hostname}"
info " Hostname: $hostname"
info " IP Address: $ip_address"
info " OS: $os_info"
if [[ "$DRY_RUN" == "true" ]]; then
info " [DRY RUN] Would enroll: $friendly_name"
((enrolled_count++)) || true
echo ""
continue
fi
# Call PatchMon auto-enrollment API
info " Enrolling $friendly_name in PatchMon..."
response=$(curl $CURL_FLAGS -X POST \
-H "X-Auto-Enrollment-Key: $AUTO_ENROLLMENT_KEY" \
-H "X-Auto-Enrollment-Secret: $AUTO_ENROLLMENT_SECRET" \
-H "Content-Type: application/json" \
-d "{
\"friendly_name\": \"$friendly_name\",
\"metadata\": {
\"vmid\": \"$vmid\",
\"proxmox_node\": \"$(hostname)\",
\"ip_address\": \"$ip_address\",
\"os_info\": \"$os_info\"
}
}" \
"$PATCHMON_URL/api/v1/auto-enrollment/enroll" \
-w "\n%{http_code}" 2>&1)
http_code=$(echo "$response" | tail -n 1)
body=$(echo "$response" | sed '$d')
if [[ "$http_code" == "201" ]]; then
api_id=$(echo "$body" | jq -r '.host.api_id' 2>/dev/null || echo "")
api_key=$(echo "$body" | jq -r '.host.api_key' 2>/dev/null || echo "")
if [[ -z "$api_id" ]] || [[ -z "$api_key" ]]; then
error " Failed to parse API credentials from response"
fi
info " ✓ Host enrolled successfully: $api_id"
# Install PatchMon agent in container
info " Installing PatchMon agent..."
# Reset exit code for this container
install_exit_code=0
# Download and execute in separate steps to avoid stdin issues with piping
install_output=$(timeout 180 pct exec "$vmid" -- bash -c "
cd /tmp
curl $CURL_FLAGS \
-H \"X-API-ID: $api_id\" \
-H \"X-API-KEY: $api_key\" \
-o patchmon-install.sh \
'$PATCHMON_URL/api/v1/hosts/install' && \
bash patchmon-install.sh && \
rm -f patchmon-install.sh
" 2>&1 </dev/null) || install_exit_code=$?
# Check both exit code AND success message in output for reliability
if [[ $install_exit_code -eq 0 ]] || [[ "$install_output" == *"PatchMon Agent installation completed successfully"* ]]; then
info " ✓ Agent installed successfully in $friendly_name"
((enrolled_count++)) || true
elif [[ $install_exit_code -eq 124 ]]; then
warn " ⏱ Agent installation timed out (>180s) in $friendly_name"
info " Install output: $install_output"
((failed_count++)) || true
else
# Check if it's a dpkg error
if [[ "$install_output" == *"dpkg was interrupted"* ]] || [[ "$install_output" == *"dpkg --configure -a"* ]]; then
warn " ⚠ Failed due to dpkg error in $friendly_name (can be fixed)"
dpkg_error_containers["$vmid"]="$friendly_name:$api_id:$api_key"
else
warn " ✗ Failed to install agent in $friendly_name (exit: $install_exit_code)"
fi
info " Install output: $install_output"
((failed_count++)) || true
fi
elif [[ "$http_code" == "409" ]]; then
warn " ⊘ Host $friendly_name already enrolled - skipping"
((skipped_count++)) || true
elif [[ "$http_code" == "429" ]]; then
error " ✗ Rate limit exceeded - maximum hosts per day reached"
((failed_count++)) || true
else
error " ✗ Failed to enroll $friendly_name - HTTP $http_code"
debug " Response: $body"
((failed_count++)) || true
fi
echo ""
sleep 1 # Rate limiting between containers
done <<< "$lxc_list"
# ===== SUMMARY =====
echo ""
echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ ENROLLMENT SUMMARY ║"
echo "╚═══════════════════════════════════════════════════════════════╝"
echo ""
info "Total Containers Found: $total_containers"
info "Successfully Enrolled: $enrolled_count"
info "Skipped: $skipped_count"
info "Failed: $failed_count"
echo ""
if [[ "$DRY_RUN" == "true" ]]; then
warn "This was a DRY RUN - no actual changes were made"
warn "Set DRY_RUN=false to perform actual enrollment"
fi
# ===== DPKG ERROR RECOVERY =====
if [[ ${#dpkg_error_containers[@]} -gt 0 ]]; then
echo ""
echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ DPKG ERROR RECOVERY AVAILABLE ║"
echo "╚═══════════════════════════════════════════════════════════════╝"
echo ""
warn "Detected ${#dpkg_error_containers[@]} container(s) with dpkg errors:"
for vmid in "${!dpkg_error_containers[@]}"; do
IFS=':' read -r name api_id api_key <<< "${dpkg_error_containers[$vmid]}"
info " • Container $vmid: $name"
done
echo ""
# Ask user if they want to fix dpkg errors
read -p "Would you like to fix dpkg errors and retry installation? (y/N): " -n 1 -r
echo ""
if [[ $REPLY =~ ^[Yy]$ ]]; then
echo ""
info "Starting dpkg recovery process..."
echo ""
recovered_count=0
for vmid in "${!dpkg_error_containers[@]}"; do
IFS=':' read -r name api_id api_key <<< "${dpkg_error_containers[$vmid]}"
info "Fixing dpkg in container $vmid ($name)..."
# Run dpkg --configure -a
dpkg_output=$(timeout 60 pct exec "$vmid" -- dpkg --configure -a 2>&1 </dev/null || true)
if [[ $? -eq 0 ]]; then
info " ✓ dpkg fixed successfully"
# Retry agent installation
info " Retrying agent installation..."
install_exit_code=0
install_output=$(timeout 180 pct exec "$vmid" -- bash -c "
cd /tmp
curl $CURL_FLAGS \
-H \"X-API-ID: $api_id\" \
-H \"X-API-KEY: $api_key\" \
-o patchmon-install.sh \
'$PATCHMON_URL/api/v1/hosts/install' && \
bash patchmon-install.sh && \
rm -f patchmon-install.sh
" 2>&1 </dev/null) || install_exit_code=$?
if [[ $install_exit_code -eq 0 ]] || [[ "$install_output" == *"PatchMon Agent installation completed successfully"* ]]; then
info " ✓ Agent installed successfully in $name"
((recovered_count++)) || true
((enrolled_count++)) || true
((failed_count--)) || true
else
warn " ✗ Agent installation still failed (exit: $install_exit_code)"
fi
else
warn " ✗ Failed to fix dpkg in $name"
info " dpkg output: $dpkg_output"
fi
echo ""
done
echo ""
info "Recovery complete: $recovered_count container(s) recovered"
echo ""
fi
fi
if [[ $failed_count -gt 0 ]]; then
warn "Some containers failed to enroll. Check the logs above for details."
exit 1
fi
info "Auto-enrollment complete! ✓"
exit 0

View File

@@ -9,9 +9,23 @@ NODE_ENV=development
API_VERSION=v1
CORS_ORIGIN=http://localhost:3000
# Rate Limiting
# Rate Limiting (times in milliseconds)
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX=100
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
# Logging
LOG_LEVEL=info
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

View File

@@ -1,36 +1,40 @@
{
"name": "patchmon-backend",
"version": "1.2.4",
"description": "Backend API for Linux Patch Monitoring System",
"main": "src/server.js",
"scripts": {
"dev": "nodemon src/server.js",
"start": "node src/server.js",
"build": "echo 'No build step needed for Node.js'",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^5.7.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"uuid": "^9.0.1",
"winston": "^3.11.0"
},
"devDependencies": {
"nodemon": "^3.0.2",
"prisma": "^5.7.0"
},
"engines": {
"node": ">=18.0.0"
}
"name": "patchmon-backend",
"version": "1.2.7",
"description": "Backend API for Linux Patch Monitoring System",
"license": "AGPL-3.0",
"main": "src/server.js",
"scripts": {
"dev": "nodemon src/server.js",
"start": "node src/server.js",
"build": "echo 'No build step needed for Node.js'",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^6.1.0",
"bcryptjs": "^2.4.3",
"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",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"qrcode": "^1.5.4",
"speakeasy": "^2.0.0",
"uuid": "^11.0.3",
"winston": "^3.17.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"nodemon": "^3.1.9",
"prisma": "^6.1.0"
},
"engines": {
"node": ">=18.0.0"
}
}

View File

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

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "tfa_backup_codes" TEXT,
ADD COLUMN "tfa_enabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "tfa_secret" TEXT;

View File

@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "settings" ADD COLUMN "last_update_check" TIMESTAMP(3),
ADD COLUMN "latest_version" TEXT,
ADD COLUMN "update_available" BOOLEAN NOT NULL DEFAULT false;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "settings" DROP COLUMN "frontend_url";

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "settings" ADD COLUMN "signup_enabled" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "first_name" TEXT,
ADD COLUMN "last_name" TEXT;

View File

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

View File

@@ -0,0 +1,65 @@
-- Initialize default dashboard preferences for all existing users
-- This migration ensures that all users have proper role-based dashboard preferences
-- Function to create default dashboard preferences for a user
CREATE OR REPLACE FUNCTION init_user_dashboard_preferences(user_id TEXT, user_role TEXT)
RETURNS VOID AS $$
DECLARE
pref_record RECORD;
BEGIN
-- Delete any existing preferences for this user
DELETE FROM dashboard_preferences WHERE dashboard_preferences.user_id = init_user_dashboard_preferences.user_id;
-- Insert role-based preferences
IF user_role = 'admin' THEN
-- Admin gets full access to all cards (iby's preferred layout)
INSERT INTO dashboard_preferences (id, user_id, card_id, enabled, "order", created_at, updated_at)
VALUES
(gen_random_uuid(), user_id, 'totalHosts', true, 0, NOW(), NOW()),
(gen_random_uuid(), user_id, 'hostsNeedingUpdates', true, 1, NOW(), NOW()),
(gen_random_uuid(), user_id, 'totalOutdatedPackages', true, 2, NOW(), NOW()),
(gen_random_uuid(), user_id, 'securityUpdates', true, 3, NOW(), NOW()),
(gen_random_uuid(), user_id, 'totalHostGroups', true, 4, NOW(), NOW()),
(gen_random_uuid(), user_id, 'upToDateHosts', true, 5, NOW(), NOW()),
(gen_random_uuid(), user_id, 'totalRepos', true, 6, NOW(), NOW()),
(gen_random_uuid(), user_id, 'totalUsers', true, 7, NOW(), NOW()),
(gen_random_uuid(), user_id, 'osDistribution', true, 8, NOW(), NOW()),
(gen_random_uuid(), user_id, 'osDistributionBar', true, 9, NOW(), NOW()),
(gen_random_uuid(), user_id, 'recentCollection', true, 10, NOW(), NOW()),
(gen_random_uuid(), user_id, 'updateStatus', true, 11, NOW(), NOW()),
(gen_random_uuid(), user_id, 'packagePriority', true, 12, NOW(), NOW()),
(gen_random_uuid(), user_id, 'recentUsers', true, 13, NOW(), NOW()),
(gen_random_uuid(), user_id, 'quickStats', true, 14, NOW(), NOW());
ELSE
-- Regular users get comprehensive layout but without user management cards
INSERT INTO dashboard_preferences (id, user_id, card_id, enabled, "order", created_at, updated_at)
VALUES
(gen_random_uuid(), user_id, 'totalHosts', true, 0, NOW(), NOW()),
(gen_random_uuid(), user_id, 'hostsNeedingUpdates', true, 1, NOW(), NOW()),
(gen_random_uuid(), user_id, 'totalOutdatedPackages', true, 2, NOW(), NOW()),
(gen_random_uuid(), user_id, 'securityUpdates', true, 3, NOW(), NOW()),
(gen_random_uuid(), user_id, 'totalHostGroups', true, 4, NOW(), NOW()),
(gen_random_uuid(), user_id, 'upToDateHosts', true, 5, NOW(), NOW()),
(gen_random_uuid(), user_id, 'totalRepos', true, 6, NOW(), NOW()),
(gen_random_uuid(), user_id, 'osDistribution', true, 7, NOW(), NOW()),
(gen_random_uuid(), user_id, 'osDistributionBar', true, 8, NOW(), NOW()),
(gen_random_uuid(), user_id, 'recentCollection', true, 9, NOW(), NOW()),
(gen_random_uuid(), user_id, 'updateStatus', true, 10, NOW(), NOW()),
(gen_random_uuid(), user_id, 'packagePriority', true, 11, NOW(), NOW()),
(gen_random_uuid(), user_id, 'quickStats', true, 12, NOW(), NOW());
END IF;
END;
$$ LANGUAGE plpgsql;
-- Apply default preferences to all existing users
DO $$
DECLARE
user_record RECORD;
BEGIN
FOR user_record IN SELECT id, role FROM users LOOP
PERFORM init_user_dashboard_preferences(user_record.id, user_record.role);
END LOOP;
END $$;
-- Drop the temporary function
DROP FUNCTION init_user_dashboard_preferences(TEXT, TEXT);

View File

@@ -0,0 +1,12 @@
-- Remove dashboard preferences population
-- This migration clears all existing dashboard preferences so they can be recreated
-- with the correct default order by server.js initialization
-- Clear all existing dashboard preferences
-- This ensures users get the correct default order from server.js
DELETE FROM dashboard_preferences;
-- Recreate indexes for better performance
CREATE INDEX IF NOT EXISTS "dashboard_preferences_user_id_idx" ON "dashboard_preferences"("user_id");
CREATE INDEX IF NOT EXISTS "dashboard_preferences_card_id_idx" ON "dashboard_preferences"("card_id");
CREATE INDEX IF NOT EXISTS "dashboard_preferences_user_card_idx" ON "dashboard_preferences"("user_id", "card_id");

View File

@@ -0,0 +1,10 @@
-- Fix dashboard preferences unique constraint
-- This migration fixes the unique constraint on dashboard_preferences table
-- Drop existing indexes if they exist
DROP INDEX IF EXISTS "dashboard_preferences_card_id_key";
DROP INDEX IF EXISTS "dashboard_preferences_user_id_card_id_key";
DROP INDEX IF EXISTS "dashboard_preferences_user_id_key";
-- Add the correct unique constraint
ALTER TABLE "dashboard_preferences" ADD CONSTRAINT "dashboard_preferences_user_id_card_id_key" UNIQUE ("user_id", "card_id");

View File

@@ -0,0 +1,2 @@
-- DropTable
DROP TABLE "agent_versions";

View File

@@ -0,0 +1,4 @@
-- Add ignore_ssl_self_signed column to settings table
-- This allows users to configure whether curl commands should ignore SSL certificate validation
ALTER TABLE "settings" ADD COLUMN "ignore_ssl_self_signed" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "hosts" ADD COLUMN "notes" TEXT;

View File

@@ -0,0 +1,37 @@
-- CreateTable
CREATE TABLE "auto_enrollment_tokens" (
"id" TEXT NOT NULL,
"token_name" TEXT NOT NULL,
"token_key" TEXT NOT NULL,
"token_secret" TEXT NOT NULL,
"created_by_user_id" TEXT,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"allowed_ip_ranges" TEXT[],
"max_hosts_per_day" INTEGER NOT NULL DEFAULT 100,
"hosts_created_today" INTEGER NOT NULL DEFAULT 0,
"last_reset_date" DATE NOT NULL DEFAULT CURRENT_TIMESTAMP,
"default_host_group_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"last_used_at" TIMESTAMP(3),
"expires_at" TIMESTAMP(3),
"metadata" JSONB,
CONSTRAINT "auto_enrollment_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "auto_enrollment_tokens_token_key_key" ON "auto_enrollment_tokens"("token_key");
-- CreateIndex
CREATE INDEX "auto_enrollment_tokens_token_key_idx" ON "auto_enrollment_tokens"("token_key");
-- CreateIndex
CREATE INDEX "auto_enrollment_tokens_is_active_idx" ON "auto_enrollment_tokens"("is_active");
-- AddForeignKey
ALTER TABLE "auto_enrollment_tokens" ADD CONSTRAINT "auto_enrollment_tokens_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "auto_enrollment_tokens" ADD CONSTRAINT "auto_enrollment_tokens_default_host_group_id_fkey" FOREIGN KEY ("default_host_group_id") REFERENCES "host_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,31 @@
-- 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

@@ -1,6 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
@@ -10,210 +7,229 @@ datasource db {
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
username String @unique
email String @unique
passwordHash String @map("password_hash")
role String @default("admin") // admin, user
isActive Boolean @default(true) @map("is_active")
lastLogin DateTime? @map("last_login")
createdAt DateTime @map("created_at") @default(now())
updatedAt DateTime @map("updated_at") @updatedAt
// Relationships
dashboardPreferences DashboardPreferences[]
@@map("users")
model dashboard_preferences {
id String @id
user_id String
card_id String
enabled Boolean @default(true)
order Int @default(0)
created_at DateTime @default(now())
updated_at DateTime
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@unique([user_id, card_id])
}
model RolePermissions {
id String @id @default(cuid())
role String @unique // admin, user, custom roles
canViewDashboard Boolean @default(true) @map("can_view_dashboard")
canViewHosts Boolean @default(true) @map("can_view_hosts")
canManageHosts Boolean @default(false) @map("can_manage_hosts")
canViewPackages Boolean @default(true) @map("can_view_packages")
canManagePackages Boolean @default(false) @map("can_manage_packages")
canViewUsers Boolean @default(false) @map("can_view_users")
canManageUsers Boolean @default(false) @map("can_manage_users")
canViewReports Boolean @default(true) @map("can_view_reports")
canExportData Boolean @default(false) @map("can_export_data")
canManageSettings Boolean @default(false) @map("can_manage_settings")
createdAt DateTime @map("created_at") @default(now())
updatedAt DateTime @map("updated_at") @updatedAt
@@map("role_permissions")
model host_groups {
id String @id
name String @unique
description String?
color String? @default("#3B82F6")
created_at DateTime @default(now())
updated_at DateTime
hosts hosts[]
auto_enrollment_tokens auto_enrollment_tokens[]
}
model HostGroup {
id String @id @default(cuid())
name String @unique
description String?
color String? @default("#3B82F6") // Hex color for UI display
createdAt DateTime @map("created_at") @default(now())
updatedAt DateTime @map("updated_at") @updatedAt
// Relationships
hosts Host[]
@@map("host_groups")
model host_packages {
id String @id
host_id String
package_id String
current_version String
available_version String?
needs_update Boolean @default(false)
is_security_update Boolean @default(false)
last_checked DateTime @default(now())
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
packages packages @relation(fields: [package_id], references: [id], onDelete: Cascade)
@@unique([host_id, package_id])
}
model Host {
id String @id @default(cuid())
hostname String @unique
ip String?
osType String @map("os_type")
osVersion String @map("os_version")
architecture String?
lastUpdate DateTime @map("last_update") @default(now())
status String @default("active") // active, inactive, error
apiId String @unique @map("api_id") // New API ID for authentication
apiKey String @unique @map("api_key") // New API Key for authentication
hostGroupId String? @map("host_group_id") // Optional group association
agentVersion String? @map("agent_version") // Agent script version
autoUpdate Boolean @map("auto_update") @default(true) // Enable auto-update for this host
createdAt DateTime @map("created_at") @default(now())
updatedAt DateTime @map("updated_at") @updatedAt
// Relationships
hostPackages HostPackage[]
updateHistory UpdateHistory[]
hostRepositories HostRepository[]
hostGroup HostGroup? @relation(fields: [hostGroupId], references: [id], onDelete: SetNull)
@@map("hosts")
model host_repositories {
id String @id
host_id String
repository_id String
is_enabled Boolean @default(true)
last_checked DateTime @default(now())
hosts hosts @relation(fields: [host_id], references: [id], onDelete: Cascade)
repositories repositories @relation(fields: [repository_id], references: [id], onDelete: Cascade)
@@unique([host_id, repository_id])
}
model Package {
id String @id @default(cuid())
name String @unique
description String?
category String? // system, security, development, etc.
latestVersion String? @map("latest_version")
createdAt DateTime @map("created_at") @default(now())
updatedAt DateTime @map("updated_at") @updatedAt
// Relationships
hostPackages HostPackage[]
@@map("packages")
model hosts {
id String @id
friendly_name String @unique
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[]
}
model HostPackage {
id String @id @default(cuid())
hostId String @map("host_id")
packageId String @map("package_id")
currentVersion String @map("current_version")
availableVersion String? @map("available_version")
needsUpdate Boolean @map("needs_update") @default(false)
isSecurityUpdate Boolean @map("is_security_update") @default(false)
lastChecked DateTime @map("last_checked") @default(now())
// Relationships
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
package Package @relation(fields: [packageId], references: [id], onDelete: Cascade)
@@unique([hostId, packageId])
@@map("host_packages")
model packages {
id String @id
name String @unique
description String?
category String?
latest_version String?
created_at DateTime @default(now())
updated_at DateTime
host_packages host_packages[]
}
model UpdateHistory {
id String @id @default(cuid())
hostId String @map("host_id")
packagesCount Int @map("packages_count")
securityCount Int @map("security_count")
timestamp DateTime @default(now())
status String @default("success") // success, error
errorMessage String? @map("error_message")
// Relationships
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
@@map("update_history")
}
model repositories {
id String @id
name String
url String
distribution String
components String
repo_type String
is_active Boolean @default(true)
is_secure Boolean @default(true)
priority Int?
description String?
created_at DateTime @default(now())
updated_at DateTime
host_repositories host_repositories[]
model Repository {
id String @id @default(cuid())
name String // Repository name (e.g., "focal", "focal-updates")
url String // Repository URL
distribution String // Distribution (e.g., "focal", "jammy")
components String // Components (e.g., "main restricted universe multiverse")
repoType String @map("repo_type") // "deb" or "deb-src"
isActive Boolean @map("is_active") @default(true)
isSecure Boolean @map("is_secure") @default(true) // HTTPS vs HTTP
priority Int? // Repository priority
description String? // Optional description
createdAt DateTime @map("created_at") @default(now())
updatedAt DateTime @map("updated_at") @updatedAt
// Relationships
hostRepositories HostRepository[]
@@unique([url, distribution, components])
@@map("repositories")
}
model HostRepository {
id String @id @default(cuid())
hostId String @map("host_id")
repositoryId String @map("repository_id")
isEnabled Boolean @map("is_enabled") @default(true)
lastChecked DateTime @map("last_checked") @default(now())
// Relationships
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade)
@@unique([hostId, repositoryId])
@@map("host_repositories")
model role_permissions {
id String @id
role String @unique
can_view_dashboard Boolean @default(true)
can_view_hosts Boolean @default(true)
can_manage_hosts Boolean @default(false)
can_view_packages Boolean @default(true)
can_manage_packages Boolean @default(false)
can_view_users Boolean @default(false)
can_manage_users Boolean @default(false)
can_view_reports Boolean @default(true)
can_export_data Boolean @default(false)
can_manage_settings Boolean @default(false)
created_at DateTime @default(now())
updated_at DateTime
}
model Settings {
id String @id @default(cuid())
serverUrl String @map("server_url") @default("http://localhost:3001")
serverProtocol String @map("server_protocol") @default("http") // http, https
serverHost String @map("server_host") @default("localhost")
serverPort Int @map("server_port") @default(3001)
frontendUrl String @map("frontend_url") @default("http://localhost:3000")
updateInterval Int @map("update_interval") @default(60) // Update interval in minutes
autoUpdate Boolean @map("auto_update") @default(false) // Enable automatic agent updates
githubRepoUrl String @map("github_repo_url") @default("git@github.com:9technologygroup/patchmon.net.git") // GitHub repository URL for version checking
sshKeyPath String? @map("ssh_key_path") // Optional SSH key path for deploy key authentication
createdAt DateTime @map("created_at") @default(now())
updatedAt DateTime @map("updated_at") @updatedAt
@@map("settings")
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)
}
model DashboardPreferences {
id String @id @default(cuid())
userId String @map("user_id")
cardId String @map("card_id") // e.g., "totalHosts", "securityUpdates", etc.
enabled Boolean @default(true)
order Int @default(0)
createdAt DateTime @map("created_at") @default(now())
updatedAt DateTime @map("updated_at") @updatedAt
// Relationships
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, cardId])
@@map("dashboard_preferences")
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)
}
model AgentVersion {
id String @id @default(cuid())
version String @unique // e.g., "1.0.0", "1.1.0"
isCurrent Boolean @default(false) @map("is_current") // Only one version can be current
releaseNotes String? @map("release_notes")
downloadUrl String? @map("download_url") // URL to download the agent script
minServerVersion String? @map("min_server_version") // Minimum server version required
scriptContent String? @map("script_content") // The actual agent script content
isDefault Boolean @default(false) @map("is_default") // Default version for new installations
createdAt DateTime @map("created_at") @default(now())
updatedAt DateTime @map("updated_at") @updatedAt
@@map("agent_versions")
}
model users {
id String @id
username String @unique
email String @unique
password_hash String
role String @default("admin")
is_active Boolean @default(true)
last_login DateTime?
created_at DateTime @default(now())
updated_at DateTime
tfa_backup_codes String?
tfa_enabled Boolean @default(false)
tfa_secret String?
first_name String?
last_name String?
dashboard_preferences dashboard_preferences[]
user_sessions user_sessions[]
auto_enrollment_tokens auto_enrollment_tokens[]
}
model user_sessions {
id String @id
user_id String
refresh_token String @unique
access_token_hash String?
ip_address String?
user_agent String?
last_activity DateTime @default(now())
expires_at DateTime
created_at DateTime @default(now())
is_revoked Boolean @default(false)
users users @relation(fields: [user_id], references: [id], onDelete: Cascade)
@@index([user_id])
@@index([refresh_token])
@@index([expires_at])
}
model auto_enrollment_tokens {
id String @id
token_name String
token_key String @unique
token_secret String
created_by_user_id String?
is_active Boolean @default(true)
allowed_ip_ranges String[]
max_hosts_per_day Int @default(100)
hosts_created_today Int @default(0)
last_reset_date DateTime @default(now()) @db.Date
default_host_group_id String?
created_at DateTime @default(now())
updated_at DateTime
last_used_at DateTime?
expires_at DateTime?
metadata Json?
users users? @relation(fields: [created_by_user_id], references: [id], onDelete: SetNull)
host_groups host_groups? @relation(fields: [default_host_group_id], references: [id], onDelete: SetNull)
@@index([token_key])
@@index([is_active])
}

View File

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

View File

@@ -1,98 +1,121 @@
const jwt = require('jsonwebtoken');
const { PrismaClient } = require('@prisma/client');
const jwt = require("jsonwebtoken");
const { PrismaClient } = require("@prisma/client");
const {
validate_session,
update_session_activity,
} = require("../utils/session_manager");
const prisma = new PrismaClient();
// Middleware to verify JWT token
// Middleware to verify JWT token with session validation
const authenticateToken = async (req, res, next) => {
try {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
try {
const authHeader = req.headers.authorization;
const token = authHeader?.split(" ")[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
if (!token) {
return res.status(401).json({ error: "Access token required" });
}
// Verify token
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
// Get user from database
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: {
id: true,
username: true,
email: true,
role: true,
isActive: true,
lastLogin: true
}
});
// Verify token
const decoded = jwt.verify(
token,
process.env.JWT_SECRET || "your-secret-key",
);
if (!user || !user.isActive) {
return res.status(401).json({ error: 'Invalid or inactive user' });
}
// Validate session and check inactivity timeout
const validation = await validate_session(decoded.sessionId, token);
// Update last login
await prisma.user.update({
where: { id: user.id },
data: { lastLogin: new Date() }
});
if (!validation.valid) {
const error_messages = {
"Session not found": "Session not found",
"Session revoked": "Session has been revoked",
"Session expired": "Session has expired",
"Session inactive":
validation.message || "Session timed out due to inactivity",
"Token mismatch": "Invalid token",
"User inactive": "User account is inactive",
};
req.user = user;
next();
} catch (error) {
if (error.name === 'JsonWebTokenError') {
return res.status(401).json({ error: 'Invalid token' });
}
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
console.error('Auth middleware error:', error);
return res.status(500).json({ error: 'Authentication failed' });
}
return res.status(401).json({
error: error_messages[validation.reason] || "Authentication failed",
reason: validation.reason,
});
}
// Update session activity timestamp
await update_session_activity(decoded.sessionId);
// Update last login (only on successful authentication)
await prisma.users.update({
where: { id: validation.user.id },
data: {
last_login: new Date(),
updated_at: new Date(),
},
});
req.user = validation.user;
req.session_id = decoded.sessionId;
next();
} catch (error) {
if (error.name === "JsonWebTokenError") {
return res.status(401).json({ error: "Invalid token" });
}
if (error.name === "TokenExpiredError") {
return res.status(401).json({ error: "Token expired" });
}
console.error("Auth middleware error:", error);
return res.status(500).json({ error: "Authentication failed" });
}
};
// Middleware to check admin role
const requireAdmin = (req, res, next) => {
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Admin access required' });
}
next();
if (req.user.role !== "admin") {
return res.status(403).json({ error: "Admin access required" });
}
next();
};
// Middleware to check if user is authenticated (optional)
const optionalAuth = async (req, res, next) => {
try {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1];
const optionalAuth = async (req, _res, next) => {
try {
const authHeader = req.headers.authorization;
const token = authHeader?.split(" ")[1];
if (token) {
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: {
id: true,
username: true,
email: true,
role: true,
isActive: true
}
});
if (token) {
const decoded = jwt.verify(
token,
process.env.JWT_SECRET || "your-secret-key",
);
const user = await prisma.users.findUnique({
where: { id: decoded.userId },
select: {
id: true,
username: true,
email: true,
role: true,
is_active: true,
last_login: true,
created_at: true,
updated_at: true,
},
});
if (user && user.isActive) {
req.user = user;
}
}
next();
} catch (error) {
// Continue without authentication for optional auth
next();
}
if (user?.is_active) {
req.user = user;
}
}
next();
} catch {
// Continue without authentication for optional auth
next();
}
};
module.exports = {
authenticateToken,
requireAdmin,
optionalAuth
authenticateToken,
requireAdmin,
optionalAuth,
};

View File

@@ -1,59 +1,61 @@
const { PrismaClient } = require('@prisma/client');
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
// Permission middleware factory
const requirePermission = (permission) => {
return async (req, res, next) => {
try {
// Get user's role permissions
const rolePermissions = await prisma.rolePermissions.findUnique({
where: { role: req.user.role }
});
return async (req, res, next) => {
try {
// Get user's role permissions
const rolePermissions = await prisma.role_permissions.findUnique({
where: { role: req.user.role },
});
// If no specific permissions found, default to admin permissions (for backward compatibility)
if (!rolePermissions) {
console.warn(`No permissions found for role: ${req.user.role}, defaulting to admin access`);
return next();
}
// If no specific permissions found, default to admin permissions (for backward compatibility)
if (!rolePermissions) {
console.warn(
`No permissions found for role: ${req.user.role}, defaulting to admin access`,
);
return next();
}
// Check if user has the required permission
if (!rolePermissions[permission]) {
return res.status(403).json({
error: 'Insufficient permissions',
message: `You don't have permission to ${permission.replace('can', '').toLowerCase()}`
});
}
// Check if user has the required permission
if (!rolePermissions[permission]) {
return res.status(403).json({
error: "Insufficient permissions",
message: `You don't have permission to ${permission.replace("can_", "").replace("_", " ")}`,
});
}
next();
} catch (error) {
console.error('Permission check error:', error);
res.status(500).json({ error: 'Permission check failed' });
}
};
next();
} catch (error) {
console.error("Permission check error:", error);
res.status(500).json({ error: "Permission check failed" });
}
};
};
// Specific permission middlewares
const requireViewDashboard = requirePermission('canViewDashboard');
const requireViewHosts = requirePermission('canViewHosts');
const requireManageHosts = requirePermission('canManageHosts');
const requireViewPackages = requirePermission('canViewPackages');
const requireManagePackages = requirePermission('canManagePackages');
const requireViewUsers = requirePermission('canViewUsers');
const requireManageUsers = requirePermission('canManageUsers');
const requireViewReports = requirePermission('canViewReports');
const requireExportData = requirePermission('canExportData');
const requireManageSettings = requirePermission('canManageSettings');
// Specific permission middlewares - using snake_case field names
const requireViewDashboard = requirePermission("can_view_dashboard");
const requireViewHosts = requirePermission("can_view_hosts");
const requireManageHosts = requirePermission("can_manage_hosts");
const requireViewPackages = requirePermission("can_view_packages");
const requireManagePackages = requirePermission("can_manage_packages");
const requireViewUsers = requirePermission("can_view_users");
const requireManageUsers = requirePermission("can_manage_users");
const requireViewReports = requirePermission("can_view_reports");
const requireExportData = requirePermission("can_export_data");
const requireManageSettings = requirePermission("can_manage_settings");
module.exports = {
requirePermission,
requireViewDashboard,
requireViewHosts,
requireManageHosts,
requireViewPackages,
requireManagePackages,
requireViewUsers,
requireManageUsers,
requireViewReports,
requireExportData,
requireManageSettings
requirePermission,
requireViewDashboard,
requireViewHosts,
requireManageHosts,
requireViewPackages,
requireManagePackages,
requireViewUsers,
requireManageUsers,
requireViewReports,
requireExportData,
requireManageSettings,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,724 @@
const express = require("express");
const { PrismaClient } = require("@prisma/client");
const crypto = require("node:crypto");
const bcrypt = require("bcryptjs");
const { body, validationResult } = require("express-validator");
const { authenticateToken } = require("../middleware/auth");
const { requireManageSettings } = require("../middleware/permissions");
const { v4: uuidv4 } = require("uuid");
const router = express.Router();
const prisma = new PrismaClient();
// Generate auto-enrollment token credentials
const generate_auto_enrollment_token = () => {
const token_key = `patchmon_ae_${crypto.randomBytes(16).toString("hex")}`;
const token_secret = crypto.randomBytes(48).toString("hex");
return { token_key, token_secret };
};
// Middleware to validate auto-enrollment token
const validate_auto_enrollment_token = async (req, res, next) => {
try {
const token_key = req.headers["x-auto-enrollment-key"];
const token_secret = req.headers["x-auto-enrollment-secret"];
if (!token_key || !token_secret) {
return res
.status(401)
.json({ error: "Auto-enrollment credentials required" });
}
// Find token
const token = await prisma.auto_enrollment_tokens.findUnique({
where: { token_key: token_key },
});
if (!token || !token.is_active) {
return res.status(401).json({ error: "Invalid or inactive token" });
}
// Verify secret (hashed)
const is_valid = await bcrypt.compare(token_secret, token.token_secret);
if (!is_valid) {
return res.status(401).json({ error: "Invalid token secret" });
}
// Check expiration
if (token.expires_at && new Date() > new Date(token.expires_at)) {
return res.status(401).json({ error: "Token expired" });
}
// Check IP whitelist if configured
if (token.allowed_ip_ranges && token.allowed_ip_ranges.length > 0) {
const client_ip = req.ip || req.connection.remoteAddress;
// Basic IP check - can be enhanced with CIDR matching
const ip_allowed = token.allowed_ip_ranges.some((allowed_ip) => {
return client_ip.includes(allowed_ip);
});
if (!ip_allowed) {
console.warn(
`Auto-enrollment attempt from unauthorized IP: ${client_ip}`,
);
return res
.status(403)
.json({ error: "IP address not authorized for this token" });
}
}
// Check rate limit (hosts per day)
const today = new Date().toISOString().split("T")[0];
const token_reset_date = token.last_reset_date.toISOString().split("T")[0];
if (token_reset_date !== today) {
// Reset daily counter
await prisma.auto_enrollment_tokens.update({
where: { id: token.id },
data: {
hosts_created_today: 0,
last_reset_date: new Date(),
updated_at: new Date(),
},
});
token.hosts_created_today = 0;
}
if (token.hosts_created_today >= token.max_hosts_per_day) {
return res.status(429).json({
error: "Rate limit exceeded",
message: `Maximum ${token.max_hosts_per_day} hosts per day allowed for this token`,
});
}
req.auto_enrollment_token = token;
next();
} catch (error) {
console.error("Auto-enrollment token validation error:", error);
res.status(500).json({ error: "Token validation failed" });
}
};
// ========== ADMIN ENDPOINTS (Manage Tokens) ==========
// Create auto-enrollment token
router.post(
"/tokens",
authenticateToken,
requireManageSettings,
[
body("token_name")
.isLength({ min: 1, max: 255 })
.withMessage("Token name is required (max 255 characters)"),
body("allowed_ip_ranges")
.optional()
.isArray()
.withMessage("Allowed IP ranges must be an array"),
body("max_hosts_per_day")
.optional()
.isInt({ min: 1, max: 1000 })
.withMessage("Max hosts per day must be between 1 and 1000"),
body("default_host_group_id")
.optional({ nullable: true, checkFalsy: true })
.isString(),
body("expires_at")
.optional({ nullable: true, checkFalsy: true })
.isISO8601()
.withMessage("Invalid date format"),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const {
token_name,
allowed_ip_ranges = [],
max_hosts_per_day = 100,
default_host_group_id,
expires_at,
metadata = {},
} = req.body;
// Validate host group if provided
if (default_host_group_id) {
const host_group = await prisma.host_groups.findUnique({
where: { id: default_host_group_id },
});
if (!host_group) {
return res.status(400).json({ error: "Host group not found" });
}
}
const { token_key, token_secret } = generate_auto_enrollment_token();
const hashed_secret = await bcrypt.hash(token_secret, 10);
const token = await prisma.auto_enrollment_tokens.create({
data: {
id: uuidv4(),
token_name,
token_key: token_key,
token_secret: hashed_secret,
created_by_user_id: req.user.id,
allowed_ip_ranges,
max_hosts_per_day,
default_host_group_id: default_host_group_id || null,
expires_at: expires_at ? new Date(expires_at) : null,
metadata: { integration_type: "proxmox-lxc", ...metadata },
updated_at: new Date(),
},
include: {
host_groups: {
select: {
id: true,
name: true,
color: true,
},
},
users: {
select: {
id: true,
username: true,
first_name: true,
last_name: true,
},
},
},
});
// Return unhashed secret ONLY once (like API keys)
res.status(201).json({
message: "Auto-enrollment token created successfully",
token: {
id: token.id,
token_name: token.token_name,
token_key: token_key,
token_secret: token_secret, // ONLY returned here!
max_hosts_per_day: token.max_hosts_per_day,
default_host_group: token.host_groups,
created_by: token.users,
expires_at: token.expires_at,
},
warning: "⚠️ Save the token_secret now - it cannot be retrieved later!",
});
} catch (error) {
console.error("Create auto-enrollment token error:", error);
res.status(500).json({ error: "Failed to create token" });
}
},
);
// List auto-enrollment tokens
router.get(
"/tokens",
authenticateToken,
requireManageSettings,
async (_req, res) => {
try {
const tokens = await prisma.auto_enrollment_tokens.findMany({
select: {
id: true,
token_name: true,
token_key: true,
is_active: true,
allowed_ip_ranges: true,
max_hosts_per_day: true,
hosts_created_today: true,
last_used_at: true,
expires_at: true,
created_at: true,
default_host_group_id: true,
metadata: true,
host_groups: {
select: {
id: true,
name: true,
color: true,
},
},
users: {
select: {
id: true,
username: true,
first_name: true,
last_name: true,
},
},
},
orderBy: { created_at: "desc" },
});
res.json(tokens);
} catch (error) {
console.error("List auto-enrollment tokens error:", error);
res.status(500).json({ error: "Failed to list tokens" });
}
},
);
// Get single token details
router.get(
"/tokens/:tokenId",
authenticateToken,
requireManageSettings,
async (req, res) => {
try {
const { tokenId } = req.params;
const token = await prisma.auto_enrollment_tokens.findUnique({
where: { id: tokenId },
include: {
host_groups: {
select: {
id: true,
name: true,
color: true,
},
},
users: {
select: {
id: true,
username: true,
first_name: true,
last_name: true,
},
},
},
});
if (!token) {
return res.status(404).json({ error: "Token not found" });
}
// Don't include the secret in response
const { token_secret: _secret, ...token_data } = token;
res.json(token_data);
} catch (error) {
console.error("Get token error:", error);
res.status(500).json({ error: "Failed to get token" });
}
},
);
// Update token (toggle active state, update limits, etc.)
router.patch(
"/tokens/:tokenId",
authenticateToken,
requireManageSettings,
[
body("is_active").optional().isBoolean(),
body("max_hosts_per_day").optional().isInt({ min: 1, max: 1000 }),
body("allowed_ip_ranges").optional().isArray(),
body("expires_at").optional().isISO8601(),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { tokenId } = req.params;
const update_data = { updated_at: new Date() };
if (req.body.is_active !== undefined)
update_data.is_active = req.body.is_active;
if (req.body.max_hosts_per_day !== undefined)
update_data.max_hosts_per_day = req.body.max_hosts_per_day;
if (req.body.allowed_ip_ranges !== undefined)
update_data.allowed_ip_ranges = req.body.allowed_ip_ranges;
if (req.body.expires_at !== undefined)
update_data.expires_at = new Date(req.body.expires_at);
const token = await prisma.auto_enrollment_tokens.update({
where: { id: tokenId },
data: update_data,
include: {
host_groups: true,
users: {
select: {
username: true,
first_name: true,
last_name: true,
},
},
},
});
const { token_secret: _secret, ...token_data } = token;
res.json({
message: "Token updated successfully",
token: token_data,
});
} catch (error) {
console.error("Update token error:", error);
res.status(500).json({ error: "Failed to update token" });
}
},
);
// Delete token
router.delete(
"/tokens/:tokenId",
authenticateToken,
requireManageSettings,
async (req, res) => {
try {
const { tokenId } = req.params;
const token = await prisma.auto_enrollment_tokens.findUnique({
where: { id: tokenId },
});
if (!token) {
return res.status(404).json({ error: "Token not found" });
}
await prisma.auto_enrollment_tokens.delete({
where: { id: tokenId },
});
res.json({
message: "Auto-enrollment token deleted successfully",
deleted_token: {
id: token.id,
token_name: token.token_name,
},
});
} catch (error) {
console.error("Delete token error:", error);
res.status(500).json({ error: "Failed to delete token" });
}
},
);
// ========== AUTO-ENROLLMENT ENDPOINTS (Used by Scripts) ==========
// Future integrations can follow this pattern:
// - /proxmox-lxc - Proxmox LXC containers
// - /vmware-esxi - VMware ESXi VMs
// - /docker - Docker containers
// - /kubernetes - Kubernetes pods
// - /aws-ec2 - AWS EC2 instances
// Serve the Proxmox LXC enrollment script with credentials injected
router.get("/proxmox-lxc", async (req, res) => {
try {
// Get token from query params
const token_key = req.query.token_key;
const token_secret = req.query.token_secret;
if (!token_key || !token_secret) {
return res
.status(401)
.json({ error: "Token key and secret required as query parameters" });
}
// Validate token
const token = await prisma.auto_enrollment_tokens.findUnique({
where: { token_key: token_key },
});
if (!token || !token.is_active) {
return res.status(401).json({ error: "Invalid or inactive token" });
}
// Verify secret
const is_valid = await bcrypt.compare(token_secret, token.token_secret);
if (!is_valid) {
return res.status(401).json({ error: "Invalid token secret" });
}
// Check expiration
if (token.expires_at && new Date() > new Date(token.expires_at)) {
return res.status(401).json({ error: "Token expired" });
}
const fs = require("node:fs");
const path = require("node:path");
const script_path = path.join(
__dirname,
"../../../agents/proxmox_auto_enroll.sh",
);
if (!fs.existsSync(script_path)) {
return res
.status(404)
.json({ error: "Proxmox enrollment script not found" });
}
let script = fs.readFileSync(script_path, "utf8");
// Convert Windows line endings to Unix line endings
script = script.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Get the configured server URL from settings
let server_url = "http://localhost:3001";
try {
const settings = await prisma.settings.findFirst();
if (settings?.server_url) {
server_url = settings.server_url;
}
} catch (settings_error) {
console.warn(
"Could not fetch settings, using default server URL:",
settings_error.message,
);
}
// Determine curl flags dynamically from settings
let curl_flags = "-s";
try {
const settings = await prisma.settings.findFirst();
if (settings && settings.ignore_ssl_self_signed === true) {
curl_flags = "-sk";
}
} catch (_) {}
// Inject the token credentials, server URL, and curl flags into the script
const env_vars = `#!/bin/bash
# PatchMon Auto-Enrollment Configuration (Auto-generated)
export PATCHMON_URL="${server_url}"
export AUTO_ENROLLMENT_KEY="${token.token_key}"
export AUTO_ENROLLMENT_SECRET="${token_secret}"
export CURL_FLAGS="${curl_flags}"
`;
// Remove the shebang and configuration section from the original script
script = script.replace(/^#!/, "#");
// Remove the configuration section (between # ===== CONFIGURATION ===== and the next # =====)
script = script.replace(
/# ===== CONFIGURATION =====[\s\S]*?(?=# ===== COLOR OUTPUT =====)/,
"",
);
script = env_vars + script;
res.setHeader("Content-Type", "text/plain");
res.setHeader(
"Content-Disposition",
'inline; filename="proxmox_auto_enroll.sh"',
);
res.send(script);
} catch (error) {
console.error("Proxmox script serve error:", error);
res.status(500).json({ error: "Failed to serve enrollment script" });
}
});
// Create host via auto-enrollment
router.post(
"/enroll",
validate_auto_enrollment_token,
[
body("friendly_name")
.isLength({ min: 1, max: 255 })
.withMessage("Friendly name is required"),
body("metadata").optional().isObject(),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { friendly_name } = req.body;
// Generate host API credentials
const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`;
const api_key = crypto.randomBytes(32).toString("hex");
// Check if host already exists
const existing_host = await prisma.hosts.findUnique({
where: { friendly_name },
});
if (existing_host) {
return res.status(409).json({
error: "Host already exists",
host_id: existing_host.id,
api_id: existing_host.api_id,
message: "This host is already enrolled in PatchMon",
});
}
// Create host
const host = await prisma.hosts.create({
data: {
id: uuidv4(),
friendly_name,
os_type: "unknown",
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,
},
},
},
});
// Update token usage stats
await prisma.auto_enrollment_tokens.update({
where: { id: req.auto_enrollment_token.id },
data: {
hosts_created_today: { increment: 1 },
last_used_at: new Date(),
updated_at: new Date(),
},
});
console.log(
`Auto-enrolled host: ${friendly_name} (${host.id}) via token: ${req.auto_enrollment_token.token_name}`,
);
res.status(201).json({
message: "Host enrolled successfully",
host: {
id: host.id,
friendly_name: host.friendly_name,
api_id: api_id,
api_key: api_key,
host_group: host.host_groups,
status: host.status,
},
});
} catch (error) {
console.error("Auto-enrollment error:", error);
res.status(500).json({ error: "Failed to enroll host" });
}
},
);
// Bulk enroll multiple hosts at once
router.post(
"/enroll/bulk",
validate_auto_enrollment_token,
[
body("hosts")
.isArray({ min: 1, max: 50 })
.withMessage("Hosts array required (max 50)"),
body("hosts.*.friendly_name")
.isLength({ min: 1 })
.withMessage("Each host needs a friendly_name"),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { hosts } = req.body;
// Check rate limit
const remaining_quota =
req.auto_enrollment_token.max_hosts_per_day -
req.auto_enrollment_token.hosts_created_today;
if (hosts.length > remaining_quota) {
return res.status(429).json({
error: "Rate limit exceeded",
message: `Only ${remaining_quota} hosts remaining in daily quota`,
});
}
const results = {
success: [],
failed: [],
skipped: [],
};
for (const host_data of hosts) {
try {
const { friendly_name } = host_data;
// Check if host already exists
const existing_host = await prisma.hosts.findUnique({
where: { friendly_name },
});
if (existing_host) {
results.skipped.push({
friendly_name,
reason: "Already exists",
api_id: existing_host.api_id,
});
continue;
}
// Generate credentials
const api_id = `patchmon_${crypto.randomBytes(8).toString("hex")}`;
const api_key = crypto.randomBytes(32).toString("hex");
// Create host
const host = await prisma.hosts.create({
data: {
id: uuidv4(),
friendly_name,
os_type: "unknown",
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(),
},
});
results.success.push({
id: host.id,
friendly_name: host.friendly_name,
api_id: api_id,
api_key: api_key,
});
} catch (error) {
results.failed.push({
friendly_name: host_data.friendly_name,
error: error.message,
});
}
}
// Update token usage stats
if (results.success.length > 0) {
await prisma.auto_enrollment_tokens.update({
where: { id: req.auto_enrollment_token.id },
data: {
hosts_created_today: { increment: results.success.length },
last_used_at: new Date(),
updated_at: new Date(),
},
});
}
res.status(201).json({
message: `Bulk enrollment completed: ${results.success.length} succeeded, ${results.failed.length} failed, ${results.skipped.length} skipped`,
results,
});
} catch (error) {
console.error("Bulk auto-enrollment error:", error);
res.status(500).json({ error: "Failed to bulk enroll hosts" });
}
},
);
module.exports = router;

View File

@@ -1,89 +1,367 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { PrismaClient } = require('@prisma/client');
const { authenticateToken } = require('../middleware/auth');
const express = require("express");
const { body, validationResult } = require("express-validator");
const { PrismaClient } = require("@prisma/client");
const { authenticateToken } = require("../middleware/auth");
const { v4: uuidv4 } = require("uuid");
const router = express.Router();
const prisma = new PrismaClient();
// Helper function to get user permissions based on role
async function getUserPermissions(userRole) {
try {
const permissions = await prisma.role_permissions.findUnique({
where: { role: userRole },
});
// If no specific permissions found, return default admin permissions (for backward compatibility)
if (!permissions) {
console.warn(
`No permissions found for role: ${userRole}, defaulting to admin access`,
);
return {
can_view_dashboard: true,
can_view_hosts: true,
can_manage_hosts: true,
can_view_packages: true,
can_manage_packages: true,
can_view_users: true,
can_manage_users: true,
can_view_reports: true,
can_export_data: true,
can_manage_settings: true,
};
}
return permissions;
} catch (error) {
console.error("Error fetching user permissions:", error);
// Return admin permissions as fallback
return {
can_view_dashboard: true,
can_view_hosts: true,
can_manage_hosts: true,
can_view_packages: true,
can_manage_packages: true,
can_view_users: true,
can_manage_users: true,
can_view_reports: true,
can_export_data: true,
can_manage_settings: true,
};
}
}
// Helper function to create permission-based dashboard preferences for a new user
async function createDefaultDashboardPreferences(userId, userRole = "user") {
try {
// Get user's actual permissions
const permissions = await getUserPermissions(userRole);
// Define all possible dashboard cards with their required permissions
// Order aligned with preferred layout
const allCards = [
// Host-related cards
{ cardId: "totalHosts", requiredPermission: "can_view_hosts", order: 0 },
{
cardId: "hostsNeedingUpdates",
requiredPermission: "can_view_hosts",
order: 1,
},
// Package-related cards
{
cardId: "totalOutdatedPackages",
requiredPermission: "can_view_packages",
order: 2,
},
{
cardId: "securityUpdates",
requiredPermission: "can_view_packages",
order: 3,
},
// Host-related cards (continued)
{
cardId: "totalHostGroups",
requiredPermission: "can_view_hosts",
order: 4,
},
{
cardId: "upToDateHosts",
requiredPermission: "can_view_hosts",
order: 5,
},
// Repository-related cards
{ cardId: "totalRepos", requiredPermission: "can_view_hosts", order: 6 },
// User management cards (admin only)
{ cardId: "totalUsers", requiredPermission: "can_view_users", order: 7 },
// System/Report cards
{
cardId: "osDistribution",
requiredPermission: "can_view_reports",
order: 8,
},
{
cardId: "osDistributionBar",
requiredPermission: "can_view_reports",
order: 9,
},
{
cardId: "osDistributionDoughnut",
requiredPermission: "can_view_reports",
order: 10,
},
{
cardId: "recentCollection",
requiredPermission: "can_view_hosts",
order: 11,
},
{
cardId: "updateStatus",
requiredPermission: "can_view_reports",
order: 12,
},
{
cardId: "packagePriority",
requiredPermission: "can_view_packages",
order: 13,
},
{
cardId: "recentUsers",
requiredPermission: "can_view_users",
order: 14,
},
{
cardId: "quickStats",
requiredPermission: "can_view_dashboard",
order: 15,
},
];
// Filter cards based on user's permissions
const allowedCards = allCards.filter((card) => {
return permissions[card.requiredPermission] === true;
});
// Create preferences data
const preferencesData = allowedCards.map((card) => ({
id: uuidv4(),
user_id: userId,
card_id: card.cardId,
enabled: true,
order: card.order, // Preserve original order from allCards
created_at: new Date(),
updated_at: new Date(),
}));
await prisma.dashboard_preferences.createMany({
data: preferencesData,
});
console.log(
`Permission-based dashboard preferences created for user ${userId} with role ${userRole}: ${allowedCards.length} cards`,
);
} catch (error) {
console.error("Error creating default dashboard preferences:", error);
// Don't throw error - this shouldn't break user creation
}
}
// Get user's dashboard preferences
router.get('/', authenticateToken, async (req, res) => {
try {
const preferences = await prisma.dashboardPreferences.findMany({
where: { userId: req.user.id },
orderBy: { order: 'asc' }
});
res.json(preferences);
} catch (error) {
console.error('Dashboard preferences fetch error:', error);
res.status(500).json({ error: 'Failed to fetch dashboard preferences' });
}
router.get("/", authenticateToken, async (req, res) => {
try {
const preferences = await prisma.dashboard_preferences.findMany({
where: { user_id: req.user.id },
orderBy: { order: "asc" },
});
res.json(preferences);
} catch (error) {
console.error("Dashboard preferences fetch error:", error);
res.status(500).json({ error: "Failed to fetch dashboard preferences" });
}
});
// Update dashboard preferences (bulk update)
router.put('/', authenticateToken, [
body('preferences').isArray().withMessage('Preferences must be an array'),
body('preferences.*.cardId').isString().withMessage('Card ID is required'),
body('preferences.*.enabled').isBoolean().withMessage('Enabled must be boolean'),
body('preferences.*.order').isInt().withMessage('Order must be integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
router.put(
"/",
authenticateToken,
[
body("preferences").isArray().withMessage("Preferences must be an array"),
body("preferences.*.cardId").isString().withMessage("Card ID is required"),
body("preferences.*.enabled")
.isBoolean()
.withMessage("Enabled must be boolean"),
body("preferences.*.order").isInt().withMessage("Order must be integer"),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { preferences } = req.body;
const userId = req.user.id;
const { preferences } = req.body;
const userId = req.user.id;
// Delete existing preferences for this user
await prisma.dashboardPreferences.deleteMany({
where: { userId }
});
// Delete existing preferences for this user
await prisma.dashboard_preferences.deleteMany({
where: { user_id: userId },
});
// Create new preferences
const newPreferences = preferences.map(pref => ({
userId,
cardId: pref.cardId,
enabled: pref.enabled,
order: pref.order
}));
// Create new preferences
const newPreferences = preferences.map((pref) => ({
id: require("uuid").v4(),
user_id: userId,
card_id: pref.cardId,
enabled: pref.enabled,
order: pref.order,
updated_at: new Date(),
}));
const createdPreferences = await prisma.dashboardPreferences.createMany({
data: newPreferences
});
await prisma.dashboard_preferences.createMany({
data: newPreferences,
});
res.json({
message: 'Dashboard preferences updated successfully',
preferences: newPreferences
});
} catch (error) {
console.error('Dashboard preferences update error:', error);
res.status(500).json({ error: 'Failed to update dashboard preferences' });
}
});
res.json({
message: "Dashboard preferences updated successfully",
preferences: newPreferences,
});
} catch (error) {
console.error("Dashboard preferences update error:", error);
res.status(500).json({ error: "Failed to update dashboard preferences" });
}
},
);
// Get default dashboard card configuration
router.get('/defaults', authenticateToken, async (req, res) => {
try {
const defaultCards = [
{ cardId: 'totalHosts', title: 'Total Hosts', icon: 'Server', enabled: true, order: 0 },
{ cardId: 'hostsNeedingUpdates', title: 'Needs Updating', icon: 'AlertTriangle', enabled: true, order: 1 },
{ cardId: 'totalOutdatedPackages', title: 'Outdated Packages', icon: 'Package', enabled: true, order: 2 },
{ cardId: 'securityUpdates', title: 'Security Updates', icon: 'Shield', enabled: true, order: 3 },
{ cardId: 'erroredHosts', title: 'Errored Hosts', icon: 'AlertTriangle', enabled: true, order: 4 },
{ cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 5 },
{ cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 6 },
{ cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 7 },
{ cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 8 }
];
router.get("/defaults", authenticateToken, async (_req, res) => {
try {
// This provides a comprehensive dashboard view for all new users
const defaultCards = [
{
cardId: "totalHosts",
title: "Total Hosts",
icon: "Server",
enabled: true,
order: 0,
},
{
cardId: "hostsNeedingUpdates",
title: "Needs Updating",
icon: "AlertTriangle",
enabled: true,
order: 1,
},
{
cardId: "totalOutdatedPackages",
title: "Outdated Packages",
icon: "Package",
enabled: true,
order: 2,
},
{
cardId: "securityUpdates",
title: "Security Updates",
icon: "Shield",
enabled: true,
order: 3,
},
{
cardId: "totalHostGroups",
title: "Host Groups",
icon: "Folder",
enabled: true,
order: 4,
},
{
cardId: "upToDateHosts",
title: "Up to date",
icon: "CheckCircle",
enabled: true,
order: 5,
},
{
cardId: "totalRepos",
title: "Repositories",
icon: "GitBranch",
enabled: true,
order: 6,
},
{
cardId: "totalUsers",
title: "Users",
icon: "Users",
enabled: true,
order: 7,
},
{
cardId: "osDistribution",
title: "OS Distribution",
icon: "BarChart3",
enabled: true,
order: 8,
},
{
cardId: "osDistributionBar",
title: "OS Distribution (Bar)",
icon: "BarChart3",
enabled: true,
order: 9,
},
{
cardId: "osDistributionDoughnut",
title: "OS Distribution (Doughnut)",
icon: "PieChart",
enabled: true,
order: 10,
},
{
cardId: "recentCollection",
title: "Recent Collection",
icon: "Server",
enabled: true,
order: 11,
},
{
cardId: "updateStatus",
title: "Update Status",
icon: "BarChart3",
enabled: true,
order: 12,
},
{
cardId: "packagePriority",
title: "Package Priority",
icon: "BarChart3",
enabled: true,
order: 13,
},
{
cardId: "recentUsers",
title: "Recent Users Logged in",
icon: "Users",
enabled: true,
order: 14,
},
{
cardId: "quickStats",
title: "Quick Stats",
icon: "TrendingUp",
enabled: true,
order: 15,
},
];
res.json(defaultCards);
} catch (error) {
console.error('Default dashboard cards error:', error);
res.status(500).json({ error: 'Failed to fetch default dashboard cards' });
}
res.json(defaultCards);
} catch (error) {
console.error("Default dashboard cards error:", error);
res.status(500).json({ error: "Failed to fetch default dashboard cards" });
}
});
module.exports = router;
module.exports = { router, createDefaultDashboardPreferences };

View File

@@ -1,336 +1,458 @@
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const moment = require('moment');
const { authenticateToken } = require('../middleware/auth');
const {
requireViewDashboard,
requireViewHosts,
requireViewPackages
} = require('../middleware/permissions');
const express = require("express");
const { PrismaClient } = require("@prisma/client");
const moment = require("moment");
const { authenticateToken } = require("../middleware/auth");
const {
requireViewDashboard,
requireViewHosts,
requireViewPackages,
requireViewUsers,
} = require("../middleware/permissions");
const router = express.Router();
const prisma = new PrismaClient();
// Get dashboard statistics
router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) => {
try {
const now = new Date();
// Get the agent update interval setting
const settings = await prisma.settings.findFirst();
const updateIntervalMinutes = settings?.updateInterval || 60; // Default to 60 minutes if no setting
// Calculate the threshold based on the actual update interval
// Use 2x the update interval as the threshold for "errored" hosts
const thresholdMinutes = updateIntervalMinutes * 2;
const thresholdTime = moment(now).subtract(thresholdMinutes, 'minutes').toDate();
router.get(
"/stats",
authenticateToken,
requireViewDashboard,
async (_req, res) => {
try {
const now = new Date();
// Get all statistics in parallel for better performance
const [
totalHosts,
hostsNeedingUpdates,
totalOutdatedPackages,
erroredHosts,
securityUpdates,
osDistribution,
updateTrends
] = await Promise.all([
// Total hosts count
prisma.host.count({
where: { status: 'active' }
}),
// Get the agent update interval setting
const settings = await prisma.settings.findFirst();
const updateIntervalMinutes = settings?.update_interval || 60; // Default to 60 minutes if no setting
// Hosts needing updates (distinct hosts with packages needing updates)
prisma.host.count({
where: {
status: 'active',
hostPackages: {
some: {
needsUpdate: true
}
}
}
}),
// Calculate the threshold based on the actual update interval
// Use 2x the update interval as the threshold for "errored" hosts
const thresholdMinutes = updateIntervalMinutes * 2;
const thresholdTime = moment(now)
.subtract(thresholdMinutes, "minutes")
.toDate();
// Total outdated packages across all hosts
prisma.hostPackage.count({
where: { needsUpdate: true }
}),
// Get all statistics in parallel for better performance
const [
totalHosts,
hostsNeedingUpdates,
totalOutdatedPackages,
erroredHosts,
securityUpdates,
offlineHosts,
totalHostGroups,
totalUsers,
totalRepos,
osDistribution,
updateTrends,
] = await Promise.all([
// Total hosts count (all hosts regardless of status)
prisma.hosts.count(),
// Errored hosts (not updated within threshold based on update interval)
prisma.host.count({
where: {
status: 'active',
lastUpdate: {
lt: thresholdTime
}
}
}),
// Hosts needing updates (distinct hosts with packages needing updates)
prisma.hosts.count({
where: {
host_packages: {
some: {
needs_update: true,
},
},
},
}),
// Security updates count
prisma.hostPackage.count({
where: {
needsUpdate: true,
isSecurityUpdate: true
}
}),
// Total outdated packages across all hosts
prisma.host_packages.count({
where: { needs_update: true },
}),
// OS distribution for pie chart
prisma.host.groupBy({
by: ['osType'],
where: { status: 'active' },
_count: {
osType: true
}
}),
// Errored hosts (not updated within threshold based on update interval)
prisma.hosts.count({
where: {
status: "active",
last_update: {
lt: thresholdTime,
},
},
}),
// Update trends for the last 7 days
prisma.updateHistory.groupBy({
by: ['timestamp'],
where: {
timestamp: {
gte: moment(now).subtract(7, 'days').toDate()
}
},
_count: {
id: true
},
_sum: {
packagesCount: true,
securityCount: true
}
})
]);
// Security updates count
prisma.host_packages.count({
where: {
needs_update: true,
is_security_update: true,
},
}),
// Format OS distribution for pie chart
const osDistributionFormatted = osDistribution.map(item => ({
name: item.osType,
count: item._count.osType
}));
// Offline/Stale hosts (not updated within 3x the update interval)
prisma.hosts.count({
where: {
status: "active",
last_update: {
lt: moment(now)
.subtract(updateIntervalMinutes * 3, "minutes")
.toDate(),
},
},
}),
// Calculate update status distribution
const updateStatusDistribution = [
{ name: 'Up to date', count: totalHosts - hostsNeedingUpdates },
{ name: 'Needs updates', count: hostsNeedingUpdates },
{ name: 'Errored', count: erroredHosts }
];
// Total host groups count
prisma.host_groups.count(),
// Package update priority distribution
const packageUpdateDistribution = [
{ name: 'Security', count: securityUpdates },
{ name: 'Regular', count: totalOutdatedPackages - securityUpdates }
];
// Total users count
prisma.users.count(),
res.json({
cards: {
totalHosts,
hostsNeedingUpdates,
totalOutdatedPackages,
erroredHosts,
securityUpdates
},
charts: {
osDistribution: osDistributionFormatted,
updateStatusDistribution,
packageUpdateDistribution
},
trends: updateTrends,
lastUpdated: now.toISOString()
});
} catch (error) {
console.error('Error fetching dashboard stats:', error);
res.status(500).json({ error: 'Failed to fetch dashboard statistics' });
}
});
// Total repositories count
prisma.repositories.count(),
// OS distribution for pie chart
prisma.hosts.groupBy({
by: ["os_type"],
where: { status: "active" },
_count: {
os_type: true,
},
}),
// Update trends for the last 7 days
prisma.update_history.groupBy({
by: ["timestamp"],
where: {
timestamp: {
gte: moment(now).subtract(7, "days").toDate(),
},
},
_count: {
id: true,
},
_sum: {
packages_count: true,
security_count: true,
},
}),
]);
// Format OS distribution for pie chart
const osDistributionFormatted = osDistribution.map((item) => ({
name: item.os_type,
count: item._count.os_type,
}));
// Calculate update status distribution
const updateStatusDistribution = [
{ name: "Up to date", count: totalHosts - hostsNeedingUpdates },
{ name: "Needs updates", count: hostsNeedingUpdates },
{ name: "Errored", count: erroredHosts },
];
// Package update priority distribution
const packageUpdateDistribution = [
{ name: "Security", count: securityUpdates },
{ name: "Regular", count: totalOutdatedPackages - securityUpdates },
];
res.json({
cards: {
totalHosts,
hostsNeedingUpdates,
upToDateHosts: Math.max(totalHosts - hostsNeedingUpdates, 0),
totalOutdatedPackages,
erroredHosts,
securityUpdates,
offlineHosts,
totalHostGroups,
totalUsers,
totalRepos,
},
charts: {
osDistribution: osDistributionFormatted,
updateStatusDistribution,
packageUpdateDistribution,
},
trends: updateTrends,
lastUpdated: now.toISOString(),
});
} catch (error) {
console.error("Error fetching dashboard stats:", error);
res.status(500).json({ error: "Failed to fetch dashboard statistics" });
}
},
);
// Get hosts with their update status
router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
try {
const hosts = await prisma.host.findMany({
// Show all hosts regardless of status
select: {
id: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
lastUpdate: true,
status: true,
agentVersion: true,
autoUpdate: true,
hostGroup: {
select: {
id: true,
name: true,
color: true
}
},
_count: {
select: {
hostPackages: {
where: {
needsUpdate: true
}
}
}
}
},
orderBy: { lastUpdate: 'desc' }
});
router.get("/hosts", authenticateToken, requireViewHosts, async (_req, res) => {
try {
const hosts = await prisma.hosts.findMany({
// Show all hosts regardless of status
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
os_type: true,
os_version: true,
last_update: true,
status: true,
agent_version: true,
auto_update: true,
notes: true,
host_groups: {
select: {
id: true,
name: true,
color: true,
},
},
_count: {
select: {
host_packages: {
where: {
needs_update: true,
},
},
},
},
},
orderBy: { last_update: "desc" },
});
// Get update counts for each host separately
const hostsWithUpdateInfo = await Promise.all(
hosts.map(async (host) => {
const updatesCount = await prisma.hostPackage.count({
where: {
hostId: host.id,
needsUpdate: true
}
});
// Get update counts for each host separately
const hostsWithUpdateInfo = await Promise.all(
hosts.map(async (host) => {
const updatesCount = await prisma.host_packages.count({
where: {
host_id: host.id,
needs_update: true,
},
});
// Get the agent update interval setting for stale calculation
const settings = await prisma.settings.findFirst();
const updateIntervalMinutes = settings?.updateInterval || 60;
const thresholdMinutes = updateIntervalMinutes * 2;
// Get total packages count for this host
const totalPackagesCount = await prisma.host_packages.count({
where: {
host_id: host.id,
},
});
// Calculate effective status based on reporting interval
const isStale = moment(host.lastUpdate).isBefore(moment().subtract(thresholdMinutes, 'minutes'));
let effectiveStatus = host.status;
// Override status if host hasn't reported within threshold
if (isStale && host.status === 'active') {
effectiveStatus = 'inactive';
}
// Get the agent update interval setting for stale calculation
const settings = await prisma.settings.findFirst();
const updateIntervalMinutes = settings?.update_interval || 60;
const thresholdMinutes = updateIntervalMinutes * 2;
return {
...host,
updatesCount,
isStale,
effectiveStatus
};
})
);
// Calculate effective status based on reporting interval
const isStale = moment(host.last_update).isBefore(
moment().subtract(thresholdMinutes, "minutes"),
);
let effectiveStatus = host.status;
res.json(hostsWithUpdateInfo);
} catch (error) {
console.error('Error fetching hosts:', error);
res.status(500).json({ error: 'Failed to fetch hosts' });
}
// Override status if host hasn't reported within threshold
if (isStale && host.status === "active") {
effectiveStatus = "inactive";
}
return {
...host,
updatesCount,
totalPackagesCount,
isStale,
effectiveStatus,
};
}),
);
res.json(hostsWithUpdateInfo);
} catch (error) {
console.error("Error fetching hosts:", error);
res.status(500).json({ error: "Failed to fetch hosts" });
}
});
// Get packages that need updates across all hosts
router.get('/packages', authenticateToken, requireViewPackages, async (req, res) => {
try {
const packages = await prisma.package.findMany({
where: {
hostPackages: {
some: {
needsUpdate: true
}
}
},
select: {
id: true,
name: true,
description: true,
category: true,
latestVersion: true,
hostPackages: {
where: { needsUpdate: true },
select: {
currentVersion: true,
availableVersion: true,
isSecurityUpdate: true,
host: {
select: {
id: true,
hostname: true,
osType: true
}
}
}
}
},
orderBy: {
name: 'asc'
}
});
router.get(
"/packages",
authenticateToken,
requireViewPackages,
async (_req, res) => {
try {
const packages = await prisma.packages.findMany({
where: {
host_packages: {
some: {
needs_update: true,
},
},
},
select: {
id: true,
name: true,
description: true,
category: true,
latest_version: true,
host_packages: {
where: { needs_update: true },
select: {
current_version: true,
available_version: true,
is_security_update: true,
hosts: {
select: {
id: true,
friendly_name: true,
os_type: true,
},
},
},
},
},
orderBy: {
name: "asc",
},
});
const packagesWithHostInfo = packages.map(pkg => ({
id: pkg.id,
name: pkg.name,
description: pkg.description,
category: pkg.category,
latestVersion: pkg.latestVersion,
affectedHostsCount: pkg.hostPackages.length,
isSecurityUpdate: pkg.hostPackages.some(hp => hp.isSecurityUpdate),
affectedHosts: pkg.hostPackages.map(hp => ({
hostId: hp.host.id,
hostname: hp.host.hostname,
osType: hp.host.osType,
currentVersion: hp.currentVersion,
availableVersion: hp.availableVersion,
isSecurityUpdate: hp.isSecurityUpdate
}))
}));
const packagesWithHostInfo = packages.map((pkg) => ({
id: pkg.id,
name: pkg.name,
description: pkg.description,
category: pkg.category,
latestVersion: pkg.latest_version,
affectedHostsCount: pkg.host_packages.length,
isSecurityUpdate: pkg.host_packages.some((hp) => hp.is_security_update),
affectedHosts: pkg.host_packages.map((hp) => ({
hostId: hp.hosts.id,
friendlyName: hp.hosts.friendly_name,
osType: hp.hosts.os_type,
currentVersion: hp.current_version,
availableVersion: hp.available_version,
isSecurityUpdate: hp.is_security_update,
})),
}));
res.json(packagesWithHostInfo);
} catch (error) {
console.error('Error fetching packages:', error);
res.status(500).json({ error: 'Failed to fetch packages' });
}
});
res.json(packagesWithHostInfo);
} catch (error) {
console.error("Error fetching packages:", error);
res.status(500).json({ error: "Failed to fetch packages" });
}
},
);
// Get detailed host information
router.get('/hosts/:hostId', authenticateToken, requireViewHosts, async (req, res) => {
try {
const { hostId } = req.params;
const host = await prisma.host.findUnique({
where: { id: hostId },
include: {
hostGroup: {
select: {
id: true,
name: true,
color: true
}
},
hostPackages: {
include: {
package: true
},
orderBy: {
needsUpdate: 'desc'
}
},
updateHistory: {
orderBy: {
timestamp: 'desc'
},
take: 10
}
}
});
router.get(
"/hosts/:hostId",
authenticateToken,
requireViewHosts,
async (req, res) => {
try {
const { hostId } = req.params;
if (!host) {
return res.status(404).json({ error: 'Host not found' });
}
const host = await prisma.hosts.findUnique({
where: { id: hostId },
include: {
host_groups: {
select: {
id: true,
name: true,
color: true,
},
},
host_packages: {
include: {
packages: true,
},
orderBy: {
needs_update: "desc",
},
},
update_history: {
orderBy: {
timestamp: "desc",
},
take: 10,
},
},
});
const hostWithStats = {
...host,
stats: {
totalPackages: host.hostPackages.length,
outdatedPackages: host.hostPackages.filter(hp => hp.needsUpdate).length,
securityUpdates: host.hostPackages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length
}
};
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
res.json(hostWithStats);
} catch (error) {
console.error('Error fetching host details:', error);
res.status(500).json({ error: 'Failed to fetch host details' });
}
});
const hostWithStats = {
...host,
stats: {
total_packages: host.host_packages.length,
outdated_packages: host.host_packages.filter((hp) => hp.needs_update)
.length,
security_updates: host.host_packages.filter(
(hp) => hp.needs_update && hp.is_security_update,
).length,
},
};
module.exports = router;
res.json(hostWithStats);
} catch (error) {
console.error("Error fetching host details:", error);
res.status(500).json({ error: "Failed to fetch host details" });
}
},
);
// Get recent users ordered by last_login desc
router.get(
"/recent-users",
authenticateToken,
requireViewUsers,
async (_req, res) => {
try {
const users = await prisma.users.findMany({
where: {
last_login: {
not: null,
},
},
select: {
id: true,
username: true,
email: true,
role: true,
last_login: true,
created_at: true,
},
orderBy: [{ last_login: "desc" }, { created_at: "desc" }],
take: 5,
});
res.json(users);
} catch (error) {
console.error("Error fetching recent users:", error);
res.status(500).json({ error: "Failed to fetch recent users" });
}
},
);
// Get recent hosts that have sent data (ordered by last_update desc)
router.get(
"/recent-collection",
authenticateToken,
requireViewHosts,
async (_req, res) => {
try {
const hosts = await prisma.hosts.findMany({
select: {
id: true,
friendly_name: true,
hostname: true,
last_update: true,
status: true,
},
orderBy: {
last_update: "desc",
},
take: 5,
});
res.json(hosts);
} catch (error) {
console.error("Error fetching recent collection:", error);
res.status(500).json({ error: "Failed to fetch recent collection" });
}
},
);
module.exports = router;

View File

@@ -1,225 +1,258 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { PrismaClient } = require('@prisma/client');
const { authenticateToken } = require('../middleware/auth');
const { requireManageHosts } = require('../middleware/permissions');
const express = require("express");
const { body, validationResult } = require("express-validator");
const { PrismaClient } = require("@prisma/client");
const { randomUUID } = require("node:crypto");
const { authenticateToken } = require("../middleware/auth");
const { requireManageHosts } = require("../middleware/permissions");
const router = express.Router();
const prisma = new PrismaClient();
// Get all host groups
router.get('/', authenticateToken, async (req, res) => {
try {
const hostGroups = await prisma.hostGroup.findMany({
include: {
_count: {
select: {
hosts: true
}
}
},
orderBy: {
name: 'asc'
}
});
router.get("/", authenticateToken, async (_req, res) => {
try {
const hostGroups = await prisma.host_groups.findMany({
include: {
_count: {
select: {
hosts: true,
},
},
},
orderBy: {
name: "asc",
},
});
res.json(hostGroups);
} catch (error) {
console.error('Error fetching host groups:', error);
res.status(500).json({ error: 'Failed to fetch host groups' });
}
res.json(hostGroups);
} catch (error) {
console.error("Error fetching host groups:", error);
res.status(500).json({ error: "Failed to fetch host groups" });
}
});
// Get a specific host group by ID
router.get('/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
router.get("/:id", authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const hostGroup = await prisma.hostGroup.findUnique({
where: { id },
include: {
hosts: {
select: {
id: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
status: true,
lastUpdate: true
}
}
}
});
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,
},
},
},
});
if (!hostGroup) {
return res.status(404).json({ error: 'Host group not found' });
}
if (!hostGroup) {
return res.status(404).json({ error: "Host group not found" });
}
res.json(hostGroup);
} catch (error) {
console.error('Error fetching host group:', error);
res.status(500).json({ error: 'Failed to fetch host group' });
}
res.json(hostGroup);
} catch (error) {
console.error("Error fetching host group:", error);
res.status(500).json({ error: "Failed to fetch host group" });
}
});
// Create a new host group
router.post('/', authenticateToken, requireManageHosts, [
body('name').trim().isLength({ min: 1 }).withMessage('Name is required'),
body('description').optional().trim(),
body('color').optional().isHexColor().withMessage('Color must be a valid hex color')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
router.post(
"/",
authenticateToken,
requireManageHosts,
[
body("name").trim().isLength({ min: 1 }).withMessage("Name is required"),
body("description").optional().trim(),
body("color")
.optional()
.isHexColor()
.withMessage("Color must be a valid hex color"),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { name, description, color } = req.body;
const { name, description, color } = req.body;
// Check if host group with this name already exists
const existingGroup = await prisma.hostGroup.findUnique({
where: { name }
});
// Check if host group with this name already exists
const existingGroup = await prisma.host_groups.findUnique({
where: { name },
});
if (existingGroup) {
return res.status(400).json({ error: 'A host group with this name already exists' });
}
if (existingGroup) {
return res
.status(400)
.json({ error: "A host group with this name already exists" });
}
const hostGroup = await prisma.hostGroup.create({
data: {
name,
description: description || null,
color: color || '#3B82F6'
}
});
const hostGroup = await prisma.host_groups.create({
data: {
id: randomUUID(),
name,
description: description || null,
color: color || "#3B82F6",
updated_at: new Date(),
},
});
res.status(201).json(hostGroup);
} catch (error) {
console.error('Error creating host group:', error);
res.status(500).json({ error: 'Failed to create host group' });
}
});
res.status(201).json(hostGroup);
} catch (error) {
console.error("Error creating host group:", error);
res.status(500).json({ error: "Failed to create host group" });
}
},
);
// Update a host group
router.put('/:id', authenticateToken, requireManageHosts, [
body('name').trim().isLength({ min: 1 }).withMessage('Name is required'),
body('description').optional().trim(),
body('color').optional().isHexColor().withMessage('Color must be a valid hex color')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
router.put(
"/:id",
authenticateToken,
requireManageHosts,
[
body("name").trim().isLength({ min: 1 }).withMessage("Name is required"),
body("description").optional().trim(),
body("color")
.optional()
.isHexColor()
.withMessage("Color must be a valid hex color"),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { name, description, color } = req.body;
const { id } = req.params;
const { name, description, color } = req.body;
// Check if host group exists
const existingGroup = await prisma.hostGroup.findUnique({
where: { id }
});
// Check if host group exists
const existingGroup = await prisma.host_groups.findUnique({
where: { id },
});
if (!existingGroup) {
return res.status(404).json({ error: 'Host group not found' });
}
if (!existingGroup) {
return res.status(404).json({ error: "Host group not found" });
}
// Check if another host group with this name already exists
const duplicateGroup = await prisma.hostGroup.findFirst({
where: {
name,
id: { not: id }
}
});
// Check if another host group with this name already exists
const duplicateGroup = await prisma.host_groups.findFirst({
where: {
name,
id: { not: id },
},
});
if (duplicateGroup) {
return res.status(400).json({ error: 'A host group with this name already exists' });
}
if (duplicateGroup) {
return res
.status(400)
.json({ error: "A host group with this name already exists" });
}
const hostGroup = await prisma.hostGroup.update({
where: { id },
data: {
name,
description: description || null,
color: color || '#3B82F6'
}
});
const hostGroup = await prisma.host_groups.update({
where: { id },
data: {
name,
description: description || null,
color: color || "#3B82F6",
updated_at: new Date(),
},
});
res.json(hostGroup);
} catch (error) {
console.error('Error updating host group:', error);
res.status(500).json({ error: 'Failed to update host group' });
}
});
res.json(hostGroup);
} catch (error) {
console.error("Error updating host group:", error);
res.status(500).json({ error: "Failed to update host group" });
}
},
);
// Delete a host group
router.delete('/:id', authenticateToken, requireManageHosts, async (req, res) => {
try {
const { id } = req.params;
router.delete(
"/:id",
authenticateToken,
requireManageHosts,
async (req, res) => {
try {
const { id } = req.params;
// Check if host group exists
const existingGroup = await prisma.hostGroup.findUnique({
where: { id },
include: {
_count: {
select: {
hosts: true
}
}
}
});
// Check if host group exists
const existingGroup = await prisma.host_groups.findUnique({
where: { id },
include: {
_count: {
select: {
hosts: true,
},
},
},
});
if (!existingGroup) {
return res.status(404).json({ error: 'Host group not found' });
}
if (!existingGroup) {
return res.status(404).json({ error: "Host group not found" });
}
// Check if host group has hosts
if (existingGroup._count.hosts > 0) {
return res.status(400).json({
error: 'Cannot delete host group that contains hosts. Please move or remove hosts first.'
});
}
// If host group has hosts, ungroup them first
if (existingGroup._count.hosts > 0) {
await prisma.hosts.updateMany({
where: { host_group_id: id },
data: { host_group_id: null },
});
}
await prisma.hostGroup.delete({
where: { id }
});
await prisma.host_groups.delete({
where: { id },
});
res.json({ message: 'Host group deleted successfully' });
} catch (error) {
console.error('Error deleting host group:', error);
res.status(500).json({ error: 'Failed to delete host group' });
}
});
res.json({ message: "Host group deleted successfully" });
} catch (error) {
console.error("Error deleting host group:", error);
res.status(500).json({ error: "Failed to delete host group" });
}
},
);
// Get hosts in a specific group
router.get('/:id/hosts', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
router.get("/:id/hosts", authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const hosts = await prisma.host.findMany({
where: { hostGroupId: id },
select: {
id: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
architecture: true,
status: true,
lastUpdate: true,
createdAt: true
},
orderBy: {
hostname: 'asc'
}
});
const hosts = await prisma.hosts.findMany({
where: { host_group_id: id },
select: {
id: true,
friendly_name: true,
ip: true,
os_type: true,
os_version: true,
architecture: true,
status: true,
last_update: true,
created_at: true,
},
orderBy: {
friendly_name: "asc",
},
});
res.json(hosts);
} catch (error) {
console.error('Error fetching hosts in group:', error);
res.status(500).json({ error: 'Failed to fetch hosts in group' });
}
res.json(hosts);
} catch (error) {
console.error("Error fetching hosts in group:", error);
res.status(500).json({ error: "Failed to fetch hosts in group" });
}
});
module.exports = router;

File diff suppressed because it is too large Load Diff

View File

@@ -1,213 +1,233 @@
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { body, validationResult } = require('express-validator');
const express = require("express");
const { PrismaClient } = require("@prisma/client");
const router = express.Router();
const prisma = new PrismaClient();
// Get all packages with their update status
router.get('/', async (req, res) => {
try {
const {
page = 1,
limit = 50,
search = '',
category = '',
needsUpdate = '',
isSecurityUpdate = ''
} = req.query;
router.get("/", async (req, res) => {
try {
const {
page = 1,
limit = 50,
search = "",
category = "",
needsUpdate = "",
isSecurityUpdate = "",
} = req.query;
const skip = (parseInt(page) - 1) * parseInt(limit);
const take = parseInt(limit);
const skip = (parseInt(page, 10) - 1) * parseInt(limit, 10);
const take = parseInt(limit, 10);
// Build where clause
const where = {
AND: [
// Search filter
search ? {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } }
]
} : {},
// Category filter
category ? { category: { equals: category } } : {},
// Update status filters
needsUpdate ? {
hostPackages: {
some: {
needsUpdate: needsUpdate === 'true'
}
}
} : {},
isSecurityUpdate ? {
hostPackages: {
some: {
isSecurityUpdate: isSecurityUpdate === 'true'
}
}
} : {}
]
};
// Build where clause
const where = {
AND: [
// Search filter
search
? {
OR: [
{ name: { contains: search, mode: "insensitive" } },
{ description: { contains: search, mode: "insensitive" } },
],
}
: {},
// Category filter
category ? { category: { equals: category } } : {},
// Update status filters
needsUpdate
? {
host_packages: {
some: {
needs_update: needsUpdate === "true",
},
},
}
: {},
isSecurityUpdate
? {
host_packages: {
some: {
is_security_update: isSecurityUpdate === "true",
},
},
}
: {},
],
};
// Get packages with counts
const [packages, totalCount] = await Promise.all([
prisma.package.findMany({
where,
select: {
id: true,
name: true,
description: true,
category: true,
latestVersion: true,
createdAt: true,
_count: {
hostPackages: true
}
},
skip,
take,
orderBy: {
name: 'asc'
}
}),
prisma.package.count({ where })
]);
// Get packages with counts
const [packages, totalCount] = await Promise.all([
prisma.packages.findMany({
where,
select: {
id: true,
name: true,
description: true,
category: true,
latest_version: true,
created_at: true,
_count: {
host_packages: true,
},
},
skip,
take,
orderBy: {
name: "asc",
},
}),
prisma.packages.count({ where }),
]);
// Get additional stats for each package
const packagesWithStats = await Promise.all(
packages.map(async (pkg) => {
const [updatesCount, securityCount, affectedHosts] = await Promise.all([
prisma.hostPackage.count({
where: {
packageId: pkg.id,
needsUpdate: true
}
}),
prisma.hostPackage.count({
where: {
packageId: pkg.id,
needsUpdate: true,
isSecurityUpdate: true
}
}),
prisma.hostPackage.findMany({
where: {
packageId: pkg.id,
needsUpdate: true
},
select: {
host: {
select: {
id: true,
hostname: true,
osType: true
}
}
},
take: 10 // Limit to first 10 for performance
})
]);
// Get additional stats for each package
const packagesWithStats = await Promise.all(
packages.map(async (pkg) => {
const [updatesCount, securityCount, affectedHosts] = 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,
},
},
},
take: 10, // Limit to first 10 for performance
}),
]);
return {
...pkg,
stats: {
totalInstalls: pkg._count.hostPackages,
updatesNeeded: updatesCount,
securityUpdates: securityCount,
affectedHosts: affectedHosts.map(hp => hp.host)
}
};
})
);
return {
...pkg,
affectedHostsCount: pkg._count.hostPackages,
affectedHosts: affectedHosts.map((hp) => ({
hostId: hp.host.id,
friendlyName: hp.host.friendly_name,
osType: hp.host.os_type,
currentVersion: hp.current_version,
availableVersion: hp.available_version,
isSecurityUpdate: hp.is_security_update,
})),
stats: {
totalInstalls: pkg._count.hostPackages,
updatesNeeded: updatesCount,
securityUpdates: securityCount,
},
};
}),
);
res.json({
packages: packagesWithStats,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: totalCount,
pages: Math.ceil(totalCount / parseInt(limit))
}
});
} catch (error) {
console.error('Error fetching packages:', error);
res.status(500).json({ error: 'Failed to fetch packages' });
}
res.json({
packages: packagesWithStats,
pagination: {
page: parseInt(page, 10),
limit: parseInt(limit, 10),
total: totalCount,
pages: Math.ceil(totalCount / parseInt(limit, 10)),
},
});
} catch (error) {
console.error("Error fetching packages:", error);
res.status(500).json({ error: "Failed to fetch packages" });
}
});
// Get package details by ID
router.get('/:packageId', async (req, res) => {
try {
const { packageId } = req.params;
router.get("/:packageId", async (req, res) => {
try {
const { packageId } = req.params;
const packageData = await prisma.package.findUnique({
where: { id: packageId },
include: {
hostPackages: {
include: {
host: {
select: {
id: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
lastUpdate: true
}
}
},
orderBy: {
needsUpdate: 'desc'
}
}
}
});
const packageData = await prisma.packages.findUnique({
where: { id: packageId },
include: {
host_packages: {
include: {
host: {
select: {
id: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
lastUpdate: true,
},
},
},
orderBy: {
needsUpdate: "desc",
},
},
},
});
if (!packageData) {
return res.status(404).json({ error: 'Package not found' });
}
if (!packageData) {
return res.status(404).json({ error: "Package not found" });
}
// Calculate statistics
const stats = {
totalInstalls: packageData.hostPackages.length,
updatesNeeded: packageData.hostPackages.filter(hp => hp.needsUpdate).length,
securityUpdates: packageData.hostPackages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length,
upToDate: packageData.hostPackages.filter(hp => !hp.needsUpdate).length
};
// Calculate statistics
const stats = {
totalInstalls: packageData.host_packages.length,
updatesNeeded: packageData.host_packages.filter((hp) => hp.needsUpdate)
.length,
securityUpdates: packageData.host_packages.filter(
(hp) => hp.needsUpdate && hp.isSecurityUpdate,
).length,
upToDate: packageData.host_packages.filter((hp) => !hp.needsUpdate)
.length,
};
// Group by version
const versionDistribution = packageData.hostPackages.reduce((acc, hp) => {
const version = hp.currentVersion;
acc[version] = (acc[version] || 0) + 1;
return acc;
}, {});
// Group by version
const versionDistribution = packageData.host_packages.reduce((acc, hp) => {
const version = hp.currentVersion;
acc[version] = (acc[version] || 0) + 1;
return acc;
}, {});
// Group by OS type
const osDistribution = packageData.hostPackages.reduce((acc, hp) => {
const osType = hp.host.osType;
acc[osType] = (acc[osType] || 0) + 1;
return acc;
}, {});
// Group by OS type
const osDistribution = packageData.host_packages.reduce((acc, hp) => {
const osType = hp.host.osType;
acc[osType] = (acc[osType] || 0) + 1;
return acc;
}, {});
res.json({
...packageData,
stats,
distributions: {
versions: Object.entries(versionDistribution).map(([version, count]) => ({
version,
count
})),
osTypes: Object.entries(osDistribution).map(([osType, count]) => ({
osType,
count
}))
}
});
} catch (error) {
console.error('Error fetching package details:', error);
res.status(500).json({ error: 'Failed to fetch package details' });
}
res.json({
...packageData,
stats,
distributions: {
versions: Object.entries(versionDistribution).map(
([version, count]) => ({
version,
count,
}),
),
osTypes: Object.entries(osDistribution).map(([osType, count]) => ({
osType,
count,
})),
},
});
} catch (error) {
console.error("Error fetching package details:", error);
res.status(500).json({ error: "Failed to fetch package details" });
}
});
module.exports = router;
module.exports = router;

View File

@@ -1,173 +1,203 @@
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const { requireManageSettings } = require('../middleware/permissions');
const express = require("express");
const { PrismaClient } = require("@prisma/client");
const { authenticateToken } = require("../middleware/auth");
const {
requireManageSettings,
requireManageUsers,
} = require("../middleware/permissions");
const router = express.Router();
const prisma = new PrismaClient();
// Get all role permissions
router.get('/roles', authenticateToken, requireManageSettings, async (req, res) => {
try {
const permissions = await prisma.rolePermissions.findMany({
orderBy: {
role: 'asc'
}
});
// Get all role permissions (allow users who can manage users to view roles)
router.get(
"/roles",
authenticateToken,
requireManageUsers,
async (_req, res) => {
try {
const permissions = await prisma.role_permissions.findMany({
orderBy: {
role: "asc",
},
});
res.json(permissions);
} catch (error) {
console.error('Get role permissions error:', error);
res.status(500).json({ error: 'Failed to fetch role permissions' });
}
});
res.json(permissions);
} catch (error) {
console.error("Get role permissions error:", error);
res.status(500).json({ error: "Failed to fetch role permissions" });
}
},
);
// Get permissions for a specific role
router.get('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
try {
const { role } = req.params;
const permissions = await prisma.rolePermissions.findUnique({
where: { role }
});
router.get(
"/roles/:role",
authenticateToken,
requireManageSettings,
async (req, res) => {
try {
const { role } = req.params;
if (!permissions) {
return res.status(404).json({ error: 'Role not found' });
}
const permissions = await prisma.role_permissions.findUnique({
where: { role },
});
res.json(permissions);
} catch (error) {
console.error('Get role permission error:', error);
res.status(500).json({ error: 'Failed to fetch role permission' });
}
});
if (!permissions) {
return res.status(404).json({ error: "Role not found" });
}
res.json(permissions);
} catch (error) {
console.error("Get role permission error:", error);
res.status(500).json({ error: "Failed to fetch role permission" });
}
},
);
// Create or update role permissions
router.put('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
try {
const { role } = req.params;
const {
canViewDashboard,
canViewHosts,
canManageHosts,
canViewPackages,
canManagePackages,
canViewUsers,
canManageUsers,
canViewReports,
canExportData,
canManageSettings
} = req.body;
router.put(
"/roles/:role",
authenticateToken,
requireManageSettings,
async (req, res) => {
try {
const { role } = req.params;
const {
can_view_dashboard,
can_view_hosts,
can_manage_hosts,
can_view_packages,
can_manage_packages,
can_view_users,
can_manage_users,
can_view_reports,
can_export_data,
can_manage_settings,
} = req.body;
// Prevent modifying admin role permissions (admin should always have full access)
if (role === 'admin') {
return res.status(400).json({ error: 'Cannot modify admin role permissions' });
}
// Prevent modifying admin and user role permissions (built-in roles)
if (role === "admin" || role === "user") {
return res.status(400).json({
error: `Cannot modify ${role} role permissions - this is a built-in role`,
});
}
const permissions = await prisma.rolePermissions.upsert({
where: { role },
update: {
canViewDashboard,
canViewHosts,
canManageHosts,
canViewPackages,
canManagePackages,
canViewUsers,
canManageUsers,
canViewReports,
canExportData,
canManageSettings
},
create: {
role,
canViewDashboard,
canViewHosts,
canManageHosts,
canViewPackages,
canManagePackages,
canViewUsers,
canManageUsers,
canViewReports,
canExportData,
canManageSettings
}
});
const permissions = await prisma.role_permissions.upsert({
where: { role },
update: {
can_view_dashboard: can_view_dashboard,
can_view_hosts: can_view_hosts,
can_manage_hosts: can_manage_hosts,
can_view_packages: can_view_packages,
can_manage_packages: can_manage_packages,
can_view_users: can_view_users,
can_manage_users: can_manage_users,
can_view_reports: can_view_reports,
can_export_data: can_export_data,
can_manage_settings: can_manage_settings,
updated_at: new Date(),
},
create: {
id: require("uuid").v4(),
role,
can_view_dashboard: can_view_dashboard,
can_view_hosts: can_view_hosts,
can_manage_hosts: can_manage_hosts,
can_view_packages: can_view_packages,
can_manage_packages: can_manage_packages,
can_view_users: can_view_users,
can_manage_users: can_manage_users,
can_view_reports: can_view_reports,
can_export_data: can_export_data,
can_manage_settings: can_manage_settings,
updated_at: new Date(),
},
});
res.json({
message: 'Role permissions updated successfully',
permissions
});
} catch (error) {
console.error('Update role permissions error:', error);
res.status(500).json({ error: 'Failed to update role permissions' });
}
});
res.json({
message: "Role permissions updated successfully",
permissions,
});
} catch (error) {
console.error("Update role permissions error:", error);
res.status(500).json({ error: "Failed to update role permissions" });
}
},
);
// Delete a role (and its permissions)
router.delete('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
try {
const { role } = req.params;
router.delete(
"/roles/:role",
authenticateToken,
requireManageSettings,
async (req, res) => {
try {
const { role } = req.params;
// Prevent deleting admin role
if (role === 'admin') {
return res.status(400).json({ error: 'Cannot delete admin role' });
}
// Prevent deleting admin and user roles (built-in roles)
if (role === "admin" || role === "user") {
return res.status(400).json({
error: `Cannot delete ${role} role - this is a built-in role`,
});
}
// Check if any users are using this role
const usersWithRole = await prisma.user.count({
where: { role }
});
// Check if any users are using this role
const usersWithRole = await prisma.users.count({
where: { role },
});
if (usersWithRole > 0) {
return res.status(400).json({
error: `Cannot delete role "${role}" because ${usersWithRole} user(s) are currently using it`
});
}
if (usersWithRole > 0) {
return res.status(400).json({
error: `Cannot delete role "${role}" because ${usersWithRole} user(s) are currently using it`,
});
}
await prisma.rolePermissions.delete({
where: { role }
});
await prisma.role_permissions.delete({
where: { role },
});
res.json({
message: `Role "${role}" deleted successfully`
});
} catch (error) {
console.error('Delete role error:', error);
res.status(500).json({ error: 'Failed to delete role' });
}
});
res.json({
message: `Role "${role}" deleted successfully`,
});
} catch (error) {
console.error("Delete role error:", error);
res.status(500).json({ error: "Failed to delete role" });
}
},
);
// Get user's permissions based on their role
router.get('/user-permissions', authenticateToken, async (req, res) => {
try {
const userRole = req.user.role;
const permissions = await prisma.rolePermissions.findUnique({
where: { role: userRole }
});
router.get("/user-permissions", authenticateToken, async (req, res) => {
try {
const userRole = req.user.role;
if (!permissions) {
// If no specific permissions found, return default admin permissions
return res.json({
role: userRole,
canViewDashboard: true,
canViewHosts: true,
canManageHosts: true,
canViewPackages: true,
canManagePackages: true,
canViewUsers: true,
canManageUsers: true,
canViewReports: true,
canExportData: true,
canManageSettings: true,
});
}
const permissions = await prisma.role_permissions.findUnique({
where: { role: userRole },
});
res.json(permissions);
} catch (error) {
console.error('Get user permissions error:', error);
res.status(500).json({ error: 'Failed to fetch user permissions' });
}
if (!permissions) {
// If no specific permissions found, return default admin permissions
return res.json({
role: userRole,
can_view_dashboard: true,
can_view_hosts: true,
can_manage_hosts: true,
can_view_packages: true,
can_manage_packages: true,
can_view_users: true,
can_manage_users: true,
can_view_reports: true,
can_export_data: true,
can_manage_settings: true,
});
}
res.json(permissions);
} catch (error) {
console.error("Get user permissions error:", error);
res.status(500).json({ error: "Failed to fetch user permissions" });
}
});
module.exports = router;

View File

@@ -1,301 +1,347 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { PrismaClient } = require('@prisma/client');
const { authenticateToken } = require('../middleware/auth');
const { requireViewHosts, requireManageHosts } = require('../middleware/permissions');
const express = require("express");
const { body, validationResult } = require("express-validator");
const { PrismaClient } = require("@prisma/client");
const { authenticateToken } = require("../middleware/auth");
const {
requireViewHosts,
requireManageHosts,
} = require("../middleware/permissions");
const router = express.Router();
const prisma = new PrismaClient();
// Get all repositories with host count
router.get('/', authenticateToken, requireViewHosts, async (req, res) => {
try {
const repositories = await prisma.repository.findMany({
include: {
hostRepositories: {
include: {
host: {
select: {
id: true,
hostname: true,
status: true
}
}
}
},
_count: {
select: {
hostRepositories: true
}
}
},
orderBy: [
{ name: 'asc' },
{ url: 'asc' }
]
});
router.get("/", authenticateToken, requireViewHosts, async (_req, res) => {
try {
const repositories = await prisma.repositories.findMany({
include: {
host_repositories: {
include: {
hosts: {
select: {
id: true,
friendly_name: true,
status: true,
},
},
},
},
_count: {
select: {
host_repositories: true,
},
},
},
orderBy: [{ name: "asc" }, { url: "asc" }],
});
// Transform data to include host counts and status
const transformedRepos = repositories.map(repo => ({
...repo,
hostCount: repo._count.hostRepositories,
enabledHostCount: repo.hostRepositories.filter(hr => hr.isEnabled).length,
activeHostCount: repo.hostRepositories.filter(hr => hr.host.status === 'active').length,
hosts: repo.hostRepositories.map(hr => ({
id: hr.host.id,
hostname: hr.host.hostname,
status: hr.host.status,
isEnabled: hr.isEnabled,
lastChecked: hr.lastChecked
}))
}));
// Transform data to include host counts and status
const transformedRepos = repositories.map((repo) => ({
...repo,
hostCount: repo._count.host_repositories,
enabledHostCount: repo.host_repositories.filter((hr) => hr.is_enabled)
.length,
activeHostCount: repo.host_repositories.filter(
(hr) => hr.hosts.status === "active",
).length,
hosts: repo.host_repositories.map((hr) => ({
id: hr.hosts.id,
friendlyName: hr.hosts.friendly_name,
status: hr.hosts.status,
isEnabled: hr.is_enabled,
lastChecked: hr.last_checked,
})),
}));
res.json(transformedRepos);
} catch (error) {
console.error('Repository list error:', error);
res.status(500).json({ error: 'Failed to fetch repositories' });
}
res.json(transformedRepos);
} catch (error) {
console.error("Repository list error:", error);
res.status(500).json({ error: "Failed to fetch repositories" });
}
});
// Get repositories for a specific host
router.get('/host/:hostId', authenticateToken, requireViewHosts, async (req, res) => {
try {
const { hostId } = req.params;
router.get(
"/host/:hostId",
authenticateToken,
requireViewHosts,
async (req, res) => {
try {
const { hostId } = req.params;
const hostRepositories = await prisma.hostRepository.findMany({
where: { hostId },
include: {
repository: true,
host: {
select: {
id: true,
hostname: true
}
}
},
orderBy: {
repository: {
name: 'asc'
}
}
});
const hostRepositories = await prisma.host_repositories.findMany({
where: { host_id: hostId },
include: {
repositories: true,
hosts: {
select: {
id: true,
friendly_name: true,
},
},
},
orderBy: {
repositories: {
name: "asc",
},
},
});
res.json(hostRepositories);
} catch (error) {
console.error('Host repositories error:', error);
res.status(500).json({ error: 'Failed to fetch host repositories' });
}
});
res.json(hostRepositories);
} catch (error) {
console.error("Host repositories error:", error);
res.status(500).json({ error: "Failed to fetch host repositories" });
}
},
);
// Get repository details with all hosts
router.get('/:repositoryId', authenticateToken, requireViewHosts, async (req, res) => {
try {
const { repositoryId } = req.params;
router.get(
"/:repositoryId",
authenticateToken,
requireViewHosts,
async (req, res) => {
try {
const { repositoryId } = req.params;
const repository = await prisma.repository.findUnique({
where: { id: repositoryId },
include: {
hostRepositories: {
include: {
host: {
select: {
id: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
status: true,
lastUpdate: true
}
}
},
orderBy: {
host: {
hostname: 'asc'
}
}
}
}
});
const repository = await prisma.repositories.findUnique({
where: { id: repositoryId },
include: {
host_repositories: {
include: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
os_type: true,
os_version: true,
status: true,
last_update: true,
},
},
},
orderBy: {
hosts: {
friendly_name: "asc",
},
},
},
},
});
if (!repository) {
return res.status(404).json({ error: 'Repository not found' });
}
if (!repository) {
return res.status(404).json({ error: "Repository not found" });
}
res.json(repository);
} catch (error) {
console.error('Repository detail error:', error);
res.status(500).json({ error: 'Failed to fetch repository details' });
}
});
res.json(repository);
} catch (error) {
console.error("Repository detail error:", error);
res.status(500).json({ error: "Failed to fetch repository details" });
}
},
);
// Update repository information (admin only)
router.put('/:repositoryId', authenticateToken, requireManageHosts, [
body('name').optional().isLength({ min: 1 }).withMessage('Name is required'),
body('description').optional(),
body('isActive').optional().isBoolean().withMessage('isActive must be a boolean'),
body('priority').optional().isInt({ min: 0 }).withMessage('Priority must be a positive integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
router.put(
"/:repositoryId",
authenticateToken,
requireManageHosts,
[
body("name")
.optional()
.isLength({ min: 1 })
.withMessage("Name is required"),
body("description").optional(),
body("isActive")
.optional()
.isBoolean()
.withMessage("isActive must be a boolean"),
body("priority")
.optional()
.isInt({ min: 0 })
.withMessage("Priority must be a positive integer"),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { repositoryId } = req.params;
const { name, description, isActive, priority } = req.body;
const { repositoryId } = req.params;
const { name, description, isActive, priority } = req.body;
const repository = await prisma.repository.update({
where: { id: repositoryId },
data: {
...(name && { name }),
...(description !== undefined && { description }),
...(isActive !== undefined && { isActive }),
...(priority !== undefined && { priority })
},
include: {
_count: {
select: {
hostRepositories: true
}
}
}
});
const repository = await prisma.repositories.update({
where: { id: repositoryId },
data: {
...(name && { name }),
...(description !== undefined && { description }),
...(isActive !== undefined && { is_active: isActive }),
...(priority !== undefined && { priority }),
},
include: {
_count: {
select: {
host_repositories: true,
},
},
},
});
res.json(repository);
} catch (error) {
console.error('Repository update error:', error);
res.status(500).json({ error: 'Failed to update repository' });
}
});
res.json(repository);
} catch (error) {
console.error("Repository update error:", error);
res.status(500).json({ error: "Failed to update repository" });
}
},
);
// Toggle repository status for a specific host
router.patch('/host/:hostId/repository/:repositoryId', authenticateToken, requireManageHosts, [
body('isEnabled').isBoolean().withMessage('isEnabled must be a boolean')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
router.patch(
"/host/:hostId/repository/:repositoryId",
authenticateToken,
requireManageHosts,
[body("isEnabled").isBoolean().withMessage("isEnabled must be a boolean")],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { hostId, repositoryId } = req.params;
const { isEnabled } = req.body;
const { hostId, repositoryId } = req.params;
const { isEnabled } = req.body;
const hostRepository = await prisma.hostRepository.update({
where: {
hostId_repositoryId: {
hostId,
repositoryId
}
},
data: {
isEnabled,
lastChecked: new Date()
},
include: {
repository: true,
host: {
select: {
hostname: true
}
}
}
});
const hostRepository = await prisma.host_repositories.update({
where: {
host_id_repository_id: {
host_id: hostId,
repository_id: repositoryId,
},
},
data: {
is_enabled: isEnabled,
last_checked: new Date(),
},
include: {
repositories: true,
hosts: {
select: {
friendly_name: true,
},
},
},
});
res.json({
message: `Repository ${isEnabled ? 'enabled' : 'disabled'} for host ${hostRepository.host.hostname}`,
hostRepository
});
} catch (error) {
console.error('Host repository toggle error:', error);
res.status(500).json({ error: 'Failed to toggle repository status' });
}
});
res.json({
message: `Repository ${isEnabled ? "enabled" : "disabled"} for host ${hostRepository.hosts.friendly_name}`,
hostRepository,
});
} catch (error) {
console.error("Host repository toggle error:", error);
res.status(500).json({ error: "Failed to toggle repository status" });
}
},
);
// Get repository statistics
router.get('/stats/summary', authenticateToken, requireViewHosts, async (req, res) => {
try {
const stats = await prisma.repository.aggregate({
_count: true
});
router.get(
"/stats/summary",
authenticateToken,
requireViewHosts,
async (_req, res) => {
try {
const stats = await prisma.repositories.aggregate({
_count: true,
});
const hostRepoStats = await prisma.hostRepository.aggregate({
_count: {
isEnabled: true
},
where: {
isEnabled: true
}
});
const hostRepoStats = await prisma.host_repositories.aggregate({
_count: {
is_enabled: true,
},
where: {
is_enabled: true,
},
});
const secureRepos = await prisma.repository.count({
where: { isSecure: true }
});
const secureRepos = await prisma.repositories.count({
where: { is_secure: true },
});
const activeRepos = await prisma.repository.count({
where: { isActive: true }
});
const activeRepos = await prisma.repositories.count({
where: { is_active: true },
});
res.json({
totalRepositories: stats._count,
activeRepositories: activeRepos,
secureRepositories: secureRepos,
enabledHostRepositories: hostRepoStats._count.isEnabled,
securityPercentage: stats._count > 0 ? Math.round((secureRepos / stats._count) * 100) : 0
});
} catch (error) {
console.error('Repository stats error:', error);
res.status(500).json({ error: 'Failed to fetch repository statistics' });
}
});
res.json({
totalRepositories: stats._count,
activeRepositories: activeRepos,
secureRepositories: secureRepos,
enabledHostRepositories: hostRepoStats._count.isEnabled,
securityPercentage:
stats._count > 0 ? Math.round((secureRepos / stats._count) * 100) : 0,
});
} catch (error) {
console.error("Repository stats error:", error);
res.status(500).json({ error: "Failed to fetch repository statistics" });
}
},
);
// Cleanup orphaned repositories (admin only)
router.delete('/cleanup/orphaned', authenticateToken, requireManageHosts, async (req, res) => {
try {
console.log('Cleaning up orphaned repositories...');
// Find repositories with no host relationships
const orphanedRepos = await prisma.repository.findMany({
where: {
hostRepositories: {
none: {}
}
}
});
router.delete(
"/cleanup/orphaned",
authenticateToken,
requireManageHosts,
async (_req, res) => {
try {
console.log("Cleaning up orphaned repositories...");
if (orphanedRepos.length === 0) {
return res.json({
message: 'No orphaned repositories found',
deletedCount: 0,
deletedRepositories: []
});
}
// Find repositories with no host relationships
const orphanedRepos = await prisma.repositories.findMany({
where: {
host_repositories: {
none: {},
},
},
});
// Delete orphaned repositories
const deleteResult = await prisma.repository.deleteMany({
where: {
hostRepositories: {
none: {}
}
}
});
if (orphanedRepos.length === 0) {
return res.json({
message: "No orphaned repositories found",
deletedCount: 0,
deletedRepositories: [],
});
}
console.log(`Deleted ${deleteResult.count} orphaned repositories`);
// Delete orphaned repositories
const deleteResult = await prisma.repositories.deleteMany({
where: {
hostRepositories: {
none: {},
},
},
});
res.json({
message: `Successfully deleted ${deleteResult.count} orphaned repositories`,
deletedCount: deleteResult.count,
deletedRepositories: orphanedRepos.map(repo => ({
id: repo.id,
name: repo.name,
url: repo.url
}))
});
} catch (error) {
console.error('Repository cleanup error:', error);
res.status(500).json({ error: 'Failed to cleanup orphaned repositories' });
}
});
console.log(`Deleted ${deleteResult.count} orphaned repositories`);
res.json({
message: `Successfully deleted ${deleteResult.count} orphaned repositories`,
deletedCount: deleteResult.count,
deletedRepositories: orphanedRepos.map((repo) => ({
id: repo.id,
name: repo.name,
url: repo.url,
})),
});
} catch (error) {
console.error("Repository cleanup error:", error);
res
.status(500)
.json({ error: "Failed to cleanup orphaned repositories" });
}
},
);
module.exports = router;

View File

@@ -0,0 +1,247 @@
const express = require("express");
const router = express.Router();
const { createPrismaClient } = require("../config/database");
const { authenticateToken } = require("../middleware/auth");
const prisma = createPrismaClient();
/**
* Global search endpoint
* Searches across hosts, packages, repositories, and users
* Returns categorized results
*/
router.get("/", authenticateToken, async (req, res) => {
try {
const { q } = req.query;
if (!q || q.trim().length === 0) {
return res.json({
hosts: [],
packages: [],
repositories: [],
users: [],
});
}
const searchTerm = q.trim();
// Prepare results object
const results = {
hosts: [],
packages: [],
repositories: [],
users: [],
};
// Get user permissions from database
let userPermissions = null;
try {
userPermissions = await prisma.role_permissions.findUnique({
where: { role: req.user.role },
});
// If no specific permissions found, default to admin permissions
if (!userPermissions) {
console.warn(
`No permissions found for role: ${req.user.role}, defaulting to admin access`,
);
userPermissions = {
can_view_hosts: true,
can_view_packages: true,
can_view_users: true,
};
}
} catch (permError) {
console.error("Error fetching permissions:", permError);
// Default to restrictive permissions on error
userPermissions = {
can_view_hosts: false,
can_view_packages: false,
can_view_users: false,
};
}
// Search hosts if user has permission
if (userPermissions.can_view_hosts) {
try {
const hosts = await prisma.hosts.findMany({
where: {
OR: [
{ hostname: { contains: searchTerm, mode: "insensitive" } },
{ friendly_name: { contains: searchTerm, mode: "insensitive" } },
{ ip: { contains: searchTerm, mode: "insensitive" } },
],
},
select: {
id: true,
hostname: true,
friendly_name: true,
ip: true,
os_type: true,
os_version: true,
status: true,
last_update: true,
},
take: 10, // Limit results
orderBy: {
last_update: "desc",
},
});
results.hosts = hosts.map((host) => ({
id: host.id,
hostname: host.hostname,
friendly_name: host.friendly_name,
ip: host.ip,
os_type: host.os_type,
os_version: host.os_version,
status: host.status,
last_update: host.last_update,
type: "host",
}));
} catch (error) {
console.error("Error searching hosts:", error);
}
}
// Search packages if user has permission
if (userPermissions.can_view_packages) {
try {
const packages = await prisma.packages.findMany({
where: {
name: { contains: searchTerm, mode: "insensitive" },
},
select: {
id: true,
name: true,
description: true,
category: true,
latest_version: true,
_count: {
select: {
host_packages: true,
},
},
},
take: 10,
orderBy: {
name: "asc",
},
});
results.packages = packages.map((pkg) => ({
id: pkg.id,
name: pkg.name,
description: pkg.description,
category: pkg.category,
latest_version: pkg.latest_version,
host_count: pkg._count.host_packages,
type: "package",
}));
} catch (error) {
console.error("Error searching packages:", error);
}
}
// Search repositories if user has permission (usually same as hosts)
if (userPermissions.can_view_hosts) {
try {
const repositories = await prisma.repositories.findMany({
where: {
OR: [
{ name: { contains: searchTerm, mode: "insensitive" } },
{ url: { contains: searchTerm, mode: "insensitive" } },
{ description: { contains: searchTerm, mode: "insensitive" } },
],
},
select: {
id: true,
name: true,
url: true,
distribution: true,
repo_type: true,
is_active: true,
description: true,
_count: {
select: {
host_repositories: true,
},
},
},
take: 10,
orderBy: {
name: "asc",
},
});
results.repositories = repositories.map((repo) => ({
id: repo.id,
name: repo.name,
url: repo.url,
distribution: repo.distribution,
repo_type: repo.repo_type,
is_active: repo.is_active,
description: repo.description,
host_count: repo._count.host_repositories,
type: "repository",
}));
} catch (error) {
console.error("Error searching repositories:", error);
}
}
// Search users if user has permission
if (userPermissions.can_view_users) {
try {
const users = await prisma.users.findMany({
where: {
OR: [
{ username: { contains: searchTerm, mode: "insensitive" } },
{ email: { contains: searchTerm, mode: "insensitive" } },
{ first_name: { contains: searchTerm, mode: "insensitive" } },
{ last_name: { contains: searchTerm, mode: "insensitive" } },
],
},
select: {
id: true,
username: true,
email: true,
first_name: true,
last_name: true,
role: true,
is_active: true,
last_login: true,
},
take: 10,
orderBy: {
username: "asc",
},
});
results.users = users.map((user) => ({
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,
type: "user",
}));
} catch (error) {
console.error("Error searching users:", error);
}
}
res.json(results);
} catch (error) {
console.error("Global search error:", error);
res.status(500).json({
error: "Failed to perform search",
message: error.message,
});
}
});
module.exports = router;

View File

@@ -1,268 +1,354 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { PrismaClient } = require('@prisma/client');
const { authenticateToken } = require('../middleware/auth');
const { requireManageSettings } = require('../middleware/permissions');
const express = require("express");
const { body, validationResult } = require("express-validator");
const { PrismaClient } = require("@prisma/client");
const { authenticateToken } = require("../middleware/auth");
const { requireManageSettings } = require("../middleware/permissions");
const { getSettings, updateSettings } = require("../services/settingsService");
const router = express.Router();
const prisma = new PrismaClient();
// 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 all hosts that have auto-update enabled
const hosts = await prisma.host.findMany({
where: {
autoUpdate: true,
status: 'active' // Only update active hosts
},
select: {
id: true,
hostname: true,
apiId: true,
apiKey: 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.hostname}`);
// 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('http');
const https = require('https');
const serverUrl = process.env.SERVER_URL || 'http://localhost:3001';
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.apiId,
'X-API-KEY': host.apiKey
}
};
const req = client.request(options, (res) => {
if (res.statusCode === 200) {
console.log(`Successfully triggered crontab update for ${host.hostname}`);
} else {
console.error(`Failed to trigger crontab update for ${host.hostname}: ${res.statusCode}`);
}
});
req.on('error', (error) => {
console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
});
req.write(postData);
req.end();
} catch (error) {
console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
}
}
console.log('Crontab update trigger completed');
} catch (error) {
console.error('Error in triggerCrontabUpdates:', error);
}
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);
}
}
// Helpers
function normalizeUpdateInterval(minutes) {
let m = parseInt(minutes, 10);
if (Number.isNaN(m)) return 60;
if (m < 5) m = 5;
if (m > 1440) m = 1440;
if (m < 60) {
// Clamp to 5-59, step 5
const snapped = Math.round(m / 5) * 5;
return Math.min(59, Math.max(5, snapped));
}
// Allowed hour-based presets
const allowed = [60, 120, 180, 360, 720, 1440];
let nearest = allowed[0];
let bestDiff = Math.abs(m - nearest);
for (const a of allowed) {
const d = Math.abs(m - a);
if (d < bestDiff) {
bestDiff = d;
nearest = a;
}
}
return nearest;
}
function buildCronExpression(minutes) {
const m = normalizeUpdateInterval(minutes);
if (m < 60) {
return `*/${m} * * * *`;
}
if (m === 60) {
// Hourly at current minute is chosen by agent; default 0 here
return `0 * * * *`;
}
const hours = Math.floor(m / 60);
// Every N hours at minute 0
return `0 */${hours} * * *`;
}
// Get current settings
router.get('/', authenticateToken, requireManageSettings, async (req, res) => {
try {
let settings = await prisma.settings.findFirst();
// If no settings exist, create default settings
if (!settings) {
settings = await prisma.settings.create({
data: {
serverUrl: 'http://localhost:3001',
serverProtocol: 'http',
serverHost: 'localhost',
serverPort: 3001,
frontendUrl: 'http://localhost:3000',
updateInterval: 60,
autoUpdate: false
}
});
}
console.log('Returning settings:', settings);
res.json(settings);
} catch (error) {
console.error('Settings fetch error:', error);
res.status(500).json({ error: 'Failed to fetch settings' });
}
router.get("/", authenticateToken, requireManageSettings, async (_req, res) => {
try {
const settings = await getSettings();
if (process.env.ENABLE_LOGGING === "true") {
console.log("Returning settings:", settings);
}
res.json(settings);
} catch (error) {
console.error("Settings fetch error:", error);
res.status(500).json({ error: "Failed to fetch settings" });
}
});
// Update settings
router.put('/', authenticateToken, requireManageSettings, [
body('serverProtocol').isIn(['http', 'https']).withMessage('Protocol must be http or https'),
body('serverHost').isLength({ min: 1 }).withMessage('Server host is required'),
body('serverPort').isInt({ min: 1, max: 65535 }).withMessage('Port must be between 1 and 65535'),
body('frontendUrl').isLength({ min: 1 }).withMessage('Frontend URL is required'),
body('updateInterval').isInt({ min: 5, max: 1440 }).withMessage('Update interval must be between 5 and 1440 minutes'),
body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean'),
body('githubRepoUrl').optional().isLength({ min: 1 }).withMessage('GitHub repo URL must be a non-empty string'),
body('sshKeyPath').optional().custom((value) => {
if (value && value.trim().length === 0) {
return true; // Allow empty string
}
if (value && value.trim().length < 1) {
throw new Error('SSH key path must be a non-empty string');
}
return true;
})
], async (req, res) => {
try {
console.log('Settings update request body:', req.body);
const errors = validationResult(req);
if (!errors.isEmpty()) {
console.log('Validation errors:', errors.array());
return res.status(400).json({ errors: errors.array() });
}
router.put(
"/",
authenticateToken,
requireManageSettings,
[
body("serverProtocol")
.optional()
.isIn(["http", "https"])
.withMessage("Protocol must be http or https"),
body("serverHost")
.optional()
.isLength({ min: 1 })
.withMessage("Server host is required"),
body("serverPort")
.optional()
.isInt({ min: 1, max: 65535 })
.withMessage("Port must be between 1 and 65535"),
body("updateInterval")
.optional()
.isInt({ min: 5, max: 1440 })
.withMessage("Update interval must be between 5 and 1440 minutes"),
body("autoUpdate")
.optional()
.isBoolean()
.withMessage("Auto update must be a boolean"),
body("ignoreSslSelfSigned")
.optional()
.isBoolean()
.withMessage("Ignore SSL self-signed must be a boolean"),
body("signupEnabled")
.optional()
.isBoolean()
.withMessage("Signup enabled must be a boolean"),
body("defaultUserRole")
.optional()
.isLength({ min: 1 })
.withMessage("Default user role must be a non-empty string"),
body("githubRepoUrl")
.optional()
.isLength({ min: 1 })
.withMessage("GitHub repo URL must be a non-empty string"),
body("repositoryType")
.optional()
.isIn(["public", "private"])
.withMessage("Repository type must be public or private"),
body("sshKeyPath")
.optional()
.custom((value) => {
if (value && value.trim().length === 0) {
return true; // Allow empty string
}
if (value && value.trim().length < 1) {
throw new Error("SSH key path must be a non-empty string");
}
return true;
}),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
console.log("Validation errors:", errors.array());
return res.status(400).json({ errors: errors.array() });
}
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, sshKeyPath } = req.body;
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, sshKeyPath });
// Construct server URL from components
const serverUrl = `${serverProtocol}://${serverHost}:${serverPort}`;
let settings = await prisma.settings.findFirst();
if (settings) {
// Update existing settings
console.log('Updating existing settings with data:', {
serverUrl,
serverProtocol,
serverHost,
serverPort,
frontendUrl,
updateInterval: updateInterval || 60,
autoUpdate: autoUpdate || false,
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
});
const oldUpdateInterval = settings.updateInterval;
settings = await prisma.settings.update({
where: { id: settings.id },
data: {
serverUrl,
serverProtocol,
serverHost,
serverPort,
frontendUrl,
updateInterval: updateInterval || 60,
autoUpdate: autoUpdate || false,
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
sshKeyPath: sshKeyPath || null
}
});
console.log('Settings updated successfully:', settings);
// If update interval changed, trigger crontab updates on all hosts with auto-update enabled
if (oldUpdateInterval !== (updateInterval || 60)) {
console.log(`Update interval changed from ${oldUpdateInterval} to ${updateInterval || 60} minutes. Triggering crontab updates...`);
await triggerCrontabUpdates();
}
} else {
// Create new settings
settings = await prisma.settings.create({
data: {
serverUrl,
serverProtocol,
serverHost,
serverPort,
frontendUrl,
updateInterval: updateInterval || 60,
autoUpdate: autoUpdate || false,
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
sshKeyPath: sshKeyPath || null
}
});
}
res.json({
message: 'Settings updated successfully',
settings
});
} catch (error) {
console.error('Settings update error:', error);
res.status(500).json({ error: 'Failed to update settings' });
}
});
const {
serverProtocol,
serverHost,
serverPort,
updateInterval,
autoUpdate,
ignoreSslSelfSigned,
signupEnabled,
defaultUserRole,
githubRepoUrl,
repositoryType,
sshKeyPath,
} = req.body;
// Get current settings to check for update interval changes
const currentSettings = await getSettings();
const oldUpdateInterval = currentSettings.update_interval;
// Build update object with only provided fields
const updateData = {};
if (serverProtocol !== undefined)
updateData.server_protocol = serverProtocol;
if (serverHost !== undefined) updateData.server_host = serverHost;
if (serverPort !== undefined) updateData.server_port = serverPort;
if (updateInterval !== undefined) {
updateData.update_interval = normalizeUpdateInterval(updateInterval);
}
if (autoUpdate !== undefined) updateData.auto_update = autoUpdate;
if (ignoreSslSelfSigned !== undefined)
updateData.ignore_ssl_self_signed = ignoreSslSelfSigned;
if (signupEnabled !== undefined)
updateData.signup_enabled = signupEnabled;
if (defaultUserRole !== undefined)
updateData.default_user_role = defaultUserRole;
if (githubRepoUrl !== undefined)
updateData.github_repo_url = githubRepoUrl;
if (repositoryType !== undefined)
updateData.repository_type = repositoryType;
if (sshKeyPath !== undefined) updateData.ssh_key_path = sshKeyPath;
const updatedSettings = await updateSettings(
currentSettings.id,
updateData,
);
console.log("Settings updated successfully:", updatedSettings);
// If update interval changed, trigger crontab updates on all hosts with auto-update enabled
if (
updateInterval !== undefined &&
oldUpdateInterval !== updateData.update_interval
) {
console.log(
`Update interval changed from ${oldUpdateInterval} to ${updateData.update_interval} minutes. Triggering crontab updates...`,
);
await triggerCrontabUpdates();
}
res.json({
message: "Settings updated successfully",
settings: updatedSettings,
});
} catch (error) {
console.error("Settings update error:", error);
res.status(500).json({ error: "Failed to update settings" });
}
},
);
// Get server URL for public use (used by installation scripts)
router.get('/server-url', async (req, res) => {
try {
const settings = await prisma.settings.findFirst();
if (!settings) {
return res.json({ serverUrl: 'http://localhost:3001' });
}
res.json({ serverUrl: settings.serverUrl });
} catch (error) {
console.error('Server URL fetch error:', error);
res.json({ serverUrl: 'http://localhost:3001' });
}
router.get("/server-url", async (_req, res) => {
try {
const settings = await getSettings();
const serverUrl = settings.server_url;
res.json({ server_url: serverUrl });
} catch (error) {
console.error("Server URL fetch error:", error);
res.status(500).json({ error: "Failed to fetch server URL" });
}
});
// Get update interval policy for agents (public endpoint)
router.get('/update-interval', async (req, res) => {
try {
const settings = await prisma.settings.findFirst();
if (!settings) {
return res.json({ updateInterval: 60 });
}
res.json({
updateInterval: settings.updateInterval,
cronExpression: `*/${settings.updateInterval} * * * *` // Generate cron expression
});
} catch (error) {
console.error('Update interval fetch error:', error);
res.json({ updateInterval: 60, cronExpression: '0 * * * *' });
}
// Get update interval policy for agents (requires API authentication)
router.get("/update-interval", async (req, res) => {
try {
// Verify API credentials
const apiId = req.headers["x-api-id"];
const apiKey = req.headers["x-api-key"];
if (!apiId || !apiKey) {
return res.status(401).json({ error: "API credentials required" });
}
// Validate API credentials
const host = await prisma.hosts.findUnique({
where: { api_id: apiId },
});
if (!host || host.api_key !== apiKey) {
return res.status(401).json({ error: "Invalid API credentials" });
}
const settings = await getSettings();
const interval = normalizeUpdateInterval(settings.update_interval || 60);
res.json({
updateInterval: interval,
cronExpression: buildCronExpression(interval),
});
} catch (error) {
console.error("Update interval fetch error:", error);
res.json({ updateInterval: 60, cronExpression: "0 * * * *" });
}
});
// Get auto-update policy for agents (public endpoint)
router.get('/auto-update', async (req, res) => {
try {
const settings = await prisma.settings.findFirst();
if (!settings) {
return res.json({ autoUpdate: false });
}
res.json({
autoUpdate: settings.autoUpdate || false
});
} catch (error) {
console.error('Auto-update fetch error:', error);
res.json({ autoUpdate: false });
}
router.get("/auto-update", async (_req, res) => {
try {
const settings = await getSettings();
res.json({
autoUpdate: settings.auto_update || false,
});
} catch (error) {
console.error("Auto-update fetch error:", error);
res.json({ autoUpdate: false });
}
});
module.exports = router;

View File

@@ -0,0 +1,340 @@
const express = require("express");
const { PrismaClient } = require("@prisma/client");
const speakeasy = require("speakeasy");
const QRCode = require("qrcode");
const { authenticateToken } = require("../middleware/auth");
const { body, validationResult } = require("express-validator");
const router = express.Router();
const prisma = new PrismaClient();
// Generate TFA secret and QR code
router.get("/setup", authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
// Check if user already has TFA enabled
const user = await prisma.users.findUnique({
where: { id: userId },
select: { tfa_enabled: true, tfa_secret: true },
});
if (user.tfa_enabled) {
return res.status(400).json({
error: "Two-factor authentication is already enabled for this account",
});
}
// Generate a new secret
const secret = speakeasy.generateSecret({
name: `PatchMon (${req.user.username})`,
issuer: "PatchMon",
length: 32,
});
// Generate QR code
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
// Store the secret temporarily (not enabled yet)
await prisma.users.update({
where: { id: userId },
data: { tfa_secret: secret.base32 },
});
res.json({
secret: secret.base32,
qrCode: qrCodeUrl,
manualEntryKey: secret.base32,
});
} catch (error) {
console.error("TFA setup error:", error);
res
.status(500)
.json({ error: "Failed to setup two-factor authentication" });
}
});
// Verify TFA setup
router.post(
"/verify-setup",
authenticateToken,
[
body("token")
.isLength({ min: 6, max: 6 })
.withMessage("Token must be 6 digits"),
body("token").isNumeric().withMessage("Token must contain only numbers"),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { token } = req.body;
const userId = req.user.id;
// Get user's TFA secret
const user = await prisma.users.findUnique({
where: { id: userId },
select: { tfa_secret: true, tfa_enabled: true },
});
if (!user.tfa_secret) {
return res.status(400).json({
error: "No TFA secret found. Please start the setup process first.",
});
}
if (user.tfa_enabled) {
return res.status(400).json({
error:
"Two-factor authentication is already enabled for this account",
});
}
// Verify the token
const verified = speakeasy.totp.verify({
secret: user.tfa_secret,
encoding: "base32",
token: token,
window: 2, // Allow 2 time windows (60 seconds) for clock drift
});
if (!verified) {
return res.status(400).json({
error: "Invalid verification code. Please try again.",
});
}
// Generate backup codes
const backupCodes = Array.from({ length: 10 }, () =>
Math.random().toString(36).substring(2, 8).toUpperCase(),
);
// Enable TFA and store backup codes
await prisma.users.update({
where: { id: userId },
data: {
tfa_enabled: true,
tfa_backup_codes: JSON.stringify(backupCodes),
},
});
res.json({
message: "Two-factor authentication has been enabled successfully",
backupCodes: backupCodes,
});
} catch (error) {
console.error("TFA verification error:", error);
res
.status(500)
.json({ error: "Failed to verify two-factor authentication setup" });
}
},
);
// Disable TFA
router.post(
"/disable",
authenticateToken,
[
body("password")
.notEmpty()
.withMessage("Password is required to disable TFA"),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { password: _password } = req.body;
const userId = req.user.id;
// Verify password
const user = await prisma.users.findUnique({
where: { id: userId },
select: { password_hash: true, tfa_enabled: true },
});
if (!user.tfa_enabled) {
return res.status(400).json({
error: "Two-factor authentication is not enabled for this account",
});
}
// FIXME: In a real implementation, you would verify the password hash here
// For now, we'll skip password verification for simplicity
// Disable TFA
await prisma.users.update({
where: { id: userId },
data: {
tfa_enabled: false,
tfa_secret: null,
tfa_backup_codes: null,
},
});
res.json({
message: "Two-factor authentication has been disabled successfully",
});
} catch (error) {
console.error("TFA disable error:", error);
res
.status(500)
.json({ error: "Failed to disable two-factor authentication" });
}
},
);
// Get TFA status
router.get("/status", authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const user = await prisma.users.findUnique({
where: { id: userId },
select: {
tfa_enabled: true,
tfa_secret: true,
tfa_backup_codes: true,
},
});
res.json({
enabled: user.tfa_enabled,
hasBackupCodes: !!user.tfa_backup_codes,
});
} catch (error) {
console.error("TFA status error:", error);
res.status(500).json({ error: "Failed to get TFA status" });
}
});
// Regenerate backup codes
router.post("/regenerate-backup-codes", authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
// Check if TFA is enabled
const user = await prisma.users.findUnique({
where: { id: userId },
select: { tfa_enabled: true },
});
if (!user.tfa_enabled) {
return res.status(400).json({
error: "Two-factor authentication is not enabled for this account",
});
}
// Generate new backup codes
const backupCodes = Array.from({ length: 10 }, () =>
Math.random().toString(36).substring(2, 8).toUpperCase(),
);
// Update backup codes
await prisma.users.update({
where: { id: userId },
data: {
tfa_backup_codes: JSON.stringify(backupCodes),
},
});
res.json({
message: "Backup codes have been regenerated successfully",
backupCodes: backupCodes,
});
} catch (error) {
console.error("TFA backup codes regeneration error:", error);
res.status(500).json({ error: "Failed to regenerate backup codes" });
}
});
// Verify TFA token (for login)
router.post(
"/verify",
[
body("username").notEmpty().withMessage("Username is required"),
body("token")
.isLength({ min: 6, max: 6 })
.withMessage("Token must be 6 digits"),
body("token").isNumeric().withMessage("Token must contain only numbers"),
],
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { username, token } = req.body;
// Get user's TFA secret
const user = await prisma.users.findUnique({
where: { username },
select: {
id: true,
tfa_enabled: true,
tfa_secret: true,
tfa_backup_codes: true,
},
});
if (!user || !user.tfa_enabled || !user.tfa_secret) {
return res.status(400).json({
error: "Two-factor authentication is not enabled for this account",
});
}
// Check if it's a backup code
const backupCodes = user.tfa_backup_codes
? JSON.parse(user.tfa_backup_codes)
: [];
const isBackupCode = backupCodes.includes(token);
let verified = false;
if (isBackupCode) {
// Remove the used backup code
const updatedBackupCodes = backupCodes.filter((code) => code !== token);
await prisma.users.update({
where: { id: user.id },
data: {
tfa_backup_codes: JSON.stringify(updatedBackupCodes),
},
});
verified = true;
} else {
// Verify TOTP token
verified = speakeasy.totp.verify({
secret: user.tfa_secret,
encoding: "base32",
token: token,
window: 2,
});
}
if (!verified) {
return res.status(400).json({
error: "Invalid verification code",
});
}
res.json({
message: "Two-factor authentication verified successfully",
userId: user.id,
});
} catch (error) {
console.error("TFA verification error:", error);
res
.status(500)
.json({ error: "Failed to verify two-factor authentication" });
}
},
);
module.exports = router;

View File

@@ -1,9 +1,9 @@
const express = require('express');
const { authenticateToken } = require('../middleware/auth');
const { requireManageSettings } = require('../middleware/permissions');
const { PrismaClient } = require('@prisma/client');
const { exec } = require('child_process');
const { promisify } = require('util');
const express = require("express");
const { authenticateToken } = require("../middleware/auth");
const { requireManageSettings } = require("../middleware/permissions");
const { PrismaClient } = require("@prisma/client");
const { exec } = require("node:child_process");
const { promisify } = require("node:util");
const prisma = new PrismaClient();
const execAsync = promisify(exec);
@@ -11,299 +11,194 @@ const execAsync = promisify(exec);
const router = express.Router();
// Get current version info
router.get('/current', authenticateToken, async (req, res) => {
try {
// For now, return hardcoded version - this should match your agent version
const currentVersion = '1.2.4';
res.json({
version: currentVersion,
buildDate: new Date().toISOString(),
environment: process.env.NODE_ENV || 'development'
});
} catch (error) {
console.error('Error getting current version:', error);
res.status(500).json({ error: 'Failed to get current version' });
}
router.get("/current", authenticateToken, async (_req, res) => {
try {
// 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,
);
}
res.json({
version: currentVersion,
buildDate: new Date().toISOString(),
environment: process.env.NODE_ENV || "development",
});
} catch (error) {
console.error("Error getting current version:", error);
res.status(500).json({ error: "Failed to get current version" });
}
});
// Test SSH key permissions and GitHub access
router.post('/test-ssh-key', authenticateToken, requireManageSettings, async (req, res) => {
try {
const { sshKeyPath, githubRepoUrl } = req.body;
if (!sshKeyPath || !githubRepoUrl) {
return res.status(400).json({
error: 'SSH key path and GitHub repo URL are required'
});
}
// Parse repository info
let owner, repo;
if (githubRepoUrl.includes('git@github.com:')) {
const match = githubRepoUrl.match(/git@github\.com:([^\/]+)\/([^\/]+)\.git/);
if (match) {
[, owner, repo] = match;
}
} else if (githubRepoUrl.includes('github.com/')) {
const match = githubRepoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/);
if (match) {
[, owner, repo] = match;
}
}
if (!owner || !repo) {
return res.status(400).json({
error: 'Invalid GitHub repository URL format'
});
}
// Check if SSH key file exists and is readable
try {
require('fs').accessSync(sshKeyPath);
} catch (e) {
return res.status(400).json({
error: 'SSH key file not found or not accessible',
details: `Cannot access: ${sshKeyPath}`,
suggestion: 'Check the file path and ensure the application has read permissions'
});
}
// Test SSH connection to GitHub
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
const env = {
...process.env,
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -o ConnectTimeout=10`
};
try {
// Test with a simple git command
const { stdout } = await execAsync(
`git ls-remote --heads ${sshRepoUrl} | head -n 1`,
{
timeout: 15000,
env: env
}
);
if (stdout.trim()) {
return res.json({
success: true,
message: 'SSH key is working correctly',
details: {
sshKeyPath,
repository: `${owner}/${repo}`,
testResult: 'Successfully connected to GitHub'
}
});
} else {
return res.status(400).json({
error: 'SSH connection succeeded but no data returned',
suggestion: 'Check repository access permissions'
});
}
} catch (sshError) {
console.error('SSH test error:', sshError.message);
if (sshError.message.includes('Permission denied')) {
return res.status(403).json({
error: 'SSH key permission denied',
details: 'The SSH key exists but GitHub rejected the connection',
suggestion: 'Verify the SSH key is added to the repository as a deploy key with read access'
});
} else if (sshError.message.includes('Host key verification failed')) {
return res.status(403).json({
error: 'Host key verification failed',
suggestion: 'This is normal for first-time connections. The key will be added to known_hosts automatically.'
});
} else if (sshError.message.includes('Connection timed out')) {
return res.status(408).json({
error: 'Connection timed out',
suggestion: 'Check your internet connection and GitHub status'
});
} else {
return res.status(500).json({
error: 'SSH connection failed',
details: sshError.message,
suggestion: 'Check the SSH key format and repository URL'
});
}
}
} catch (error) {
console.error('SSH key test error:', error);
res.status(500).json({
error: 'Failed to test SSH key',
details: error.message
});
}
});
router.post(
"/test-ssh-key",
authenticateToken,
requireManageSettings,
async (req, res) => {
try {
const { sshKeyPath, githubRepoUrl } = req.body;
if (!sshKeyPath || !githubRepoUrl) {
return res.status(400).json({
error: "SSH key path and GitHub repo URL are required",
});
}
// Parse repository info
let owner, repo;
if (githubRepoUrl.includes("git@github.com:")) {
const match = githubRepoUrl.match(
/git@github\.com:([^/]+)\/([^/]+)\.git/,
);
if (match) {
[, owner, repo] = match;
}
} else if (githubRepoUrl.includes("github.com/")) {
const match = githubRepoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
if (match) {
[, owner, repo] = match;
}
}
if (!owner || !repo) {
return res.status(400).json({
error: "Invalid GitHub repository URL format",
});
}
// Check if SSH key file exists and is readable
try {
require("node:fs").accessSync(sshKeyPath);
} catch {
return res.status(400).json({
error: "SSH key file not found or not accessible",
details: `Cannot access: ${sshKeyPath}`,
suggestion:
"Check the file path and ensure the application has read permissions",
});
}
// Test SSH connection to GitHub
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
const env = {
...process.env,
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -o ConnectTimeout=10`,
};
try {
// Test with a simple git command
const { stdout } = await execAsync(
`git ls-remote --heads ${sshRepoUrl} | head -n 1`,
{
timeout: 15000,
env: env,
},
);
if (stdout.trim()) {
return res.json({
success: true,
message: "SSH key is working correctly",
details: {
sshKeyPath,
repository: `${owner}/${repo}`,
testResult: "Successfully connected to GitHub",
},
});
} else {
return res.status(400).json({
error: "SSH connection succeeded but no data returned",
suggestion: "Check repository access permissions",
});
}
} catch (sshError) {
console.error("SSH test error:", sshError.message);
if (sshError.message.includes("Permission denied")) {
return res.status(403).json({
error: "SSH key permission denied",
details: "The SSH key exists but GitHub rejected the connection",
suggestion:
"Verify the SSH key is added to the repository as a deploy key with read access",
});
} else if (sshError.message.includes("Host key verification failed")) {
return res.status(403).json({
error: "Host key verification failed",
suggestion:
"This is normal for first-time connections. The key will be added to known_hosts automatically.",
});
} else if (sshError.message.includes("Connection timed out")) {
return res.status(408).json({
error: "Connection timed out",
suggestion: "Check your internet connection and GitHub status",
});
} else {
return res.status(500).json({
error: "SSH connection failed",
details: sshError.message,
suggestion: "Check the SSH key format and repository URL",
});
}
}
} catch (error) {
console.error("SSH key test error:", error);
res.status(500).json({
error: "Failed to test SSH key",
details: error.message,
});
}
},
);
// Check for updates from GitHub
router.get('/check-updates', authenticateToken, requireManageSettings, async (req, res) => {
try {
// Get GitHub repo URL from settings
const settings = await prisma.settings.findFirst();
if (!settings || !settings.githubRepoUrl) {
return res.status(400).json({ error: 'GitHub repository URL not configured' });
}
router.get(
"/check-updates",
authenticateToken,
requireManageSettings,
async (_req, res) => {
try {
// Get cached update information from settings
const settings = await prisma.settings.findFirst();
// Extract owner and repo from GitHub URL
// Support both SSH and HTTPS formats:
// git@github.com:owner/repo.git
// https://github.com/owner/repo.git
const repoUrl = settings.githubRepoUrl;
let owner, repo;
if (repoUrl.includes('git@github.com:')) {
const match = repoUrl.match(/git@github\.com:([^\/]+)\/([^\/]+)\.git/);
if (match) {
[, owner, repo] = match;
}
} else if (repoUrl.includes('github.com/')) {
const match = repoUrl.match(/github\.com\/([^\/]+)\/([^\/]+)/);
if (match) {
[, owner, repo] = match;
}
}
if (!settings) {
return res.status(400).json({ error: "Settings not found" });
}
if (!owner || !repo) {
return res.status(400).json({ error: 'Invalid GitHub repository URL format' });
}
const currentVersion = "1.2.7";
const latestVersion = settings.latest_version || currentVersion;
const isUpdateAvailable = settings.update_available || false;
const lastUpdateCheck = settings.last_update_check || null;
// Use SSH with deploy keys (secure approach)
const sshRepoUrl = `git@github.com:${owner}/${repo}.git`;
try {
let sshKeyPath = null;
// First, try to use the configured SSH key path from settings
if (settings.sshKeyPath) {
try {
require('fs').accessSync(settings.sshKeyPath);
sshKeyPath = settings.sshKeyPath;
console.log(`Using configured SSH key at: ${sshKeyPath}`);
} catch (e) {
console.warn(`Configured SSH key path not accessible: ${settings.sshKeyPath}`);
}
}
// If no configured path or it's not accessible, try common locations
if (!sshKeyPath) {
const possibleKeyPaths = [
'/root/.ssh/id_ed25519', // Root user (if service runs as root)
'/root/.ssh/id_rsa', // Root user RSA key
'/home/patchmon/.ssh/id_ed25519', // PatchMon user
'/home/patchmon/.ssh/id_rsa', // PatchMon user RSA key
'/var/www/.ssh/id_ed25519', // Web user
'/var/www/.ssh/id_rsa' // Web user RSA key
];
for (const path of possibleKeyPaths) {
try {
require('fs').accessSync(path);
sshKeyPath = path;
console.log(`Found SSH key at: ${path}`);
break;
} catch (e) {
// Key not found at this path, try next
}
}
}
if (!sshKeyPath) {
throw new Error('No SSH deploy key found. Please configure the SSH key path in settings or ensure a deploy key is installed in one of the expected locations.');
}
const env = {
...process.env,
GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o StrictHostKeyChecking=no -o IdentitiesOnly=yes`
};
// Fetch the latest tag using SSH with deploy key
const { stdout: latestTag } = await execAsync(
`git ls-remote --tags --sort=-version:refname ${sshRepoUrl} | head -n 1 | sed 's/.*refs\\/tags\\///' | sed 's/\\^{}//'`,
{
timeout: 10000,
env: env
}
);
const latestVersion = latestTag.trim().replace('v', ''); // Remove 'v' prefix
const currentVersion = '1.2.4';
// Simple version comparison (assumes semantic versioning)
const isUpdateAvailable = compareVersions(latestVersion, currentVersion) > 0;
res.json({
currentVersion,
latestVersion,
isUpdateAvailable,
latestRelease: {
tagName: latestTag.trim(),
version: latestVersion,
repository: `${owner}/${repo}`,
sshUrl: sshRepoUrl,
sshKeyUsed: sshKeyPath
}
});
} catch (sshError) {
console.error('SSH Git error:', sshError.message);
if (sshError.message.includes('Permission denied') || sshError.message.includes('Host key verification failed')) {
return res.status(403).json({
error: 'SSH access denied to repository',
suggestion: 'Ensure your deploy key is properly configured and has access to the repository. Check that the key has read access to the repository.'
});
}
if (sshError.message.includes('not found') || sshError.message.includes('does not exist')) {
return res.status(404).json({
error: 'Repository not found',
suggestion: 'Check that the repository URL is correct and accessible with the deploy key.'
});
}
if (sshError.message.includes('No SSH deploy key found')) {
return res.status(400).json({
error: 'No SSH deploy key found',
suggestion: 'Please install a deploy key in one of the expected locations: /root/.ssh/, /home/patchmon/.ssh/, or /var/www/.ssh/'
});
}
return res.status(500).json({
error: 'Failed to fetch repository information',
details: sshError.message,
suggestion: 'Check deploy key configuration and repository access permissions.'
});
}
} catch (error) {
console.error('Error checking for updates:', error);
res.status(500).json({
error: 'Failed to check for updates',
details: error.message
});
}
});
// Simple version comparison function
function compareVersions(version1, version2) {
const v1Parts = version1.split('.').map(Number);
const v2Parts = version2.split('.').map(Number);
const maxLength = Math.max(v1Parts.length, v2Parts.length);
for (let i = 0; i < maxLength; i++) {
const v1Part = v1Parts[i] || 0;
const v2Part = v2Parts[i] || 0;
if (v1Part > v2Part) return 1;
if (v1Part < v2Part) return -1;
}
return 0;
}
res.json({
currentVersion,
latestVersion,
isUpdateAvailable,
lastUpdateCheck,
repositoryType: settings.repository_type || "public",
latestRelease: {
tagName: latestVersion ? `v${latestVersion}` : null,
version: latestVersion,
repository: settings.github_repo_url
? settings.github_repo_url.split("/").slice(-2).join("/")
: null,
accessMethod: settings.repository_type === "private" ? "ssh" : "api",
},
});
} catch (error) {
console.error("Error getting update information:", error);
res.status(500).json({ error: "Failed to get update information" });
}
},
);
module.exports = router;

View File

@@ -1,49 +1,216 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const { PrismaClient } = require('@prisma/client');
const winston = require('winston');
require("dotenv").config();
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
const {
createPrismaClient,
waitForDatabase,
disconnectPrisma,
} = require("./config/database");
const winston = require("winston");
// Import routes
const authRoutes = require('./routes/authRoutes');
const hostRoutes = require('./routes/hostRoutes');
const hostGroupRoutes = require('./routes/hostGroupRoutes');
const packageRoutes = require('./routes/packageRoutes');
const dashboardRoutes = require('./routes/dashboardRoutes');
const permissionsRoutes = require('./routes/permissionsRoutes');
const settingsRoutes = require('./routes/settingsRoutes');
const dashboardPreferencesRoutes = require('./routes/dashboardPreferencesRoutes');
const repositoryRoutes = require('./routes/repositoryRoutes');
const versionRoutes = require('./routes/versionRoutes');
const authRoutes = require("./routes/authRoutes");
const hostRoutes = require("./routes/hostRoutes");
const hostGroupRoutes = require("./routes/hostGroupRoutes");
const packageRoutes = require("./routes/packageRoutes");
const dashboardRoutes = require("./routes/dashboardRoutes");
const permissionsRoutes = require("./routes/permissionsRoutes");
const settingsRoutes = require("./routes/settingsRoutes");
const {
router: dashboardPreferencesRoutes,
} = require("./routes/dashboardPreferencesRoutes");
const repositoryRoutes = require("./routes/repositoryRoutes");
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 { initSettings } = require("./services/settingsService");
const { cleanup_expired_sessions } = require("./utils/session_manager");
// Initialize Prisma client
const prisma = new PrismaClient();
// Initialize Prisma client with optimized connection pooling for multiple instances
const prisma = createPrismaClient();
// Function to check and create default role permissions on startup
async function checkAndCreateRolePermissions() {
console.log("🔐 Starting role permissions auto-creation check...");
// Skip if auto-creation is disabled
if (process.env.AUTO_CREATE_ROLE_PERMISSIONS === "false") {
console.log("❌ Auto-creation of role permissions is disabled");
if (process.env.ENABLE_LOGGING === "true") {
logger.info("Auto-creation of role permissions is disabled");
}
return;
}
try {
const crypto = require("node:crypto");
// Define default roles and permissions
const defaultRoles = [
{
id: crypto.randomUUID(),
role: "admin",
can_view_dashboard: true,
can_view_hosts: true,
can_manage_hosts: true,
can_view_packages: true,
can_manage_packages: true,
can_view_users: true,
can_manage_users: true,
can_view_reports: true,
can_export_data: true,
can_manage_settings: true,
created_at: new Date(),
updated_at: new Date(),
},
{
id: crypto.randomUUID(),
role: "user",
can_view_dashboard: true,
can_view_hosts: true,
can_manage_hosts: false,
can_view_packages: true,
can_manage_packages: false,
can_view_users: false,
can_manage_users: false,
can_view_reports: true,
can_export_data: false,
can_manage_settings: false,
created_at: new Date(),
updated_at: new Date(),
},
];
const createdRoles = [];
const existingRoles = [];
for (const roleData of defaultRoles) {
// Check if role already exists
const existingRole = await prisma.role_permissions.findUnique({
where: { role: roleData.role },
});
if (existingRole) {
console.log(`✅ Role '${roleData.role}' already exists in database`);
existingRoles.push(existingRole);
if (process.env.ENABLE_LOGGING === "true") {
logger.info(`Role '${roleData.role}' already exists in database`);
}
} else {
// Create new role permission
const permission = await prisma.role_permissions.create({
data: roleData,
});
createdRoles.push(permission);
console.log(`🆕 Created role '${roleData.role}' with permissions`);
if (process.env.ENABLE_LOGGING === "true") {
logger.info(`Created role '${roleData.role}' with permissions`);
}
}
}
if (createdRoles.length > 0) {
console.log(
`🎉 Successfully auto-created ${createdRoles.length} role permissions on startup`,
);
console.log("📋 Created roles:");
createdRoles.forEach((role) => {
console.log(
`${role.role}: dashboard=${role.can_view_dashboard}, hosts=${role.can_manage_hosts}, packages=${role.can_manage_packages}, users=${role.can_manage_users}, settings=${role.can_manage_settings}`,
);
});
if (process.env.ENABLE_LOGGING === "true") {
logger.info(
`✅ Auto-created ${createdRoles.length} role permissions on startup`,
);
}
} else {
console.log(
`✅ All default role permissions already exist (${existingRoles.length} roles verified)`,
);
if (process.env.ENABLE_LOGGING === "true") {
logger.info(
`All default role permissions already exist (${existingRoles.length} roles verified)`,
);
}
}
} catch (error) {
console.error(
"❌ Failed to check/create role permissions on startup:",
error.message,
);
if (process.env.ENABLE_LOGGING === "true") {
logger.error(
"Failed to check/create role permissions on startup:",
error.message,
);
}
}
}
// Initialize logger - only if logging is enabled
const logger = process.env.ENABLE_LOGGING === 'true' ? winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' }),
],
}) : {
info: () => {},
error: () => {},
warn: () => {},
debug: () => {}
};
const logger =
process.env.ENABLE_LOGGING === "true"
? winston.createLogger({
level: process.env.LOG_LEVEL || "info",
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json(),
),
transports: [],
})
: {
info: () => {},
error: () => {},
warn: () => {},
debug: () => {},
};
if (process.env.ENABLE_LOGGING === 'true' && process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.simple()
}));
// Configure transports based on PM_LOG_TO_CONSOLE environment variable
if (process.env.ENABLE_LOGGING === "true") {
const logToConsole =
process.env.PM_LOG_TO_CONSOLE === "1" ||
process.env.PM_LOG_TO_CONSOLE === "true";
if (logToConsole) {
// Log to stdout/stderr instead of files
logger.add(
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.printf(({ timestamp, level, message, stack }) => {
return `${timestamp} [${level.toUpperCase()}]: ${stack || message}`;
}),
),
stderrLevels: ["error", "warn"],
}),
);
} else {
// Log to files (default behavior)
logger.add(
new winston.transports.File({
filename: "logs/error.log",
level: "error",
}),
);
logger.add(new winston.transports.File({ filename: "logs/combined.log" }));
// Also add console logging for non-production environments
if (process.env.NODE_ENV !== "production") {
logger.add(
new winston.transports.Console({
format: winston.format.simple(),
}),
);
}
}
}
const app = express();
@@ -51,79 +218,155 @@ const PORT = process.env.PORT || 3001;
// Trust proxy (needed when behind reverse proxy) and remove X-Powered-By
if (process.env.TRUST_PROXY) {
app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : parseInt(process.env.TRUST_PROXY, 10) || true);
} else {
app.set('trust proxy', 1);
}
app.disable('x-powered-by');
const trustProxyValue = process.env.TRUST_PROXY;
// Rate limiting
// Parse the trust proxy setting according to Express documentation
if (trustProxyValue === "true") {
app.set("trust proxy", true);
} else if (trustProxyValue === "false") {
app.set("trust proxy", false);
} else if (/^\d+$/.test(trustProxyValue)) {
// If it's a number (hop count)
app.set("trust proxy", parseInt(trustProxyValue, 10));
} else {
// If it contains commas, split into array; otherwise use as single value
// This handles: IP addresses, subnets, named subnets (loopback, linklocal, uniquelocal)
app.set(
"trust proxy",
trustProxyValue.includes(",")
? trustProxyValue.split(",").map((s) => s.trim())
: trustProxyValue,
);
}
} else {
app.set("trust proxy", 1);
}
app.disable("x-powered-by");
// Rate limiting with monitoring
const limiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000,
max: parseInt(process.env.RATE_LIMIT_MAX) || 100,
message: 'Too many requests from this IP, please try again later.',
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000,
max: parseInt(process.env.RATE_LIMIT_MAX, 10) || 100,
message: {
error: "Too many requests from this IP, please try again later.",
retryAfter: Math.ceil(
(parseInt(process.env.RATE_LIMIT_WINDOW_MS, 10) || 15 * 60 * 1000) / 1000,
),
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true, // Don't count successful requests
skipFailedRequests: false, // Count failed requests
});
// Middleware
// Helmet with stricter defaults (CSP/HSTS only in production)
app.use(helmet({
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
useDefaults: true,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:'],
fontSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"]
}
} : false,
hsts: process.env.ENABLE_HSTS === 'true' || process.env.NODE_ENV === 'production'
}));
app.use(
helmet({
contentSecurityPolicy:
process.env.NODE_ENV === "production"
? {
useDefaults: true,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:"],
fontSrc: ["'self'", "data:"],
connectSrc: ["'self'"],
frameAncestors: ["'none'"],
objectSrc: ["'none'"],
},
}
: false,
hsts:
process.env.ENABLE_HSTS === "true" ||
process.env.NODE_ENV === "production",
}),
);
// CORS allowlist from comma-separated env
const parseOrigins = (val) => (val || '').split(',').map(s => s.trim()).filter(Boolean);
const allowedOrigins = parseOrigins(process.env.CORS_ORIGINS || process.env.CORS_ORIGIN || 'http://localhost:3000');
app.use(cors({
origin: function(origin, callback) {
// Allow non-browser/SSR tools with no origin
if (!origin) return callback(null, true);
if (allowedOrigins.includes(origin)) return callback(null, true);
return callback(new Error('Not allowed by CORS'));
},
credentials: true
}));
const parseOrigins = (val) =>
(val || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
const allowedOrigins = parseOrigins(
process.env.CORS_ORIGINS ||
process.env.CORS_ORIGIN ||
"http://localhost:3000",
);
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);
return callback(new Error("Not allowed by CORS"));
},
credentials: true,
}),
);
app.use(limiter);
// Reduce body size limits to reasonable defaults
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '5mb' }));
app.use(express.urlencoded({ extended: true, limit: process.env.JSON_BODY_LIMIT || '5mb' }));
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || "5mb" }));
app.use(
express.urlencoded({
extended: true,
limit: process.env.JSON_BODY_LIMIT || "5mb",
}),
);
// Request logging - only if logging is enabled
if (process.env.ENABLE_LOGGING === 'true') {
app.use((req, res, next) => {
logger.info(`${req.method} ${req.path} - ${req.ip}`);
next();
});
if (process.env.ENABLE_LOGGING === "true") {
app.use((req, _, next) => {
// Log health check requests at debug level to reduce log spam
if (req.path === "/health") {
logger.debug(`${req.method} ${req.path} - ${req.ip}`);
} else {
logger.info(`${req.method} ${req.path} - ${req.ip}`);
}
next();
});
}
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
app.get("/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// API routes
const apiVersion = process.env.API_VERSION || 'v1';
const apiVersion = process.env.API_VERSION || "v1";
// Per-route rate limits
// Per-route rate limits with monitoring
const authLimiter = rateLimit({
windowMs: parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS) || 10 * 60 * 1000,
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX) || 20
windowMs:
parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 10) || 10 * 60 * 1000,
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 20,
message: {
error: "Too many authentication requests, please try again later.",
retryAfter: Math.ceil(
(parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS, 10) || 10 * 60 * 1000) /
1000,
),
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true,
});
const agentLimiter = rateLimit({
windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS) || 60 * 1000,
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX) || 120
windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS, 10) || 60 * 1000,
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX, 10) || 120,
message: {
error: "Too many agent requests, please try again later.",
retryAfter: Math.ceil(
(parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS, 10) || 60 * 1000) /
1000,
),
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true,
});
app.use(`/api/${apiVersion}/auth`, authLimiter, authRoutes);
@@ -136,46 +379,348 @@ app.use(`/api/${apiVersion}/settings`, settingsRoutes);
app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes);
app.use(`/api/${apiVersion}/repositories`, repositoryRoutes);
app.use(`/api/${apiVersion}/version`, versionRoutes);
app.use(`/api/${apiVersion}/tfa`, tfaRoutes);
app.use(`/api/${apiVersion}/search`, searchRoutes);
app.use(
`/api/${apiVersion}/auto-enrollment`,
authLimiter,
autoEnrollmentRoutes,
);
// Error handling middleware
app.use((err, req, res, next) => {
if (process.env.ENABLE_LOGGING === 'true') {
logger.error(err.stack);
}
res.status(500).json({
error: 'Something went wrong!',
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
app.use((err, _req, res, _next) => {
if (process.env.ENABLE_LOGGING === "true") {
logger.error(err.stack);
}
res.status(500).json({
error: "Something went wrong!",
message: process.env.NODE_ENV === "development" ? err.message : undefined,
});
});
// 404 handler
app.use('*', (req, res) => {
res.status(404).json({ error: 'Route not found' });
app.use("*", (_req, res) => {
res.status(404).json({ error: "Route not found" });
});
// Graceful shutdown
process.on('SIGTERM', async () => {
if (process.env.ENABLE_LOGGING === 'true') {
logger.info('SIGTERM received, shutting down gracefully');
}
await prisma.$disconnect();
process.exit(0);
process.on("SIGINT", async () => {
if (process.env.ENABLE_LOGGING === "true") {
logger.info("SIGINT received, shutting down gracefully");
}
if (app.locals.session_cleanup_interval) {
clearInterval(app.locals.session_cleanup_interval);
}
updateScheduler.stop();
await disconnectPrisma(prisma);
process.exit(0);
});
process.on('SIGINT', async () => {
if (process.env.ENABLE_LOGGING === 'true') {
logger.info('SIGINT received, shutting down gracefully');
}
await prisma.$disconnect();
process.exit(0);
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 disconnectPrisma(prisma);
process.exit(0);
});
// Start server
app.listen(PORT, () => {
if (process.env.ENABLE_LOGGING === 'true') {
logger.info(`Server running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV}`);
}
});
// Initialize dashboard preferences for all users
async function initializeDashboardPreferences() {
try {
// Get all users
const users = await prisma.users.findMany({
select: {
id: true,
username: true,
email: true,
role: true,
dashboard_preferences: {
select: {
card_id: true,
},
},
},
});
module.exports = app;
if (users.length === 0) {
return;
}
let initializedCount = 0;
let updatedCount = 0;
for (const user of users) {
const hasPreferences = user.dashboard_preferences.length > 0;
// Get permission-based preferences for this user's role
const expectedPreferences = await getPermissionBasedPreferences(
user.role,
);
const expectedCardCount = expectedPreferences.length;
if (!hasPreferences) {
// User has no preferences - create them
const preferencesData = expectedPreferences.map((pref) => ({
id: require("uuid").v4(),
user_id: user.id,
card_id: pref.cardId,
enabled: pref.enabled,
order: pref.order,
created_at: new Date(),
updated_at: new Date(),
}));
await prisma.dashboard_preferences.createMany({
data: preferencesData,
});
initializedCount++;
} else {
// User already has preferences - check if they need updating
const currentCardCount = user.dashboard_preferences.length;
if (currentCardCount !== expectedCardCount) {
// Delete existing preferences
await prisma.dashboard_preferences.deleteMany({
where: { user_id: user.id },
});
// Create new preferences based on permissions
const preferencesData = expectedPreferences.map((pref) => ({
id: require("uuid").v4(),
user_id: user.id,
card_id: pref.cardId,
enabled: pref.enabled,
order: pref.order,
created_at: new Date(),
updated_at: new Date(),
}));
await prisma.dashboard_preferences.createMany({
data: preferencesData,
});
updatedCount++;
}
}
}
// Only show summary if there were changes
if (initializedCount > 0 || updatedCount > 0) {
console.log(
`✅ Dashboard preferences: ${initializedCount} initialized, ${updatedCount} updated`,
);
}
} catch (error) {
console.error("❌ Error initializing dashboard preferences:", error);
throw error;
}
}
// Helper function to get user permissions based on role
async function getUserPermissions(userRole) {
try {
const permissions = await prisma.role_permissions.findUnique({
where: { role: userRole },
});
// If no specific permissions found, return default admin permissions (for backward compatibility)
if (!permissions) {
console.warn(
`No permissions found for role: ${userRole}, defaulting to admin access`,
);
return {
can_view_dashboard: true,
can_view_hosts: true,
can_manage_hosts: true,
can_view_packages: true,
can_manage_packages: true,
can_view_users: true,
can_manage_users: true,
can_view_reports: true,
can_export_data: true,
can_manage_settings: true,
};
}
return permissions;
} catch (error) {
console.error("Error fetching user permissions:", error);
// Return admin permissions as fallback
return {
can_view_dashboard: true,
can_view_hosts: true,
can_manage_hosts: true,
can_view_packages: true,
can_manage_packages: true,
can_view_users: true,
can_manage_users: true,
can_view_reports: true,
can_export_data: true,
can_manage_settings: true,
};
}
}
// Helper function to get permission-based dashboard preferences for a role
async function getPermissionBasedPreferences(userRole) {
// Get user's actual permissions
const permissions = await getUserPermissions(userRole);
// Define all possible dashboard cards with their required permissions
const allCards = [
// Host-related cards
{ cardId: "totalHosts", requiredPermission: "can_view_hosts", order: 0 },
{
cardId: "hostsNeedingUpdates",
requiredPermission: "can_view_hosts",
order: 1,
},
// Package-related cards
{
cardId: "totalOutdatedPackages",
requiredPermission: "can_view_packages",
order: 2,
},
{
cardId: "securityUpdates",
requiredPermission: "can_view_packages",
order: 3,
},
// Host-related cards (continued)
{
cardId: "totalHostGroups",
requiredPermission: "can_view_hosts",
order: 4,
},
{ cardId: "upToDateHosts", requiredPermission: "can_view_hosts", order: 5 },
// Repository-related cards
{ cardId: "totalRepos", requiredPermission: "can_view_hosts", order: 6 }, // Repos are host-related
// User management cards (admin only)
{ cardId: "totalUsers", requiredPermission: "can_view_users", order: 7 },
// System/Report cards
{
cardId: "osDistribution",
requiredPermission: "can_view_reports",
order: 8,
},
{
cardId: "osDistributionBar",
requiredPermission: "can_view_reports",
order: 9,
},
{
cardId: "osDistributionDoughnut",
requiredPermission: "can_view_reports",
order: 10,
},
{
cardId: "recentCollection",
requiredPermission: "can_view_hosts",
order: 11,
}, // Collection is host-related
{
cardId: "updateStatus",
requiredPermission: "can_view_reports",
order: 12,
},
{
cardId: "packagePriority",
requiredPermission: "can_view_packages",
order: 13,
},
{ cardId: "recentUsers", requiredPermission: "can_view_users", order: 14 },
{
cardId: "quickStats",
requiredPermission: "can_view_dashboard",
order: 15,
},
];
// Filter cards based on user's permissions
const allowedCards = allCards.filter((card) => {
return permissions[card.requiredPermission] === true;
});
return allowedCards.map((card) => ({
cardId: card.cardId,
enabled: true,
order: card.order, // Preserve original order from allCards
}));
}
// Start server with database health check
async function startServer() {
try {
// Wait for database to be available
await waitForDatabase(prisma);
if (process.env.ENABLE_LOGGING === "true") {
logger.info("✅ Database connection successful");
}
// Initialise settings on startup
try {
await initSettings();
if (process.env.ENABLE_LOGGING === "true") {
logger.info("✅ Settings initialised");
}
} catch (initError) {
if (process.env.ENABLE_LOGGING === "true") {
logger.error("❌ Failed to initialise settings:", initError.message);
}
throw initError; // Fail startup if settings can't be initialised
}
// Check and create default role permissions on startup
await checkAndCreateRolePermissions();
// Initialize dashboard preferences for all users
await initializeDashboardPreferences();
// Initial session cleanup
await cleanup_expired_sessions();
// 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
app.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);
}
}
startServer();
module.exports = app;

View File

@@ -0,0 +1,198 @@
const { PrismaClient } = require("@prisma/client");
const { v4: uuidv4 } = require("uuid");
const prisma = new PrismaClient();
// Cached settings instance
let cachedSettings = null;
// Environment variable to settings field mapping
const ENV_TO_SETTINGS_MAP = {
SERVER_PROTOCOL: "server_protocol",
SERVER_HOST: "server_host",
SERVER_PORT: "server_port",
};
// Helper function to construct server URL without default ports
function constructServerUrl(protocol, host, port) {
const isHttps = protocol.toLowerCase() === "https";
const isHttp = protocol.toLowerCase() === "http";
// Don't append port if it's the default port for the protocol
if ((isHttps && port === 443) || (isHttp && port === 80)) {
return `${protocol}://${host}`.toLowerCase();
}
return `${protocol}://${host}:${port}`.toLowerCase();
}
// Create settings from environment variables and/or defaults
async function createSettingsFromEnvironment() {
const protocol = process.env.SERVER_PROTOCOL || "http";
const host = process.env.SERVER_HOST || "localhost";
const port = parseInt(process.env.SERVER_PORT, 10) || 3001;
const serverUrl = constructServerUrl(protocol, host, port);
const settings = await prisma.settings.create({
data: {
id: uuidv4(),
server_url: serverUrl,
server_protocol: protocol,
server_host: host,
server_port: port,
update_interval: 60,
auto_update: false,
signup_enabled: false,
ignore_ssl_self_signed: false,
updated_at: new Date(),
},
});
console.log("Created settings");
return settings;
}
// Sync environment variables with existing settings
async function syncEnvironmentToSettings(currentSettings) {
const updates = {};
let hasChanges = false;
// Check each environment variable mapping
for (const [envVar, settingsField] of Object.entries(ENV_TO_SETTINGS_MAP)) {
if (process.env[envVar]) {
const envValue = process.env[envVar];
const currentValue = currentSettings[settingsField];
// Convert environment value to appropriate type
let convertedValue = envValue;
if (settingsField === "server_port") {
convertedValue = parseInt(envValue, 10);
}
// Only update if values differ
if (currentValue !== convertedValue) {
updates[settingsField] = convertedValue;
hasChanges = true;
if (process.env.ENABLE_LOGGING === "true") {
console.log(
`Environment variable ${envVar} (${envValue}) differs from settings ${settingsField} (${currentValue}), updating...`,
);
}
}
}
}
// Construct server_url from components if any components were updated
const protocol = updates.server_protocol || currentSettings.server_protocol;
const host = updates.server_host || currentSettings.server_host;
const port = updates.server_port || currentSettings.server_port;
const constructedServerUrl = constructServerUrl(protocol, host, port);
// Update server_url if it differs from the constructed value
if (currentSettings.server_url !== constructedServerUrl) {
updates.server_url = constructedServerUrl;
hasChanges = true;
if (process.env.ENABLE_LOGGING === "true") {
console.log(`Updating server_url to: ${constructedServerUrl}`);
}
}
// Update settings if there are changes
if (hasChanges) {
const updatedSettings = await prisma.settings.update({
where: { id: currentSettings.id },
data: {
...updates,
updated_at: new Date(),
},
});
if (process.env.ENABLE_LOGGING === "true") {
console.log(
`Synced ${Object.keys(updates).length} environment variables to settings`,
);
}
return updatedSettings;
}
return currentSettings;
}
// Initialise settings - create from environment or sync existing
async function initSettings() {
if (cachedSettings) {
return cachedSettings;
}
try {
let settings = await prisma.settings.findFirst({
orderBy: { updated_at: "desc" },
});
if (!settings) {
// No settings exist, create from environment variables and defaults
settings = await createSettingsFromEnvironment();
} else {
// Settings exist, sync with environment variables
settings = await syncEnvironmentToSettings(settings);
}
// Cache the initialised settings
cachedSettings = settings;
return settings;
} catch (error) {
console.error("Failed to initialise settings:", error);
throw error;
}
}
// Get current settings (returns cached if available)
async function getSettings() {
return cachedSettings || (await initSettings());
}
// Update settings and refresh cache
async function updateSettings(id, updateData) {
try {
const updatedSettings = await prisma.settings.update({
where: { id },
data: {
...updateData,
updated_at: new Date(),
},
});
// Reconstruct server_url from components
const serverUrl = constructServerUrl(
updatedSettings.server_protocol,
updatedSettings.server_host,
updatedSettings.server_port,
);
if (updatedSettings.server_url !== serverUrl) {
updatedSettings.server_url = serverUrl;
await prisma.settings.update({
where: { id },
data: { server_url: serverUrl },
});
}
// Update cache
cachedSettings = updatedSettings;
return updatedSettings;
} catch (error) {
console.error("Failed to update settings:", error);
throw error;
}
}
// Invalidate cache (useful for testing or manual refresh)
function invalidateCache() {
cachedSettings = null;
}
module.exports = {
initSettings,
getSettings,
updateSettings,
invalidateCache,
syncEnvironmentToSettings, // Export for startup use
};

View File

@@ -0,0 +1,290 @@
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();
if (!settings || !settings.githubRepoUrl) {
console.log("⚠️ No GitHub repository configured, skipping update check");
return;
}
// Extract owner and repo from GitHub URL
const repoUrl = settings.githubRepoUrl;
let owner, repo;
if (repoUrl.includes("git@github.com:")) {
const match = repoUrl.match(/git@github\.com:([^/]+)\/([^/]+)\.git/);
if (match) {
[, owner, repo] = match;
}
} else if (repoUrl.includes("github.com/")) {
const match = repoUrl.match(
/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/,
);
if (match) {
[, owner, repo] = match;
}
}
if (!owner || !repo) {
console.log(
"⚠️ Could not parse GitHub repository URL, skipping update check",
);
return;
}
let latestVersion;
const isPrivate = settings.repositoryType === "private";
if (isPrivate) {
// Use SSH for private repositories
latestVersion = await this.checkPrivateRepo(settings, owner, repo);
} else {
// Use GitHub API for public repositories
latestVersion = await this.checkPublicRepo(owner, repo);
}
if (!latestVersion) {
console.log(
"⚠️ Could not determine latest version, skipping update check",
);
return;
}
// 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: {
lastUpdateCheck: new Date(),
updateAvailable: isUpdateAvailable,
latestVersion: latestVersion,
},
});
console.log(
`✅ Update check completed - Current: ${currentVersion}, Latest: ${latestVersion}, Update Available: ${isUpdateAvailable}`,
);
} catch (error) {
console.error("❌ Error checking for updates:", error.message);
// Update last check time even on error
try {
const settings = await prisma.settings.findFirst();
if (settings) {
await prisma.settings.update({
where: { id: settings.id },
data: {
lastUpdateCheck: new Date(),
updateAvailable: false,
},
});
}
} catch (updateError) {
console.error(
"❌ Error updating last check time:",
updateError.message,
);
}
}
}
// Check private repository using SSH
async checkPrivateRepo(settings, owner, repo) {
try {
let sshKeyPath = settings.sshKeyPath;
// Try to find SSH key if not configured
if (!sshKeyPath) {
const possibleKeyPaths = [
"/root/.ssh/id_ed25519",
"/root/.ssh/id_rsa",
"/home/patchmon/.ssh/id_ed25519",
"/home/patchmon/.ssh/id_rsa",
"/var/www/.ssh/id_ed25519",
"/var/www/.ssh/id_rsa",
];
for (const path of possibleKeyPaths) {
try {
require("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) {
throw new Error(
`GitHub API error: ${response.status} ${response.statusText}`,
);
}
const releaseData = await response.json();
return releaseData.tag_name.replace("v", "");
} catch (error) {
console.error("GitHub API error:", error.message);
throw error;
}
}
// Compare version strings (semantic versioning)
compareVersions(version1, version2) {
const v1parts = version1.split(".").map(Number);
const v2parts = version2.split(".").map(Number);
const maxLength = Math.max(v1parts.length, v2parts.length);
for (let i = 0; i < maxLength; i++) {
const v1part = v1parts[i] || 0;
const v2part = v2parts[i] || 0;
if (v1part > v2part) return 1;
if (v1part < v2part) return -1;
}
return 0;
}
// Get scheduler status
getStatus() {
return {
isRunning: this.isRunning,
checkInterval: this.checkInterval,
nextCheck: this.isRunning
? new Date(Date.now() + this.checkInterval)
: null,
};
}
}
// Create singleton instance
const updateScheduler = new UpdateScheduler();
module.exports = updateScheduler;

View File

@@ -0,0 +1,319 @@
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();
/**
* Session Manager - Handles secure session management with inactivity timeout
*/
// Configuration
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "1h";
const JWT_REFRESH_EXPIRES_IN = process.env.JWT_REFRESH_EXPIRES_IN || "7d";
const INACTIVITY_TIMEOUT_MINUTES = parseInt(
process.env.SESSION_INACTIVITY_TIMEOUT_MINUTES || "30",
10,
);
/**
* Generate access token (short-lived)
*/
function generate_access_token(user_id, session_id) {
return jwt.sign({ userId: user_id, sessionId: session_id }, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
});
}
/**
* Generate refresh token (long-lived)
*/
function generate_refresh_token() {
return crypto.randomBytes(64).toString("hex");
}
/**
* Hash token for storage
*/
function hash_token(token) {
return crypto.createHash("sha256").update(token).digest("hex");
}
/**
* Parse expiration string to Date
*/
function parse_expiration(expiration_string) {
const match = expiration_string.match(/^(\d+)([smhd])$/);
if (!match) {
throw new Error("Invalid expiration format");
}
const value = parseInt(match[1], 10);
const unit = match[2];
const now = new Date();
switch (unit) {
case "s":
return new Date(now.getTime() + value * 1000);
case "m":
return new Date(now.getTime() + value * 60 * 1000);
case "h":
return new Date(now.getTime() + value * 60 * 60 * 1000);
case "d":
return new Date(now.getTime() + value * 24 * 60 * 60 * 1000);
default:
throw new Error("Invalid time unit");
}
}
/**
* Create a new session for user
*/
async function create_session(user_id, ip_address, user_agent) {
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);
// Store session in database
await prisma.user_sessions.create({
data: {
id: session_id,
user_id: user_id,
refresh_token: hash_token(refresh_token),
access_token_hash: hash_token(access_token),
ip_address: ip_address || null,
user_agent: user_agent || null,
last_activity: new Date(),
expires_at: expires_at,
},
});
return {
session_id,
access_token,
refresh_token,
expires_at,
};
} catch (error) {
console.error("Error creating session:", error);
throw error;
}
}
/**
* Validate session and check for inactivity timeout
*/
async function validate_session(session_id, access_token) {
try {
const session = await prisma.user_sessions.findUnique({
where: { id: session_id },
include: { users: true },
});
if (!session) {
return { valid: false, reason: "Session not found" };
}
// Check if session is revoked
if (session.is_revoked) {
return { valid: false, reason: "Session revoked" };
}
// Check if session has expired
if (new Date() > session.expires_at) {
await revoke_session(session_id);
return { valid: false, reason: "Session expired" };
}
// Check for inactivity timeout
const inactivity_threshold = new Date(
Date.now() - INACTIVITY_TIMEOUT_MINUTES * 60 * 1000,
);
if (session.last_activity < inactivity_threshold) {
await revoke_session(session_id);
return {
valid: false,
reason: "Session inactive",
message: `Session timed out after ${INACTIVITY_TIMEOUT_MINUTES} minutes of inactivity`,
};
}
// Validate access token hash (optional security check)
if (session.access_token_hash) {
const provided_hash = hash_token(access_token);
if (session.access_token_hash !== provided_hash) {
return { valid: false, reason: "Token mismatch" };
}
}
// Check if user is still active
if (!session.users.is_active) {
await revoke_session(session_id);
return { valid: false, reason: "User inactive" };
}
return {
valid: true,
session,
user: session.users,
};
} catch (error) {
console.error("Error validating session:", error);
return { valid: false, reason: "Validation error" };
}
}
/**
* Update session activity timestamp
*/
async function update_session_activity(session_id) {
try {
await prisma.user_sessions.update({
where: { id: session_id },
data: { last_activity: new Date() },
});
return true;
} catch (error) {
console.error("Error updating session activity:", error);
return false;
}
}
/**
* Refresh access token using refresh token
*/
async function refresh_access_token(refresh_token) {
try {
const hashed_token = hash_token(refresh_token);
const session = await prisma.user_sessions.findUnique({
where: { refresh_token: hashed_token },
include: { users: true },
});
if (!session) {
return { success: false, error: "Invalid refresh token" };
}
// Validate session
const validation = await validate_session(session.id, "");
if (!validation.valid) {
return { success: false, error: validation.reason };
}
// Generate new access token
const new_access_token = generate_access_token(session.user_id, session.id);
// Update access token hash
await prisma.user_sessions.update({
where: { id: session.id },
data: {
access_token_hash: hash_token(new_access_token),
last_activity: new Date(),
},
});
return {
success: true,
access_token: new_access_token,
user: session.users,
};
} catch (error) {
console.error("Error refreshing access token:", error);
return { success: false, error: "Token refresh failed" };
}
}
/**
* Revoke a session
*/
async function revoke_session(session_id) {
try {
await prisma.user_sessions.update({
where: { id: session_id },
data: { is_revoked: true },
});
return true;
} catch (error) {
console.error("Error revoking session:", error);
return false;
}
}
/**
* Revoke all sessions for a user
*/
async function revoke_all_user_sessions(user_id) {
try {
await prisma.user_sessions.updateMany({
where: { user_id: user_id },
data: { is_revoked: true },
});
return true;
} catch (error) {
console.error("Error revoking user sessions:", error);
return false;
}
}
/**
* Clean up expired sessions (should be run periodically)
*/
async function cleanup_expired_sessions() {
try {
const result = await prisma.user_sessions.deleteMany({
where: {
OR: [{ expires_at: { lt: new Date() } }, { is_revoked: true }],
},
});
console.log(`Cleaned up ${result.count} expired sessions`);
return result.count;
} catch (error) {
console.error("Error cleaning up sessions:", error);
return 0;
}
}
/**
* Get active sessions for a user
*/
async function get_user_sessions(user_id) {
try {
return await prisma.user_sessions.findMany({
where: {
user_id: user_id,
is_revoked: false,
expires_at: { gt: new Date() },
},
select: {
id: true,
ip_address: true,
user_agent: true,
last_activity: true,
created_at: true,
expires_at: true,
},
orderBy: { last_activity: "desc" },
});
} catch (error) {
console.error("Error getting user sessions:", error);
return [];
}
}
module.exports = {
create_session,
validate_session,
update_session_activity,
refresh_access_token,
revoke_session,
revoke_all_user_sessions,
cleanup_expired_sessions,
get_user_sessions,
generate_access_token,
INACTIVITY_TIMEOUT_MINUTES,
};

17
biome.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
},
"formatter": {
"enabled": true
},
"linter": {
"enabled": true
},
"assist": {
"enabled": true
}
}

BIN
dashboard.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

235
docker/README.md Normal file
View File

@@ -0,0 +1,235 @@
# PatchMon Docker
## Overview
PatchMon is a containerised application that monitors system patches and updates. The application consists of three main services:
- **Database**: PostgreSQL 17
- **Backend**: Node.js API server
- **Frontend**: React application served via Nginx
## Images
- **Backend**: [ghcr.io/9technologygroup/patchmon-backend:latest](https://github.com/9technologygroup/patchmon.net/pkgs/container/patchmon-backend)
- **Frontend**: [ghcr.io/9technologygroup/patchmon-frontend:latest](https://github.com/9technologygroup/patchmon.net/pkgs/container/patchmon-frontend)
Version tags are also available (e.g. `1.2.3`) for both of these images.
## Quick Start
### Production Deployment
1. Download the [Docker Compose file](docker-compose.yml)
2. Change the default database password in the file:
```yaml
environment:
POSTGRES_PASSWORD: YOUR_SECURE_PASSWORD_HERE
```
3. Update the corresponding `DATABASE_URL` in the backend service:
```yaml
environment:
DATABASE_URL: postgresql://patchmon_user:YOUR_SECURE_PASSWORD_HERE@database:5432/patchmon_db
```
4. Configure environment variables (see [Configuration](#configuration) section)
5. Start the application:
```bash
docker compose up -d
```
6. Access the application at `http://localhost:3000`
## Updating
To update PatchMon to the latest version:
```bash
docker compose up -d --pull
```
This command will:
- Pull the latest images from the registry
- Recreate containers with updated images
- Maintain your data and configuration
### Version-Specific Updates
If you're using specific version tags instead of `latest` in your compose file:
1. Update the image tags in your `docker-compose.yml`. For example:
```yaml
services:
backend:
image: ghcr.io/9technologygroup/patchmon-backend:1.2.7 # Update version here
...
frontend:
image: ghcr.io/9technologygroup/patchmon-frontend:1.2.7 # Update version here
...
```
2. Then run the update command:
```bash
docker compose up -d --pull
```
> [!TIP]
> Check the [releases page](https://github.com/9technologygroup/patchmon.net/releases) for version-specific changes and migration notes.
## Configuration
### Environment Variables
#### Database Service
- `POSTGRES_DB`: Database name (default: `patchmon_db`)
- `POSTGRES_USER`: Database user (default: `patchmon_user`)
- `POSTGRES_PASSWORD`: Database password - **MUST BE CHANGED!**
#### Backend Service
- `LOG_LEVEL`: Logging level (`debug`, `info`, `warn`, `error`)
- `DATABASE_URL`: PostgreSQL connection string
- `PM_DB_CONN_MAX_ATTEMPTS`: Maximum database connection attempts (default: 30)
- `PM_DB_CONN_WAIT_INTERVAL`: Wait interval between connection attempts in seconds (default: 2)
- `SERVER_PROTOCOL`: Frontend server protocol (`http` or `https`)
- `SERVER_HOST`: Frontend server host (default: `localhost`)
- `SERVER_PORT`: Frontend server port (default: 3000)
- `PORT`: Backend API port (default: 3001)
- `API_VERSION`: API version (default: `v1`)
- `CORS_ORIGIN`: CORS origin URL
- `RATE_LIMIT_WINDOW_MS`: Rate limiting window in milliseconds (default: 900000)
- `RATE_LIMIT_MAX`: Maximum requests per window (default: 100)
- `ENABLE_HSTS`: Enable HTTP Strict Transport Security (default: true)
- `TRUST_PROXY`: Trust proxy headers (default: true) - See [Express.js docs](https://expressjs.com/en/guide/behind-proxies.html) for usage.
#### Frontend Service
- `BACKEND_HOST`: Backend service hostname (default: `backend`)
- `BACKEND_PORT`: Backend service port (default: 3001)
### Volumes
The compose file creates two Docker volumes:
* `postgres_data`: PostgreSQL'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.
> [!TIP]
> The backend container runs as user & group ID 1000. If you plan to re-bind the agent files directory, ensure that the same user and/or group ID has permission to write to the host path to which it's bound.
---
# Development
This section is for developers who want to contribute to PatchMon or run it in development mode.
## Development Setup
For development with live reload and source code mounting:
1. Clone the repository:
```bash
git clone https://github.com/9technologygroup/patchmon.net.git
cd patchmon.net
```
2. Start development environment:
```bash
docker compose -f docker/docker-compose.dev.yml up
```
_See [Development Commands](#development-commands) for more options._
3. Access the application:
- Frontend: `http://localhost:3000`
- Backend API: `http://localhost:3001`
- Database: `localhost:5432`
## Development Docker Compose
The development compose file (`docker/docker-compose.dev.yml`):
- Builds images locally from source using development targets
- Enables hot reload with Docker Compose watch functionality
- Exposes database and backend ports for testing and development
- Mounts source code directly into containers for live development
- Supports debugging with enhanced logging
## Building Images Locally
Both Dockerfiles use multi-stage builds with separate development and production targets:
```bash
# Build development images
docker build -f docker/backend.Dockerfile --target development -t patchmon-backend:dev .
docker build -f docker/frontend.Dockerfile --target development -t patchmon-frontend:dev .
# Build production images (default target)
docker build -f docker/backend.Dockerfile -t patchmon-backend:latest .
docker build -f docker/frontend.Dockerfile -t patchmon-frontend:latest .
```
## Development Commands
### Hot Reload Development
```bash
# Attached, live log output, services stopped on Ctrl+C
docker compose -f docker/docker-compose.dev.yml up
# Attached with Docker Compose watch for hot reload
docker compose -f docker/docker-compose.dev.yml up --watch
# Detached
docker compose -f docker/docker-compose.dev.yml up -d
# Quiet, no log output, with Docker Compose watch for hot reload
docker compose -f docker/docker-compose.dev.yml watch
```
### Rebuild Services
```bash
# Rebuild specific service
docker compose -f docker/docker-compose.dev.yml up -d --build backend
# Rebuild all services
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
- **Backend**: `3001` - API server with development features
- **Frontend**: `3000` - React development server with hot reload
## Development Workflow
1. **Initial Setup**: Clone repository and start development environment
```bash
git clone https://github.com/9technologygroup/patchmon.net.git
cd patchmon.net
docker compose -f docker/docker-compose.dev.yml up -d --build
```
2. **Hot Reload Development**: Use Docker Compose watch for automatic reload
```bash
docker compose -f docker/docker-compose.dev.yml up --watch --build
```
3. **Code Changes**:
- **Frontend/Backend Source**: Files are synced automatically with watch mode
- **Package.json Changes**: Triggers automatic service rebuild
- **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:
```bash
docker compose -f docker/docker-compose.dev.yml logs -f
```
Otherwise logs are shown automatically in attached modes (`up`, `up --watch`).
### Features in Development Mode
- **Hot Reload**: Automatic code synchronization and service restarts
- **Enhanced Logging**: Detailed logs for debugging
- **Direct Access**: Exposed ports for database and API debugging
- **Health Checks**: Built-in health monitoring for services
- **Volume Persistence**: Development data persists between restarts

86
docker/backend.Dockerfile Normal file
View File

@@ -0,0 +1,86 @@
# Development target
FROM node:lts-alpine AS development
ENV NODE_ENV=development \
NPM_CONFIG_UPDATE_NOTIFIER=false \
ENABLE_LOGGING=true \
LOG_LEVEL=info \
PM_LOG_TO_CONSOLE=true \
PORT=3001
RUN apk add --no-cache openssl tini curl
USER node
WORKDIR /app
COPY --chown=node:node package*.json ./
COPY --chown=node:node backend/ ./backend/
COPY --chown=node:node agents ./agents_backup
COPY --chown=node:node agents ./agents
COPY --chmod=755 docker/backend.docker-entrypoint.sh ./entrypoint.sh
WORKDIR /app/backend
RUN npm ci --ignore-scripts && npx prisma generate
EXPOSE 3001
VOLUME [ "/app/agents" ]
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
CMD curl -f http://localhost:3001/health || exit 1
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/app/entrypoint.sh"]
# Builder stage for production
FROM node:lts-alpine AS builder
RUN apk add --no-cache openssl
WORKDIR /app
COPY --chown=node:node package*.json ./
COPY --chown=node:node backend/ ./backend/
WORKDIR /app/backend
RUN npm ci --ignore-scripts &&\
npx prisma generate &&\
npm prune --omit=dev &&\
npm cache clean --force
# Production stage
FROM node:lts-alpine
ENV NODE_ENV=production \
NPM_CONFIG_UPDATE_NOTIFIER=false \
ENABLE_LOGGING=true \
LOG_LEVEL=info \
PM_LOG_TO_CONSOLE=true \
PORT=3001
RUN apk add --no-cache openssl tini curl
USER node
WORKDIR /app
COPY --from=builder /app/backend ./backend
COPY --from=builder /app/node_modules ./node_modules
COPY --chown=node:node agents ./agents_backup
COPY --chown=node:node agents ./agents
COPY --chmod=755 docker/backend.docker-entrypoint.sh ./entrypoint.sh
WORKDIR /app/backend
EXPOSE 3001
VOLUME [ "/app/agents" ]
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=5 \
CMD curl -f http://localhost:3001/health || exit 1
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["/app/entrypoint.sh"]

View File

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

View File

@@ -0,0 +1,33 @@
#!/bin/sh
# Enable strict error handling
set -e
# Function to log messages with timestamp
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/
else
log "Warning: agents_backup directory not found"
fi
else
log "Agents directory already contains files, skipping copy"
fi
log "Starting PatchMon Backend (${NODE_ENV:-production})..."
log "Running database migrations..."
npx prisma migrate deploy
log "Starting application..."
if [ "${NODE_ENV}" = "development" ]; then
exec npm run dev
else
exec npm start
fi

View File

@@ -0,0 +1,79 @@
services:
database:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: patchmon_db
POSTGRES_USER: patchmon_user
POSTGRES_PASSWORD: INSECURE_REPLACE_ME_PLEASE_INSECURE
ports:
- "5432:5432"
volumes:
- ./compose_dev_data/db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U patchmon_user -d patchmon_db"]
interval: 3s
timeout: 5s
retries: 7
backend:
build:
context: ..
dockerfile: docker/backend.Dockerfile
target: development
restart: unless-stopped
environment:
NODE_ENV: development
LOG_LEVEL: info
DATABASE_URL: postgresql://patchmon_user:INSECURE_REPLACE_ME_PLEASE_INSECURE@database:5432/patchmon_db
PM_DB_CONN_MAX_ATTEMPTS: 30
PM_DB_CONN_WAIT_INTERVAL: 2
SERVER_PROTOCOL: http
SERVER_HOST: localhost
SERVER_PORT: 3000
CORS_ORIGIN: http://localhost:3000
RATE_LIMIT_WINDOW_MS: 900000
RATE_LIMIT_MAX: 100
ports:
- "3001:3001"
volumes:
- ./compose_dev_data/agents:/app/agents
depends_on:
database:
condition: service_healthy
develop:
watch:
- action: sync
path: ../backend/src
target: /app/backend/src
ignore:
- node_modules/
- action: sync
path: ../backend/prisma
target: /app/backend/prisma
- action: rebuild
path: ../backend/package.json
frontend:
build:
context: ..
dockerfile: docker/frontend.Dockerfile
target: development
restart: unless-stopped
environment:
BACKEND_HOST: backend
BACKEND_PORT: 3001
ports:
- "3000:3000"
depends_on:
backend:
condition: service_healthy
develop:
watch:
- action: sync
path: ../frontend/src
target: /app/frontend/src
ignore:
- node_modules/
- action: rebuild
path: ../frontend/package.json

48
docker/docker-compose.yml Normal file
View File

@@ -0,0 +1,48 @@
services:
database:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: patchmon_db
POSTGRES_USER: patchmon_user
POSTGRES_PASSWORD: INSECURE_REPLACE_ME_PLEASE_INSECURE
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U patchmon_user -d patchmon_db"]
interval: 3s
timeout: 5s
retries: 7
backend:
image: ghcr.io/9technologygroup/patchmon-backend:latest
restart: unless-stopped
environment:
LOG_LEVEL: info
DATABASE_URL: postgresql://patchmon_user:INSECURE_REPLACE_ME_PLEASE_INSECURE@database:5432/patchmon_db
PM_DB_CONN_MAX_ATTEMPTS: 30
PM_DB_CONN_WAIT_INTERVAL: 2
SERVER_PROTOCOL: http
SERVER_HOST: localhost
SERVER_PORT: 3000
CORS_ORIGIN: http://localhost:3000
RATE_LIMIT_WINDOW_MS: 900000
RATE_LIMIT_MAX: 100
volumes:
- agent_files:/app/agents
depends_on:
database:
condition: service_healthy
frontend:
image: ghcr.io/9technologygroup/patchmon-frontend:latest
restart: unless-stopped
ports:
- "3000:3000"
depends_on:
backend:
condition: service_healthy
volumes:
postgres_data:
agent_files:

View File

@@ -0,0 +1,42 @@
# Development target
FROM node:lts-alpine AS development
WORKDIR /app
COPY package*.json ./
COPY frontend/ ./frontend/
RUN npm ci --ignore-scripts
WORKDIR /app/frontend
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
# Builder stage for production
FROM node:lts-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY frontend/package*.json ./frontend/
RUN npm ci --ignore-scripts
COPY frontend/ ./frontend/
RUN npm run build:frontend
# Production stage
FROM nginxinc/nginx-unprivileged:alpine
ENV BACKEND_HOST=backend \
BACKEND_PORT=3001
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
COPY docker/nginx.conf.template /etc/nginx/templates/default.conf.template
EXPOSE 3000
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,2 @@
**/Dockerfile
**/dist

View File

@@ -0,0 +1,67 @@
server {
listen 3000;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
tcp_nopush on;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/xml+rss
application/json
application/xml;
# Security headers
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Handle client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# API proxy
location /api/ {
proxy_pass http://${BACKEND_HOST}:${BACKEND_PORT};
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;
# 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;
# Handle preflight requests
if ($request_method = 'OPTIONS') {
return 204;
}
}
# Static assets caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Health check endpoint
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}

View File

@@ -1,43 +1,44 @@
{
"name": "patchmon-frontend",
"private": true,
"version": "1.2.4",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.87.4",
"axios": "^1.6.2",
"chart.js": "^4.4.0",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"lucide-react": "^0.294.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.1"
},
"devDependencies": {
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.16",
"eslint": "^8.53.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"vite": "^7.1.5"
},
"overrides": {
"esbuild": "^0.24.4"
}
"name": "patchmon-frontend",
"private": true,
"version": "1.2.7",
"license": "AGPL-3.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "biome check .",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.87.4",
"axios": "^1.7.9",
"chart.js": "^4.4.7",
"clsx": "^2.1.1",
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"express": "^4.21.2",
"http-proxy-middleware": "^3.0.3",
"lucide-react": "^0.468.0",
"react": "^18.3.1",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.3.1",
"react-icons": "^5.5.0",
"react-router-dom": "^6.30.1"
},
"devDependencies": {
"@types/react": "^18.3.14",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"vite": "^7.1.5"
},
"overrides": {
"esbuild": "^0.25.10"
}
}

View File

@@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

50
frontend/server.js Normal file
View File

@@ -0,0 +1,50 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import cors from "cors";
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 3000;
const BACKEND_URL = process.env.BACKEND_URL || "http://backend:3001";
// Enable CORS for API calls
app.use(
cors({
origin: process.env.CORS_ORIGIN || "*",
credentials: true,
}),
);
// Proxy API requests to backend
app.use(
"/api",
createProxyMiddleware({
target: BACKEND_URL,
changeOrigin: true,
logLevel: "info",
onError: (err, _req, res) => {
console.error("Proxy error:", err.message);
res.status(500).json({ error: "Backend service unavailable" });
},
onProxyReq: (_proxyReq, req, _res) => {
console.log(`Proxying ${req.method} ${req.path} to ${BACKEND_URL}`);
},
}),
);
// Serve static files from dist directory
app.use(express.static(path.join(__dirname, "dist")));
// Handle SPA routing - serve index.html for all routes
app.get("*", (_req, res) => {
res.sendFile(path.join(__dirname, "dist", "index.html"));
});
app.listen(PORT, () => {
console.log(`Frontend server running on port ${PORT}`);
console.log(`Serving from: ${path.join(__dirname, "dist")}`);
});

View File

@@ -1,117 +1,339 @@
import React from 'react'
import { Routes, Route } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext'
import { ThemeProvider } from './contexts/ThemeContext'
import ProtectedRoute from './components/ProtectedRoute'
import Layout from './components/Layout'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import Hosts from './pages/Hosts'
import HostGroups from './pages/HostGroups'
import Packages from './pages/Packages'
import Repositories from './pages/Repositories'
import RepositoryDetail from './pages/RepositoryDetail'
import Users from './pages/Users'
import Permissions from './pages/Permissions'
import Settings from './pages/Settings'
import Profile from './pages/Profile'
import HostDetail from './pages/HostDetail'
import PackageDetail from './pages/PackageDetail'
import { Route, Routes } from "react-router-dom";
import FirstTimeAdminSetup from "./components/FirstTimeAdminSetup";
import Layout from "./components/Layout";
import ProtectedRoute from "./components/ProtectedRoute";
import SettingsLayout from "./components/SettingsLayout";
import { isAuthPhase } from "./constants/authPhases";
import { AuthProvider, useAuth } from "./contexts/AuthContext";
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";
function App() {
return (
<ThemeProvider>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<ProtectedRoute requirePermission="canViewDashboard">
<Layout>
<Dashboard />
</Layout>
</ProtectedRoute>
} />
<Route path="/hosts" element={
<ProtectedRoute requirePermission="canViewHosts">
<Layout>
<Hosts />
</Layout>
</ProtectedRoute>
} />
<Route path="/hosts/:hostId" element={
<ProtectedRoute requirePermission="canViewHosts">
<Layout>
<HostDetail />
</Layout>
</ProtectedRoute>
} />
<Route path="/host-groups" element={
<ProtectedRoute requirePermission="canManageHosts">
<Layout>
<HostGroups />
</Layout>
</ProtectedRoute>
} />
<Route path="/packages" element={
<ProtectedRoute requirePermission="canViewPackages">
<Layout>
<Packages />
</Layout>
</ProtectedRoute>
} />
<Route path="/repositories" element={
<ProtectedRoute requirePermission="canViewHosts">
<Layout>
<Repositories />
</Layout>
</ProtectedRoute>
} />
<Route path="/repositories/:repositoryId" element={
<ProtectedRoute requirePermission="canViewHosts">
<Layout>
<RepositoryDetail />
</Layout>
</ProtectedRoute>
} />
<Route path="/users" element={
<ProtectedRoute requirePermission="canViewUsers">
<Layout>
<Users />
</Layout>
</ProtectedRoute>
} />
<Route path="/permissions" element={
<ProtectedRoute requirePermission="canManageSettings">
<Layout>
<Permissions />
</Layout>
</ProtectedRoute>
} />
<Route path="/settings" element={
<ProtectedRoute requirePermission="canManageSettings">
<Layout>
<Settings />
</Layout>
</ProtectedRoute>
} />
<Route path="/profile" element={
<ProtectedRoute>
<Layout>
<Profile />
</Layout>
</ProtectedRoute>
} />
<Route path="/packages/:packageId" element={
<ProtectedRoute requirePermission="canViewPackages">
<Layout>
<PackageDetail />
</Layout>
</ProtectedRoute>
} />
</Routes>
</AuthProvider>
</ThemeProvider>
)
function AppRoutes() {
const { needsFirstTimeSetup, authPhase, isAuthenticated } = useAuth();
const isAuth = isAuthenticated(); // Call the function to get boolean value
// Show loading while checking setup or initialising
if (
isAuthPhase.initialising(authPhase) ||
isAuthPhase.checkingSetup(authPhase)
) {
return (
<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">
Checking system status...
</p>
</div>
</div>
);
}
// Show first-time setup if no admin users exist
if (needsFirstTimeSetup && !isAuth) {
return <FirstTimeAdminSetup />;
}
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/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>
);
}
export default App
function App() {
return (
<ThemeProvider>
<AuthProvider>
<UpdateNotificationProvider>
<AppRoutes />
</UpdateNotificationProvider>
</AuthProvider>
</ThemeProvider>
);
}
export default App;

View File

@@ -1,306 +1,366 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import {
X,
GripVertical,
Eye,
EyeOff,
Save,
RotateCcw,
Settings as SettingsIcon
} from 'lucide-react';
import { dashboardPreferencesAPI } from '../utils/api';
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Eye,
EyeOff,
GripVertical,
RotateCcw,
Save,
Settings as SettingsIcon,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import { dashboardPreferencesAPI } from "../utils/api";
// Sortable Card Item Component
const SortableCardItem = ({ card, onToggle }) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: card.cardId });
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: card.cardId,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center justify-between p-3 bg-white border border-secondary-200 rounded-lg ${
isDragging ? 'shadow-lg' : 'shadow-sm'
}`}
>
<div className="flex items-center gap-3">
<button
{...attributes}
{...listeners}
className="text-secondary-400 hover:text-secondary-600 cursor-grab active:cursor-grabbing"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-secondary-900">
{card.title}
</div>
</div>
</div>
<button
onClick={() => onToggle(card.cardId)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
card.enabled
? 'bg-green-100 text-green-800 hover:bg-green-200'
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
}`}
>
{card.enabled ? (
<>
<Eye className="h-3 w-3" />
Visible
</>
) : (
<>
<EyeOff className="h-3 w-3" />
Hidden
</>
)}
</button>
</div>
);
return (
<div
ref={setNodeRef}
style={style}
className={`flex items-center justify-between p-3 bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg ${
isDragging ? "shadow-lg" : "shadow-sm"
}`}
>
<div className="flex items-center gap-3">
<button
{...attributes}
{...listeners}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 cursor-grab active:cursor-grabbing"
>
<GripVertical className="h-4 w-4" />
</button>
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{card.title}
{card.typeLabel ? (
<span className="ml-2 text-xs font-normal text-secondary-500 dark:text-secondary-400">
({card.typeLabel})
</span>
) : null}
</div>
</div>
</div>
<button
type="button"
onClick={() => onToggle(card.cardId)}
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
card.enabled
? "bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 hover:bg-green-200 dark:hover:bg-green-800"
: "bg-secondary-100 dark:bg-secondary-700 text-secondary-600 dark:text-secondary-300 hover:bg-secondary-200 dark:hover:bg-secondary-600"
}`}
>
{card.enabled ? (
<>
<Eye className="h-3 w-3" />
Visible
</>
) : (
<>
<EyeOff className="h-3 w-3" />
Hidden
</>
)}
</button>
</div>
);
};
const DashboardSettingsModal = ({ isOpen, onClose }) => {
const [cards, setCards] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const queryClient = useQueryClient();
const [cards, setCards] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const queryClient = useQueryClient();
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
// Fetch user's dashboard preferences
const { data: preferences, isLoading } = useQuery({
queryKey: ['dashboardPreferences'],
queryFn: () => dashboardPreferencesAPI.get().then(res => res.data),
enabled: isOpen
});
// Fetch user's dashboard preferences
const { data: preferences, isLoading } = useQuery({
queryKey: ["dashboardPreferences"],
queryFn: () => dashboardPreferencesAPI.get().then((res) => res.data),
enabled: isOpen,
});
// Fetch default card configuration
const { data: defaultCards } = useQuery({
queryKey: ['dashboardDefaultCards'],
queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data),
enabled: isOpen
});
// Fetch default card configuration
const { data: defaultCards } = useQuery({
queryKey: ["dashboardDefaultCards"],
queryFn: () =>
dashboardPreferencesAPI.getDefaults().then((res) => res.data),
enabled: isOpen,
});
// Update preferences mutation
const updatePreferencesMutation = useMutation({
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
onSuccess: (response) => {
// Optimistically update the query cache with the correct data structure
queryClient.setQueryData(['dashboardPreferences'], response.data.preferences);
// Also invalidate to ensure fresh data
queryClient.invalidateQueries(['dashboardPreferences']);
setHasChanges(false);
onClose();
},
onError: (error) => {
console.error('Failed to update dashboard preferences:', error);
}
});
// Update preferences mutation
const updatePreferencesMutation = useMutation({
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
onSuccess: (response) => {
// Optimistically update the query cache with the correct data structure
queryClient.setQueryData(
["dashboardPreferences"],
response.data.preferences,
);
// Also invalidate to ensure fresh data
queryClient.invalidateQueries(["dashboardPreferences"]);
setHasChanges(false);
onClose();
},
onError: (error) => {
console.error("Failed to update dashboard preferences:", error);
},
});
// Initialize cards when preferences or defaults are loaded
useEffect(() => {
if (preferences && defaultCards) {
// Merge user preferences with default cards
const mergedCards = defaultCards.map(defaultCard => {
const userPreference = preferences.find(p => p.cardId === defaultCard.cardId);
return {
...defaultCard,
enabled: userPreference ? userPreference.enabled : defaultCard.enabled,
order: userPreference ? userPreference.order : defaultCard.order
};
}).sort((a, b) => a.order - b.order);
setCards(mergedCards);
}
}, [preferences, defaultCards]);
// Initialize cards when preferences or defaults are loaded
useEffect(() => {
if (preferences && defaultCards) {
// Normalize server preferences (snake_case -> camelCase)
const normalizedPreferences = preferences.map((p) => ({
cardId: p.cardId ?? p.card_id,
enabled: p.enabled,
order: p.order,
}));
const handleDragEnd = (event) => {
const { active, over } = event;
const typeLabelFor = (cardId) => {
if (
[
"totalHosts",
"hostsNeedingUpdates",
"totalOutdatedPackages",
"securityUpdates",
"upToDateHosts",
"totalHostGroups",
"totalUsers",
"totalRepos",
].includes(cardId)
)
return "Top card";
if (cardId === "osDistribution") return "Pie chart";
if (cardId === "osDistributionBar") return "Bar chart";
if (cardId === "osDistributionDoughnut") return "Doughnut chart";
if (cardId === "updateStatus") return "Pie chart";
if (cardId === "packagePriority") return "Pie chart";
if (cardId === "recentUsers") return "Table";
if (cardId === "recentCollection") return "Table";
if (cardId === "quickStats") return "Wide card";
return undefined;
};
if (active.id !== over.id) {
setCards((items) => {
const oldIndex = items.findIndex(item => item.cardId === active.id);
const newIndex = items.findIndex(item => item.cardId === over.id);
const newItems = arrayMove(items, oldIndex, newIndex);
// Update order values
return newItems.map((item, index) => ({
...item,
order: index
}));
});
setHasChanges(true);
}
};
// Merge user preferences with default cards
const mergedCards = defaultCards
.map((defaultCard) => {
const userPreference = normalizedPreferences.find(
(p) => p.cardId === defaultCard.cardId,
);
return {
...defaultCard,
enabled: userPreference
? userPreference.enabled
: defaultCard.enabled,
order: userPreference ? userPreference.order : defaultCard.order,
typeLabel: typeLabelFor(defaultCard.cardId),
};
})
.sort((a, b) => a.order - b.order);
const handleToggle = (cardId) => {
setCards(prevCards =>
prevCards.map(card =>
card.cardId === cardId
? { ...card, enabled: !card.enabled }
: card
)
);
setHasChanges(true);
};
setCards(mergedCards);
}
}, [preferences, defaultCards]);
const handleSave = () => {
const preferences = cards.map(card => ({
cardId: card.cardId,
enabled: card.enabled,
order: card.order
}));
updatePreferencesMutation.mutate(preferences);
};
const handleDragEnd = (event) => {
const { active, over } = event;
const handleReset = () => {
if (defaultCards) {
const resetCards = defaultCards.map(card => ({
...card,
enabled: true,
order: card.order
}));
setCards(resetCards);
setHasChanges(true);
}
};
if (active.id !== over.id) {
setCards((items) => {
const oldIndex = items.findIndex((item) => item.cardId === active.id);
const newIndex = items.findIndex((item) => item.cardId === over.id);
if (!isOpen) return null;
const newItems = arrayMove(items, oldIndex, newIndex);
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<div className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity" onClick={onClose} />
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<SettingsIcon className="h-5 w-5 text-primary-600" />
<h3 className="text-lg font-medium text-secondary-900">
Dashboard Settings
</h3>
</div>
<button
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600"
>
<X className="h-5 w-5" />
</button>
</div>
<p className="text-sm text-secondary-600 mb-6">
Customize your dashboard by reordering cards and toggling their visibility.
Drag cards to reorder them, and click the visibility toggle to show/hide cards.
</p>
// Update order values
return newItems.map((item, index) => ({
...item,
order: index,
}));
});
setHasChanges(true);
}
};
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext items={cards.map(card => card.cardId)} strategy={verticalListSortingStrategy}>
<div className="space-y-2 max-h-96 overflow-y-auto">
{cards.map((card) => (
<SortableCardItem
key={card.cardId}
card={card}
onToggle={handleToggle}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
<div className="bg-secondary-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
onClick={handleSave}
disabled={!hasChanges || updatePreferencesMutation.isPending}
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm ${
!hasChanges || updatePreferencesMutation.isPending
? 'bg-secondary-400 cursor-not-allowed'
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
}`}
>
{updatePreferencesMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</button>
<button
onClick={handleReset}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-secondary-700 hover:bg-secondary-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
</button>
<button
onClick={onClose}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-secondary-700 hover:bg-secondary-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
const handleToggle = (cardId) => {
setCards((prevCards) =>
prevCards.map((card) =>
card.cardId === cardId ? { ...card, enabled: !card.enabled } : card,
),
);
setHasChanges(true);
};
const handleSave = () => {
const preferences = cards.map((card) => ({
cardId: card.cardId,
enabled: card.enabled,
order: card.order,
}));
updatePreferencesMutation.mutate(preferences);
};
const handleReset = () => {
if (defaultCards) {
const resetCards = defaultCards.map((card) => ({
...card,
enabled: true,
order: card.order,
}));
setCards(resetCards);
setHasChanges(true);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<button
type="button"
className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity cursor-default"
onClick={onClose}
aria-label="Close modal"
/>
<div className="inline-block align-bottom bg-white dark:bg-secondary-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
<div className="bg-white dark:bg-secondary-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<SettingsIcon className="h-5 w-5 text-primary-600" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Dashboard Settings
</h3>
</div>
<button
type="button"
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
>
<X className="h-5 w-5" />
</button>
</div>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-6">
Customize your dashboard by reordering cards and toggling their
visibility. Drag cards to reorder them, and click the visibility
toggle to show/hide cards.
</p>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={cards.map((card) => card.cardId)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2 max-h-96 overflow-y-auto">
{cards.map((card) => (
<SortableCardItem
key={card.cardId}
card={card}
onToggle={handleToggle}
/>
))}
</div>
</SortableContext>
</DndContext>
)}
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
type="button"
onClick={handleSave}
disabled={!hasChanges || updatePreferencesMutation.isPending}
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm ${
!hasChanges || updatePreferencesMutation.isPending
? "bg-secondary-400 cursor-not-allowed"
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
}`}
>
{updatePreferencesMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Changes
</>
)}
</button>
<button
type="button"
onClick={handleReset}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
</button>
<button
type="button"
onClick={onClose}
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 dark:border-secondary-600 shadow-sm px-4 py-2 bg-white dark:bg-secondary-800 text-base font-medium text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
>
Cancel
</button>
</div>
</div>
</div>
</div>
);
};
export default DashboardSettingsModal;

View File

@@ -0,0 +1,348 @@
import { AlertCircle, CheckCircle, Shield, UserPlus } from "lucide-react";
import { useId, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
const FirstTimeAdminSetup = () => {
const { login, setAuthState } = useAuth();
const navigate = useNavigate();
const firstNameId = useId();
const lastNameId = useId();
const usernameId = useId();
const emailId = useId();
const passwordId = useId();
const confirmPasswordId = useId();
const [formData, setFormData] = useState({
username: "",
email: "",
password: "",
confirmPassword: "",
firstName: "",
lastName: "",
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
// Clear error when user starts typing
if (error) setError("");
};
const validateForm = () => {
if (!formData.firstName.trim()) {
setError("First name is required");
return false;
}
if (!formData.lastName.trim()) {
setError("Last name is required");
return false;
}
if (!formData.username.trim()) {
setError("Username is required");
return false;
}
if (!formData.email.trim()) {
setError("Email address is required");
return false;
}
// Enhanced email validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email.trim())) {
setError("Please enter a valid email address (e.g., user@example.com)");
return false;
}
if (formData.password.length < 8) {
setError("Password must be at least 8 characters for security");
return false;
}
if (formData.password !== formData.confirmPassword) {
setError("Passwords do not match");
return false;
}
return true;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) return;
setIsLoading(true);
setError("");
try {
const response = await fetch("/api/v1/auth/setup-admin", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: formData.username.trim(),
email: formData.email.trim(),
password: formData.password,
firstName: formData.firstName.trim(),
lastName: formData.lastName.trim(),
}),
});
const data = await response.json();
if (response.ok) {
setSuccess(true);
// If the response includes a token, use it to automatically log in
if (data.token && data.user) {
// Set the authentication state immediately
setAuthState(data.token, data.user);
// Navigate to dashboard after successful setup
setTimeout(() => {
navigate("/", { replace: true });
}, 100); // Small delay to ensure auth state is set
} else {
// Fallback to manual login if no token provided
setTimeout(async () => {
try {
await login(formData.username.trim(), formData.password);
} catch (error) {
console.error("Auto-login failed:", error);
setError(
"Account created but auto-login failed. Please login manually.",
);
setSuccess(false);
}
}, 2000);
}
} else {
setError(data.error || "Failed to create admin user");
}
} catch (error) {
console.error("Setup error:", error);
setError("Network error. Please try again.");
} finally {
setIsLoading(false);
}
};
if (success) {
return (
<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 p-4">
<div className="max-w-md w-full">
<div className="card p-8 text-center">
<div className="flex justify-center mb-6">
<div className="bg-green-100 dark:bg-green-900 p-4 rounded-full">
<CheckCircle className="h-12 w-12 text-green-600 dark:text-green-400" />
</div>
</div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white mb-4">
Admin Account Created!
</h1>
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
Your admin account has been successfully created and you are now
logged in. Redirecting to the dashboard...
</p>
<div className="flex justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
</div>
</div>
</div>
</div>
);
}
return (
<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 p-4">
<div className="max-w-md w-full">
<div className="card p-8">
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="bg-primary-100 dark:bg-primary-900 p-4 rounded-full">
<Shield className="h-12 w-12 text-primary-600 dark:text-primary-400" />
</div>
</div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white mb-2">
Welcome to PatchMon
</h1>
<p className="text-secondary-600 dark:text-secondary-300">
Let's set up your admin account to get started
</p>
</div>
{error && (
<div className="mb-6 p-4 bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-lg">
<div className="flex items-center">
<AlertCircle className="h-5 w-5 text-danger-600 dark:text-danger-400 mr-2" />
<span className="text-danger-700 dark:text-danger-300 text-sm">
{error}
</span>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
htmlFor={firstNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
First Name
</label>
<input
type="text"
id={firstNameId}
name="firstName"
value={formData.firstName}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your first name"
required
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor={lastNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Last Name
</label>
<input
type="text"
id={lastNameId}
name="lastName"
value={formData.lastName}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your last name"
required
disabled={isLoading}
/>
</div>
</div>
<div>
<label
htmlFor={usernameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Username
</label>
<input
type="text"
id={usernameId}
name="username"
value={formData.username}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor={emailId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Email Address
</label>
<input
type="email"
id={emailId}
name="email"
value={formData.email}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your email"
required
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor={passwordId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Password
</label>
<input
type="password"
id={passwordId}
name="password"
value={formData.password}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your password (min 8 characters)"
required
disabled={isLoading}
/>
</div>
<div>
<label
htmlFor={confirmPasswordId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2"
>
Confirm Password
</label>
<input
type="password"
id={confirmPasswordId}
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleInputChange}
className="input w-full"
placeholder="Confirm your password"
required
disabled={isLoading}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="btn-primary w-full flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Creating Admin Account...
</>
) : (
<>
<UserPlus className="h-4 w-4" />
Create Admin Account
</>
)}
</button>
</form>
<div className="mt-8 p-4 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-lg">
<div className="flex items-start">
<Shield className="h-5 w-5 text-blue-600 dark:text-blue-400 mr-2 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-700 dark:text-blue-300">
<p className="font-medium mb-1">Admin Privileges</p>
<p>
This account will have full administrative access to manage
users, hosts, packages, and system settings.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default FirstTimeAdminSetup;

View File

@@ -0,0 +1,428 @@
import { GitBranch, Package, Search, Server, User, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { searchAPI } from "../utils/api";
const GlobalSearch = () => {
const [query, setQuery] = useState("");
const [results, setResults] = useState(null);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const searchRef = useRef(null);
const inputRef = useRef(null);
const navigate = useNavigate();
// Debounce search
const debounceTimerRef = useRef(null);
const performSearch = useCallback(async (searchQuery) => {
if (!searchQuery || searchQuery.trim().length === 0) {
setResults(null);
setIsOpen(false);
return;
}
setIsLoading(true);
try {
const response = await searchAPI.global(searchQuery);
setResults(response.data);
setIsOpen(true);
setSelectedIndex(-1);
} catch (error) {
console.error("Search error:", error);
setResults(null);
} finally {
setIsLoading(false);
}
}, []);
const handleInputChange = (e) => {
const value = e.target.value;
setQuery(value);
// Clear previous timer
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
// Set new timer
debounceTimerRef.current = setTimeout(() => {
performSearch(value);
}, 300);
};
const handleClear = () => {
// Clear debounce timer to prevent any pending searches
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
setQuery("");
setResults(null);
setIsOpen(false);
setSelectedIndex(-1);
inputRef.current?.focus();
};
const handleResultClick = (result) => {
// Navigate based on result type
switch (result.type) {
case "host":
navigate(`/hosts/${result.id}`);
break;
case "package":
navigate(`/packages/${result.id}`);
break;
case "repository":
navigate(`/repositories/${result.id}`);
break;
case "user":
// Users don't have detail pages, so navigate to settings
navigate("/settings/users");
break;
default:
break;
}
// Close dropdown and clear
handleClear();
};
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event) => {
if (searchRef.current && !searchRef.current.contains(event.target)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, []);
// Keyboard navigation
const flattenedResults = [];
if (results) {
if (results.hosts?.length > 0) {
flattenedResults.push({ type: "header", label: "Hosts" });
flattenedResults.push(...results.hosts);
}
if (results.packages?.length > 0) {
flattenedResults.push({ type: "header", label: "Packages" });
flattenedResults.push(...results.packages);
}
if (results.repositories?.length > 0) {
flattenedResults.push({ type: "header", label: "Repositories" });
flattenedResults.push(...results.repositories);
}
if (results.users?.length > 0) {
flattenedResults.push({ type: "header", label: "Users" });
flattenedResults.push(...results.users);
}
}
const navigableResults = flattenedResults.filter((r) => r.type !== "header");
const handleKeyDown = (e) => {
if (!isOpen || !results) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((prev) =>
prev < navigableResults.length - 1 ? prev + 1 : prev,
);
break;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1));
break;
case "Enter":
e.preventDefault();
if (selectedIndex >= 0 && navigableResults[selectedIndex]) {
handleResultClick(navigableResults[selectedIndex]);
}
break;
case "Escape":
e.preventDefault();
setIsOpen(false);
setSelectedIndex(-1);
break;
default:
break;
}
};
// Get icon for result type
const getResultIcon = (type) => {
switch (type) {
case "host":
return <Server className="h-4 w-4 text-blue-500" />;
case "package":
return <Package className="h-4 w-4 text-green-500" />;
case "repository":
return <GitBranch className="h-4 w-4 text-purple-500" />;
case "user":
return <User className="h-4 w-4 text-orange-500" />;
default:
return null;
}
};
// Get display text for result
const getResultDisplay = (result) => {
switch (result.type) {
case "host":
return {
primary: result.friendly_name || result.hostname,
secondary: result.ip || result.hostname,
};
case "package":
return {
primary: result.name,
secondary: result.description || result.category,
};
case "repository":
return {
primary: result.name,
secondary: result.distribution,
};
case "user":
return {
primary: result.username,
secondary: result.email,
};
default:
return { primary: "", secondary: "" };
}
};
const hasResults =
results &&
(results.hosts?.length > 0 ||
results.packages?.length > 0 ||
results.repositories?.length > 0 ||
results.users?.length > 0);
return (
<div ref={searchRef} className="relative w-full max-w-sm">
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Search className="h-5 w-5 text-secondary-400" />
</div>
<input
ref={inputRef}
type="text"
className="block w-full rounded-lg border border-secondary-200 bg-white py-2 pl-10 pr-10 text-sm text-secondary-900 placeholder-secondary-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:border-secondary-600 dark:bg-secondary-700 dark:text-white dark:placeholder-secondary-400"
placeholder="Search hosts, packages, repos, users..."
value={query}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={() => {
if (query && results) setIsOpen(true);
}}
/>
{query && (
<button
type="button"
onClick={handleClear}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-secondary-400 hover:text-secondary-600"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Dropdown Results */}
{isOpen && (
<div className="absolute z-50 mt-2 w-full rounded-lg border border-secondary-200 bg-white shadow-lg dark:border-secondary-600 dark:bg-secondary-800">
{isLoading ? (
<div className="px-4 py-2 text-center text-sm text-secondary-500">
Searching...
</div>
) : hasResults ? (
<div className="max-h-96 overflow-y-auto">
{/* Hosts */}
{results.hosts?.length > 0 && (
<div>
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
Hosts
</div>
{results.hosts.map((host, idx) => {
const display = getResultDisplay(host);
const globalIdx = navigableResults.findIndex(
(r) => r.id === host.id && r.type === "host",
);
return (
<button
type="button"
key={host.id}
onClick={() => handleResultClick(host)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
globalIdx === selectedIndex
? "bg-primary-50 dark:bg-primary-900/20"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
>
{getResultIcon("host")}
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
{display.primary}
</span>
<span className="text-xs text-secondary-400"></span>
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
{display.secondary}
</span>
</div>
<div className="flex-shrink-0 text-xs text-secondary-400">
{host.os_type}
</div>
</button>
);
})}
</div>
)}
{/* Packages */}
{results.packages?.length > 0 && (
<div>
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
Packages
</div>
{results.packages.map((pkg, idx) => {
const display = getResultDisplay(pkg);
const globalIdx = navigableResults.findIndex(
(r) => r.id === pkg.id && r.type === "package",
);
return (
<button
type="button"
key={pkg.id}
onClick={() => handleResultClick(pkg)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
globalIdx === selectedIndex
? "bg-primary-50 dark:bg-primary-900/20"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
>
{getResultIcon("package")}
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
{display.primary}
</span>
{display.secondary && (
<>
<span className="text-xs text-secondary-400">
</span>
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
{display.secondary}
</span>
</>
)}
</div>
<div className="flex-shrink-0 text-xs text-secondary-400">
{pkg.host_count} hosts
</div>
</button>
);
})}
</div>
)}
{/* Repositories */}
{results.repositories?.length > 0 && (
<div>
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
Repositories
</div>
{results.repositories.map((repo, idx) => {
const display = getResultDisplay(repo);
const globalIdx = navigableResults.findIndex(
(r) => r.id === repo.id && r.type === "repository",
);
return (
<button
type="button"
key={repo.id}
onClick={() => handleResultClick(repo)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
globalIdx === selectedIndex
? "bg-primary-50 dark:bg-primary-900/20"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
>
{getResultIcon("repository")}
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
{display.primary}
</span>
<span className="text-xs text-secondary-400"></span>
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
{display.secondary}
</span>
</div>
<div className="flex-shrink-0 text-xs text-secondary-400">
{repo.host_count} hosts
</div>
</button>
);
})}
</div>
)}
{/* Users */}
{results.users?.length > 0 && (
<div>
<div className="sticky top-0 z-10 bg-secondary-50 px-3 py-1.5 text-xs font-semibold uppercase tracking-wider text-secondary-500 dark:bg-secondary-700 dark:text-secondary-400">
Users
</div>
{results.users.map((user, idx) => {
const display = getResultDisplay(user);
const globalIdx = navigableResults.findIndex(
(r) => r.id === user.id && r.type === "user",
);
return (
<button
type="button"
key={user.id}
onClick={() => handleResultClick(user)}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left transition-colors ${
globalIdx === selectedIndex
? "bg-primary-50 dark:bg-primary-900/20"
: "hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
>
{getResultIcon("user")}
<div className="flex-1 min-w-0 flex items-center gap-2">
<span className="text-sm font-medium text-secondary-900 dark:text-white truncate">
{display.primary}
</span>
<span className="text-xs text-secondary-400"></span>
<span className="text-xs text-secondary-500 dark:text-secondary-400 truncate">
{display.secondary}
</span>
</div>
<div className="flex-shrink-0 text-xs text-secondary-400">
{user.role}
</div>
</button>
);
})}
</div>
)}
</div>
) : query.trim() ? (
<div className="px-4 py-2 text-center text-sm text-secondary-500">
No results found for "{query}"
</div>
) : null}
</div>
)}
</div>
);
};
export default GlobalSearch;

View File

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

View File

@@ -0,0 +1,272 @@
import { Check, ChevronDown, Edit2, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
const InlineGroupEdit = ({
value,
onSave,
onCancel,
options = [],
className = "",
disabled = false,
}) => {
const [isEditing, setIsEditing] = useState(false);
const [selectedValue, setSelectedValue] = useState(value);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({
top: 0,
left: 0,
width: 0,
});
const dropdownRef = useRef(null);
const buttonRef = useRef(null);
useEffect(() => {
if (isEditing && dropdownRef.current) {
dropdownRef.current.focus();
}
}, [isEditing]);
useEffect(() => {
setSelectedValue(value);
// Force re-render when value changes
if (!isEditing) {
setIsOpen(false);
}
}, [value, isEditing]);
// Calculate dropdown position
const calculateDropdownPosition = 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);
setSelectedValue(value);
setError("");
// Automatically open dropdown when editing starts
setTimeout(() => {
setIsOpen(true);
}, 0);
};
const handleCancel = () => {
setIsEditing(false);
setSelectedValue(value);
setError("");
setIsOpen(false);
if (onCancel) onCancel();
};
const handleSave = async () => {
if (disabled || isLoading) return;
// Check if value actually changed
if (selectedValue === value) {
setIsEditing(false);
setIsOpen(false);
return;
}
setIsLoading(true);
setError("");
try {
await onSave(selectedValue);
// Update the local value to match the saved value
setSelectedValue(selectedValue);
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 displayValue = useMemo(() => {
if (!value) {
return "Ungrouped";
}
const option = options.find((opt) => opt.id === value);
return option ? option.name : "Unknown Group";
}, [value, options]);
const displayColor = useMemo(() => {
if (!value) return "bg-secondary-100 text-secondary-800";
const option = options.find((opt) => opt.id === value);
return option ? `text-white` : "bg-secondary-100 text-secondary-800";
}, [value, options]);
const selectedOption = useMemo(() => {
return options.find((opt) => opt.id === value);
}, [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">
{selectedValue
? options.find((opt) => opt.id === selectedValue)?.name ||
"Unknown Group"
: "Ungrouped"}
</span>
<ChevronDown className="h-4 w-4 flex-shrink-0" />
</button>
{isOpen && (
<div
className="fixed z-50 bg-white dark:bg-secondary-800 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-lg max-h-60 overflow-auto"
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
width: `${dropdownPosition.width}px`,
minWidth: "200px",
}}
>
<div className="py-1">
<button
type="button"
onClick={() => {
setSelectedValue(null);
setIsOpen(false);
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
selectedValue === null
? "bg-primary-50 dark:bg-primary-900/20"
: ""
}`}
>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 text-secondary-800">
Ungrouped
</span>
</button>
{options.map((option) => (
<button
key={option.id}
type="button"
onClick={() => {
setSelectedValue(option.id);
setIsOpen(false);
}}
className={`w-full px-3 py-2 text-left text-sm hover:bg-secondary-100 dark:hover:bg-secondary-700 flex items-center ${
selectedValue === option.id
? "bg-primary-50 dark:bg-primary-900/20"
: ""
}`}
>
<span
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: option.color }}
>
{option.name}
</span>
</button>
))}
</div>
</div>
)}
</div>
<button
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-2 group ${className}`}>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${displayColor}`}
style={value ? { backgroundColor: selectedOption?.color } : {}}
>
{displayValue}
</span>
{!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 group"
>
<Edit2 className="h-3 w-3" />
</button>
)}
</div>
);
};
export default InlineGroupEdit;

View File

@@ -0,0 +1,80 @@
import { useState } from "react";
const InlineToggle = ({
value,
onSave,
className = "",
disabled = false,
trueLabel = "Yes",
falseLabel = "No",
}) => {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSave = async (newValue) => {
if (disabled || isLoading) return;
// Check if value actually changed
if (newValue === value) {
return;
}
setIsLoading(true);
setError("");
try {
await onSave(newValue);
} catch (err) {
setError(err.message || "Failed to save");
} finally {
setIsLoading(false);
}
};
const handleToggle = () => {
if (disabled || isLoading) return;
handleSave(!value);
};
const displayValue = (
<span
className={`text-sm font-medium ${
value
? "text-green-600 dark:text-green-400"
: "text-red-600 dark:text-red-400"
}`}
>
{value ? trueLabel : falseLabel}
</span>
);
return (
<div className={`flex items-center gap-2 group ${className}`}>
{displayValue}
{!disabled && (
<button
type="button"
onClick={handleToggle}
disabled={isLoading}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ${
value
? "bg-primary-600 dark:bg-primary-500"
: "bg-secondary-200 dark:bg-secondary-600"
}`}
title={`Toggle ${value ? "off" : "on"}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
value ? "translate-x-5" : "translate-x-1"
}`}
/>
</button>
)}
{error && (
<span className="text-xs text-red-600 dark:text-red-400">{error}</span>
)}
</div>
);
};
export default InlineToggle;

File diff suppressed because it is too large Load Diff

View File

@@ -1,47 +1,58 @@
import React from 'react'
import { Navigate } from 'react-router-dom'
import { useAuth } from '../contexts/AuthContext'
import { Navigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = null }) => {
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth()
const ProtectedRoute = ({
children,
requireAdmin = false,
requirePermission = null,
}) => {
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth();
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (!isAuthenticated()) {
return <Navigate to="/login" replace />
}
if (!isAuthenticated()) {
return <Navigate to="/login" replace />;
}
// Check admin requirement
if (requireAdmin && !isAdmin()) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
<p className="text-secondary-600">You don't have permission to access this page.</p>
</div>
</div>
)
}
// Check admin requirement
if (requireAdmin && !isAdmin()) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">
Access Denied
</h2>
<p className="text-secondary-600">
You don't have permission to access this page.
</p>
</div>
</div>
);
}
// Check specific permission requirement
if (requirePermission && !hasPermission(requirePermission)) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
<p className="text-secondary-600">You don't have permission to access this page.</p>
</div>
</div>
)
}
// Check specific permission requirement
if (requirePermission && !hasPermission(requirePermission)) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<h2 className="text-xl font-semibold text-secondary-900 mb-2">
Access Denied
</h2>
<p className="text-secondary-600">
You don't have permission to access this page.
</p>
</div>
</div>
);
}
return children
}
return children;
};
export default ProtectedRoute
export default ProtectedRoute;

View File

@@ -0,0 +1,273 @@
import {
Bell,
ChevronLeft,
ChevronRight,
Code,
Folder,
RefreshCw,
Settings,
Shield,
UserCircle,
Users,
Wrench,
} from "lucide-react";
import { useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
const SettingsLayout = ({ children }) => {
const location = useLocation();
const { canManageSettings, canViewUsers, canManageUsers } = useAuth();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// Build secondary navigation based on permissions
const buildSecondaryNavigation = () => {
const nav = [];
// Users section
if (canViewUsers() || canManageUsers()) {
nav.push({
section: "User Management",
items: [
{
name: "Users",
href: "/settings/users",
icon: Users,
},
{
name: "Roles",
href: "/settings/roles",
icon: Shield,
},
{
name: "My Profile",
href: "/settings/profile",
icon: UserCircle,
},
],
});
}
// Host Groups
if (canManageSettings()) {
nav.push({
section: "Hosts Management",
items: [
{
name: "Host Groups",
href: "/settings/host-groups",
icon: Folder,
},
{
name: "Agent Updates",
href: "/settings/agent-config",
icon: RefreshCw,
},
{
name: "Agent Version",
href: "/settings/agent-version",
icon: Settings,
},
],
});
}
// Alert Management
if (canManageSettings()) {
nav.push({
section: "Alert Management",
items: [
{
name: "Alert Channels",
href: "/settings/alert-channels",
icon: Bell,
},
{
name: "Notifications",
href: "/settings/notifications",
icon: Bell,
comingSoon: true,
},
],
});
}
// Patch Management
if (canManageSettings()) {
nav.push({
section: "Patch Management",
items: [
{
name: "Policies",
href: "/settings/patch-management",
icon: Settings,
comingSoon: true,
},
],
});
}
// Server Config
if (canManageSettings()) {
// Integrations section
nav.push({
section: "Integrations",
items: [
{
name: "Integrations",
href: "/settings/integrations",
icon: Wrench,
comingSoon: true,
},
],
});
nav.push({
section: "Server",
items: [
{
name: "URL Config",
href: "/settings/server-url",
icon: Wrench,
},
{
name: "Server Version",
href: "/settings/server-version",
icon: Code,
},
],
});
}
return nav;
};
const secondaryNavigation = buildSecondaryNavigation();
const isActive = (path) => location.pathname === path;
const _getPageTitle = () => {
const path = location.pathname;
if (path.startsWith("/settings/users")) return "Users";
if (path.startsWith("/settings/host-groups")) return "Host Groups";
if (path.startsWith("/settings/notifications")) return "Notifications";
if (path.startsWith("/settings/agent-config")) return "Agent Config";
if (path.startsWith("/settings/server-config")) return "Server Config";
return "Settings";
};
return (
<div className="bg-transparent">
{/* Within-page secondary navigation and content */}
<div className="px-2 sm:px-4 lg:px-6">
<div className="flex gap-4">
{/* Left secondary nav (within page) */}
<aside
className={`${sidebarCollapsed ? "w-14" : "w-56"} transition-all duration-300 flex-shrink-0`}
>
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg">
{/* Collapse button */}
<div className="flex justify-end p-2 border-b border-secondary-200 dark:border-secondary-600">
<button
type="button"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
className="p-1 text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300 rounded transition-colors"
title={
sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"
}
>
{sidebarCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</button>
</div>
<div className={`${sidebarCollapsed ? "p-2" : "p-3"}`}>
<nav>
<ul
className={`${sidebarCollapsed ? "space-y-2" : "space-y-4"}`}
>
{secondaryNavigation.map((item) => (
<li key={item.section}>
{!sidebarCollapsed && (
<h4 className="text-xs font-semibold text-secondary-500 dark:text-secondary-300 uppercase tracking-wider mb-2">
{item.section}
</h4>
)}
<ul
className={`${sidebarCollapsed ? "space-y-1" : "space-y-1"}`}
>
{item.items.map((subItem) => (
<li key={subItem.name}>
<Link
to={subItem.href}
className={`group flex items-center rounded-md text-sm leading-5 font-medium transition-colors ${
sidebarCollapsed
? "justify-center p-2"
: "gap-2 p-2"
} ${
isActive(subItem.href)
? "bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white"
: "text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
title={sidebarCollapsed ? subItem.name : ""}
>
<subItem.icon className="h-4 w-4 flex-shrink-0" />
{!sidebarCollapsed && (
<span className="truncate flex items-center gap-2">
{subItem.name}
{subItem.comingSoon && (
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
Soon
</span>
)}
</span>
)}
</Link>
{!sidebarCollapsed && subItem.subTabs && (
<ul className="ml-6 mt-1 space-y-1">
{subItem.subTabs.map((subTab) => (
<li key={subTab.name}>
<Link
to={subTab.href}
className={`block px-3 py-1 text-xs font-medium rounded transition-colors ${
isActive(subTab.href)
? "bg-primary-100 dark:bg-primary-700 text-primary-700 dark:text-primary-200"
: "text-secondary-600 dark:text-secondary-400 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700"
}`}
>
{subTab.name}
</Link>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</li>
))}
</ul>
</nav>
</div>
</div>
</aside>
{/* Right content */}
<section className="flex-1 min-w-0">
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-4">
{children}
</div>
</section>
</div>
</div>
</div>
);
};
export default SettingsLayout;

View File

@@ -0,0 +1,14 @@
import { ArrowUpCircle } from "lucide-react";
const UpgradeNotificationIcon = ({ className = "h-4 w-4", show = true }) => {
if (!show) return null;
return (
<ArrowUpCircle
className={`${className} text-red-500 animate-pulse`}
title="Update available"
/>
);
};
export default UpgradeNotificationIcon;

View File

@@ -0,0 +1,379 @@
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";
const AgentManagementTab = () => {
const scriptFileId = useId();
const scriptContentId = useId();
const [showUploadModal, setShowUploadModal] = useState(false);
// Agent file queries and mutations
const {
data: agentFileInfo,
isLoading: agentFileLoading,
error: agentFileError,
refetch: refetchAgentFile,
} = useQuery({
queryKey: ["agentFile"],
queryFn: () => agentFileAPI.getInfo().then((res) => res.data),
});
// 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 uploadAgentMutation = useMutation({
mutationFn: (scriptContent) =>
agentFileAPI.upload(scriptContent).then((res) => res.data),
onSuccess: () => {
refetchAgentFile();
setShowUploadModal(false);
},
onError: (error) => {
console.error("Upload agent error:", error);
},
});
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>
</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"
>
<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
</button>
</div>
</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"
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="px-6 py-4">
<div className="space-y-4">
<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
</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>
</div>
</div>
);
};
export default AgentManagementTab;

View File

@@ -0,0 +1,453 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, CheckCircle, Save, Shield } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { permissionsAPI, settingsAPI } from "../../utils/api";
const AgentUpdatesTab = () => {
const updateIntervalId = useId();
const autoUpdateId = useId();
const signupEnabledId = useId();
const defaultRoleId = useId();
const ignoreSslId = useId();
const [formData, setFormData] = useState({
updateInterval: 60,
autoUpdate: false,
signupEnabled: false,
defaultUserRole: "user",
ignoreSslSelfSigned: false,
});
const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false);
const queryClient = useQueryClient();
// Fetch current settings
const {
data: settings,
isLoading,
error,
} = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Fetch available roles for default user role dropdown
const { data: roles, isLoading: rolesLoading } = useQuery({
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Update form data when settings are loaded
useEffect(() => {
if (settings) {
const newFormData = {
updateInterval: settings.update_interval || 60,
autoUpdate: settings.auto_update || false,
signupEnabled: settings.signup_enabled === true,
defaultUserRole: settings.default_user_role || "user",
ignoreSslSelfSigned: settings.ignore_ssl_self_signed === true,
};
setFormData(newFormData);
setIsDirty(false);
}
}, [settings]);
// Update settings mutation
const updateSettingsMutation = useMutation({
mutationFn: (data) => {
return settingsAPI.update(data).then((res) => res.data);
},
onSuccess: () => {
queryClient.invalidateQueries(["settings"]);
setIsDirty(false);
setErrors({});
},
onError: (error) => {
if (error.response?.data?.errors) {
setErrors(
error.response.data.errors.reduce((acc, err) => {
acc[err.path] = err.msg;
return acc;
}, {}),
);
} else {
setErrors({
general: error.response?.data?.error || "Failed to update settings",
});
}
},
});
// Normalize update interval to safe presets
const normalizeInterval = (minutes) => {
let m = parseInt(minutes, 10);
if (Number.isNaN(m)) return 60;
if (m < 5) m = 5;
if (m > 1440) m = 1440;
// If less than 60 minutes, keep within 5-59 and step of 5
if (m < 60) {
return Math.min(59, Math.max(5, Math.round(m / 5) * 5));
}
// 60 or more: only allow exact hour multiples (60, 120, 180, 360, 720, 1440)
const allowed = [60, 120, 180, 360, 720, 1440];
// Snap to nearest allowed value
let nearest = allowed[0];
let bestDiff = Math.abs(m - nearest);
for (const a of allowed) {
const d = Math.abs(m - a);
if (d < bestDiff) {
bestDiff = d;
nearest = a;
}
}
return nearest;
};
const handleInputChange = (field, value) => {
setFormData((prev) => {
const newData = {
...prev,
[field]: field === "updateInterval" ? normalizeInterval(value) : value,
};
return newData;
});
setIsDirty(true);
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: null }));
}
};
const validateForm = () => {
const newErrors = {};
if (
!formData.updateInterval ||
formData.updateInterval < 5 ||
formData.updateInterval > 1440
) {
newErrors.updateInterval =
"Update interval must be between 5 and 1440 minutes";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = () => {
if (validateForm()) {
updateSettingsMutation.mutate(formData);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Error loading settings
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{error.response?.data?.error || "Failed to load settings"}
</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{errors.general && (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<p className="text-sm text-red-700 dark:text-red-300">
{errors.general}
</p>
</div>
</div>
</div>
)}
<form className="space-y-6">
{/* Update Interval */}
<div>
<label
htmlFor={updateIntervalId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Agent Update Interval (minutes)
</label>
{/* Numeric input (concise width) */}
<div className="flex items-center gap-2">
<input
id={updateIntervalId}
type="number"
min="5"
max="1440"
step="5"
value={formData.updateInterval}
onChange={(e) => {
const val = parseInt(e.target.value, 10);
if (!Number.isNaN(val)) {
handleInputChange(
"updateInterval",
Math.min(1440, Math.max(5, val)),
);
} else {
handleInputChange("updateInterval", 60);
}
}}
className={`w-28 border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.updateInterval
? "border-red-300 dark:border-red-500"
: "border-secondary-300 dark:border-secondary-600"
}`}
placeholder="60"
/>
</div>
{/* Quick presets */}
<div className="mt-3 flex flex-wrap items-center gap-2">
{[5, 10, 15, 30, 45, 60, 120, 180, 360, 720, 1440].map((m) => (
<button
key={m}
type="button"
onClick={() => handleInputChange("updateInterval", m)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border ${
formData.updateInterval === m
? "bg-primary-600 text-white border-primary-600"
: "bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 border-secondary-300 dark:border-secondary-600 hover:bg-secondary-50 dark:hover:bg-secondary-600"
}`}
aria-label={`Set ${m} minutes`}
>
{m % 60 === 0 ? `${m / 60}h` : `${m}m`}
</button>
))}
</div>
{/* Range slider */}
<div className="mt-4">
<input
type="range"
min="5"
max="1440"
step="5"
value={formData.updateInterval}
onChange={(e) => {
const raw = parseInt(e.target.value, 10);
handleInputChange("updateInterval", normalizeInterval(raw));
}}
className="w-auto accent-primary-600"
aria-label="Update interval slider"
style={{ width: "fit-content", minWidth: "500px" }}
/>
</div>
{errors.updateInterval && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{errors.updateInterval}
</p>
)}
{/* Helper text */}
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
<span className="font-medium">Effective cadence:</span> {(() => {
const mins = parseInt(formData.updateInterval, 10) || 60;
if (mins < 60) return `${mins} minute${mins === 1 ? "" : "s"}`;
const hrs = Math.floor(mins / 60);
const rem = mins % 60;
return `${hrs} hour${hrs === 1 ? "" : "s"}${rem ? ` ${rem} min` : ""}`;
})()}
</div>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
This affects new installations and will update existing ones when
they next reach out.
</p>
</div>
{/* Auto-Update Setting */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
<div className="flex items-center gap-2">
<input
id={autoUpdateId}
type="checkbox"
checked={formData.autoUpdate}
onChange={(e) =>
handleInputChange("autoUpdate", e.target.checked)
}
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
/>
<label htmlFor={autoUpdateId}>
Enable Automatic Agent Updates
</label>
</div>
</label>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
When enabled, agents will automatically update themselves when a
newer version is available during their regular update cycle.
</p>
</div>
{/* SSL Certificate Setting */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
<div className="flex items-center gap-2">
<input
id={ignoreSslId}
type="checkbox"
checked={formData.ignoreSslSelfSigned}
onChange={(e) =>
handleInputChange("ignoreSslSelfSigned", e.target.checked)
}
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
/>
<label htmlFor={ignoreSslId}>
Ignore SSL Self-Signed Certificates
</label>
</div>
</label>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
When enabled, curl commands in agent scripts will use the -k flag to
ignore SSL certificate validation errors. Use with caution on
production systems as this reduces security.
</p>
</div>
{/* User Signup Setting */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
<div className="flex items-center gap-2">
<input
id={signupEnabledId}
type="checkbox"
checked={formData.signupEnabled}
onChange={(e) =>
handleInputChange("signupEnabled", e.target.checked)
}
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
/>
<label htmlFor={signupEnabledId}>
Enable User Self-Registration
</label>
</div>
</label>
{/* Default User Role Dropdown */}
{formData.signupEnabled && (
<div className="mt-3 ml-6">
<label
htmlFor={defaultRoleId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Default Role for New Users
</label>
<select
id={defaultRoleId}
value={formData.defaultUserRole}
onChange={(e) =>
handleInputChange("defaultUserRole", e.target.value)
}
className="w-full max-w-xs 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"
disabled={rolesLoading}
>
{rolesLoading ? (
<option>Loading roles...</option>
) : roles && Array.isArray(roles) ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() + role.role.slice(1)}
</option>
))
) : (
<option value="user">User</option>
)}
</select>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
New users will be assigned this role when they register.
</p>
</div>
)}
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
When enabled, users can create their own accounts through the signup
page. When disabled, only administrators can create user accounts.
</p>
</div>
{/* Security Notice */}
<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">
Security Notice
</h3>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
When enabling user self-registration, exercise caution on
internal networks. Consider restricting access to trusted
networks only and ensure proper role assignments to prevent
unauthorized access to sensitive systems.
</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={!isDirty || updateSettingsMutation.isPending}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
!isDirty || updateSettingsMutation.isPending
? "bg-secondary-400 cursor-not-allowed"
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
}`}
>
{updateSettingsMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Settings
</>
)}
</button>
</div>
{updateSettingsMutation.isSuccess && (
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
<div className="ml-3">
<p className="text-sm text-green-700 dark:text-green-300">
Settings saved successfully!
</p>
</div>
</div>
</div>
)}
</form>
</div>
);
};
export default AgentUpdatesTab;

View File

@@ -0,0 +1,305 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, CheckCircle, Save, Server, Shield } from "lucide-react";
import { useEffect, useId, useState } from "react";
import { settingsAPI } from "../../utils/api";
const ProtocolUrlTab = () => {
const protocolId = useId();
const hostId = useId();
const portId = useId();
const [formData, setFormData] = useState({
serverProtocol: "http",
serverHost: "localhost",
serverPort: 3001,
});
const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false);
const queryClient = useQueryClient();
// Fetch current settings
const {
data: settings,
isLoading,
error,
} = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Update form data when settings are loaded
useEffect(() => {
if (settings) {
const newFormData = {
serverProtocol: settings.server_protocol || "http",
serverHost: settings.server_host || "localhost",
serverPort: settings.server_port || 3001,
};
setFormData(newFormData);
setIsDirty(false);
}
}, [settings]);
// Update settings mutation
const updateSettingsMutation = useMutation({
mutationFn: (data) => {
return settingsAPI.update(data).then((res) => res.data);
},
onSuccess: () => {
queryClient.invalidateQueries(["settings"]);
setIsDirty(false);
setErrors({});
},
onError: (error) => {
if (error.response?.data?.errors) {
setErrors(
error.response.data.errors.reduce((acc, err) => {
acc[err.path] = err.msg;
return acc;
}, {}),
);
} else {
setErrors({
general: error.response?.data?.error || "Failed to update settings",
});
}
},
});
const handleInputChange = (field, value) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
setIsDirty(true);
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: null }));
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.serverHost.trim()) {
newErrors.serverHost = "Server host is required";
}
if (
!formData.serverPort ||
formData.serverPort < 1 ||
formData.serverPort > 65535
) {
newErrors.serverPort = "Port must be between 1 and 65535";
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = () => {
if (validateForm()) {
updateSettingsMutation.mutate(formData);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Error loading settings
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{error.response?.data?.error || "Failed to load settings"}
</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{errors.general && (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<p className="text-sm text-red-700 dark:text-red-300">
{errors.general}
</p>
</div>
</div>
</div>
)}
<form className="space-y-6">
<div className="flex items-center mb-6">
<Server className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Server Configuration
</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label
htmlFor={protocolId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Protocol
</label>
<select
id={protocolId}
value={formData.serverProtocol}
onChange={(e) =>
handleInputChange("serverProtocol", e.target.value)
}
className="w-full 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"
>
<option value="http">HTTP</option>
<option value="https">HTTPS</option>
</select>
</div>
<div>
<label
htmlFor={hostId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Host *
</label>
<input
id={hostId}
type="text"
value={formData.serverHost}
onChange={(e) => handleInputChange("serverHost", e.target.value)}
className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.serverHost
? "border-red-300 dark:border-red-500"
: "border-secondary-300 dark:border-secondary-600"
}`}
placeholder="example.com"
/>
{errors.serverHost && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{errors.serverHost}
</p>
)}
</div>
<div>
<label
htmlFor={portId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
Port *
</label>
<input
id={portId}
type="number"
value={formData.serverPort}
onChange={(e) =>
handleInputChange("serverPort", parseInt(e.target.value, 10))
}
className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.serverPort
? "border-red-300 dark:border-red-500"
: "border-secondary-300 dark:border-secondary-600"
}`}
min="1"
max="65535"
/>
{errors.serverPort && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">
{errors.serverPort}
</p>
)}
</div>
</div>
<div className="mt-4 p-4 bg-secondary-50 dark:bg-secondary-700 rounded-md">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">
Server URL
</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 font-mono">
{formData.serverProtocol}://{formData.serverHost}:
{formData.serverPort}
</p>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
This URL will be used in installation scripts and agent
communications.
</p>
</div>
{/* Security Notice */}
<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">
Security Notice
</h3>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
Changing these settings will affect all installation scripts and
agent communications. Make sure the server URL is accessible
from your client networks.
</p>
</div>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={!isDirty || updateSettingsMutation.isPending}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
!isDirty || updateSettingsMutation.isPending
? "bg-secondary-400 cursor-not-allowed"
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
}`}
>
{updateSettingsMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Settings
</>
)}
</button>
</div>
{updateSettingsMutation.isSuccess && (
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
<div className="ml-3">
<p className="text-sm text-green-700 dark:text-green-300">
Settings saved successfully!
</p>
</div>
</div>
</div>
)}
</form>
</div>
);
};
export default ProtocolUrlTab;

View File

@@ -0,0 +1,568 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
BarChart3,
CheckCircle,
Download,
Edit,
Package,
Save,
Server,
Settings,
Shield,
Trash2,
Users,
X,
} from "lucide-react";
import { useEffect, useId, useState } from "react";
import { useAuth } from "../../contexts/AuthContext";
import { permissionsAPI } from "../../utils/api";
const RolesTab = () => {
const [editingRole, setEditingRole] = useState(null);
const [showAddModal, setShowAddModal] = useState(false);
const queryClient = useQueryClient();
const { refreshPermissions } = useAuth();
// Listen for the header button event to open add modal
useEffect(() => {
const handleOpenAddModal = () => setShowAddModal(true);
window.addEventListener("openAddRoleModal", handleOpenAddModal);
return () =>
window.removeEventListener("openAddRoleModal", handleOpenAddModal);
}, []);
// Fetch all role permissions
const {
data: roles,
isLoading,
error,
} = useQuery({
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Update role permissions mutation
const updateRoleMutation = useMutation({
mutationFn: ({ role, permissions }) =>
permissionsAPI.updateRole(role, permissions),
onSuccess: () => {
queryClient.invalidateQueries(["rolePermissions"]);
setEditingRole(null);
// Refresh user permissions to apply changes immediately
refreshPermissions();
},
});
// Delete role mutation
const deleteRoleMutation = useMutation({
mutationFn: (role) => permissionsAPI.deleteRole(role),
onSuccess: () => {
queryClient.invalidateQueries(["rolePermissions"]);
},
});
const handleSavePermissions = async (role, permissions) => {
try {
await updateRoleMutation.mutateAsync({ role, permissions });
} catch (error) {
console.error("Failed to update permissions:", error);
}
};
const handleDeleteRole = async (role) => {
if (
window.confirm(
`Are you sure you want to delete the "${role}" role? This action cannot be undone.`,
)
) {
try {
await deleteRoleMutation.mutateAsync(role);
} catch (error) {
console.error("Failed to delete role:", error);
}
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading permissions
</h3>
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Roles Matrix Table */}
<div className="bg-white dark:bg-secondary-800 shadow overflow-hidden sm:rounded-lg">
<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-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Permission
</th>
{roles &&
Array.isArray(roles) &&
roles.map((r) => (
<th
key={r.role}
className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider"
>
<div className="flex items-center gap-2">
<span className="capitalize">
{r.role.replace(/_/g, " ")}
</span>
<button
type="button"
onClick={() => setEditingRole(r.role)}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-400 dark:hover:text-secondary-200"
title="Edit role permissions"
>
<Edit className="h-4 w-4" />
</button>
</div>
</th>
))}
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{roles &&
Array.isArray(roles) &&
roles.length > 0 &&
Object.keys(roles[0])
.filter((k) => k.startsWith("can_"))
.map((permKey) => (
<tr
key={permKey}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
<td className="px-6 py-3 text-sm font-medium text-secondary-700 dark:text-secondary-200 whitespace-nowrap">
{permKey
.replace(/^can_/, "")
.split("_")
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join(" ")}
</td>
{roles.map((r) => (
<td
key={`${r.role}-${permKey}`}
className="px-6 py-3 whitespace-nowrap"
>
{r[permKey] ? (
<div className="flex items-center text-green-600">
<CheckCircle className="h-4 w-4" />
</div>
) : (
<div className="flex items-center text-red-600">
<X className="h-4 w-4" />
</div>
)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Inline editor for selected role */}
{editingRole && roles && Array.isArray(roles) && (
<div className="space-y-4">
{roles
.filter((r) => r.role === editingRole)
.map((r) => (
<RolePermissionsCard
key={`editor-${r.role}`}
role={r}
isEditing={true}
onEdit={() => {}}
onCancel={() => setEditingRole(null)}
onSave={handleSavePermissions}
onDelete={handleDeleteRole}
/>
))}
</div>
)}
{/* Add Role Modal */}
<AddRoleModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSuccess={() => {
queryClient.invalidateQueries(["rolePermissions"]);
setShowAddModal(false);
}}
/>
</div>
);
};
// Role Permissions Card Component
const RolePermissionsCard = ({
role,
isEditing,
onEdit,
onCancel,
onSave,
onDelete,
}) => {
const [permissions, setPermissions] = useState(role);
// Sync permissions state with role prop when it changes
useEffect(() => {
setPermissions(role);
}, [role]);
const permissionFields = [
{
key: "can_view_dashboard",
label: "View Dashboard",
icon: BarChart3,
description: "Access to the main dashboard",
},
{
key: "can_view_hosts",
label: "View Hosts",
icon: Server,
description: "See host information and status",
},
{
key: "can_manage_hosts",
label: "Manage Hosts",
icon: Edit,
description: "Add, edit, and delete hosts",
},
{
key: "can_view_packages",
label: "View Packages",
icon: Package,
description: "See package information",
},
{
key: "can_manage_packages",
label: "Manage Packages",
icon: Settings,
description: "Edit package details",
},
{
key: "can_view_users",
label: "View Users",
icon: Users,
description: "See user list and details",
},
{
key: "can_manage_users",
label: "Manage Users",
icon: Shield,
description: "Add, edit, and delete users",
},
{
key: "can_view_reports",
label: "View Reports",
icon: BarChart3,
description: "Access to reports and analytics",
},
{
key: "can_export_data",
label: "Export Data",
icon: Download,
description: "Download data and reports",
},
{
key: "can_manage_settings",
label: "Manage Settings",
icon: Settings,
description: "System configuration access",
},
];
const handlePermissionChange = (key, value) => {
setPermissions((prev) => ({
...prev,
[key]: value,
}));
};
const handleSave = () => {
onSave(role.role, permissions);
};
const isBuiltInRole = role.role === "admin" || role.role === "user";
return (
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<div className="flex items-center">
<Shield className="h-5 w-5 text-primary-600 mr-3" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white capitalize">
{role.role}
</h3>
{isBuiltInRole && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
Built-in Role
</span>
)}
</div>
<div className="flex items-center space-x-2">
{isEditing ? (
<>
<button
type="button"
onClick={handleSave}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700"
>
<Save className="h-4 w-4 mr-1" />
Save
</button>
<button
type="button"
onClick={onCancel}
className="inline-flex items-center px-3 py-1 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
<X className="h-4 w-4 mr-1" />
Cancel
</button>
{!isBuiltInRole && (
<button
type="button"
onClick={() => onDelete(role.role)}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</button>
)}
</>
) : (
<>
<button
type="button"
onClick={onEdit}
disabled={isBuiltInRole}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</button>
{!isBuiltInRole && (
<button
type="button"
onClick={() => onDelete(role.role)}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</button>
)}
</>
)}
</div>
</div>
</div>
<div className="px-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{permissionFields.map((field) => {
const Icon = field.icon;
const isChecked = permissions[field.key];
return (
<div key={field.key} className="flex items-start">
<div className="flex items-center h-5">
<input
id={`${role.role}-${field.key}`}
type="checkbox"
checked={isChecked}
onChange={(e) =>
handlePermissionChange(field.key, e.target.checked)
}
disabled={
!isEditing ||
(isBuiltInRole && field.key === "can_manage_users")
}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50"
/>
</div>
<div className="ml-3">
<div className="flex items-center">
<Icon className="h-4 w-4 text-secondary-400 mr-2" />
<label
htmlFor={`${role.role}-${field.key}`}
className="text-sm font-medium text-secondary-900 dark:text-white"
>
{field.label}
</label>
</div>
<p className="text-xs text-secondary-500 mt-1">
{field.description}
</p>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
// Add Role Modal Component
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
const roleNameInputId = useId();
const [formData, setFormData] = useState({
role: "",
can_view_dashboard: true,
can_view_hosts: true,
can_manage_hosts: false,
can_view_packages: true,
can_manage_packages: false,
can_view_users: false,
can_manage_users: false,
can_view_reports: true,
can_export_data: false,
can_manage_settings: false,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
await permissionsAPI.updateRole(formData.role, formData);
onSuccess();
} catch (err) {
setError(err.response?.data?.error || "Failed to create role");
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData({
...formData,
[name]: type === "checkbox" ? checked : value,
});
};
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 p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Add New Role
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={roleNameInputId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Role Name
</label>
<input
id={roleNameInputId}
type="text"
name="role"
required
value={formData.role}
onChange={handleInputChange}
className="block w-full 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"
placeholder="e.g., host_manager, readonly"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Use lowercase with underscores (e.g., host_manager)
</p>
</div>
<div className="space-y-3">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white">
Permissions
</h4>
{[
{ key: "can_view_dashboard", label: "View Dashboard" },
{ key: "can_view_hosts", label: "View Hosts" },
{ key: "can_manage_hosts", label: "Manage Hosts" },
{ key: "can_view_packages", label: "View Packages" },
{ key: "can_manage_packages", label: "Manage Packages" },
{ key: "can_view_users", label: "View Users" },
{ key: "can_manage_users", label: "Manage Users" },
{ key: "can_view_reports", label: "View Reports" },
{ key: "can_export_data", label: "Export Data" },
{ key: "can_manage_settings", label: "Manage Settings" },
].map((permission) => (
<div key={permission.key} className="flex items-center">
<input
id={`add-role-${permission.key}`}
type="checkbox"
name={permission.key}
checked={formData[permission.key]}
onChange={handleInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label
htmlFor={`add-role-${permission.key}`}
className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200"
>
{permission.label}
</label>
</div>
))}
</div>
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? "Creating..." : "Create Role"}
</button>
</div>
</form>
</div>
</div>
);
};
export default RolesTab;

View File

@@ -0,0 +1,896 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
Calendar,
CheckCircle,
Edit,
Key,
Mail,
Shield,
Trash2,
User,
XCircle,
} from "lucide-react";
import { useEffect, useId, useState } from "react";
import { useAuth } from "../../contexts/AuthContext";
import { adminUsersAPI, permissionsAPI } from "../../utils/api";
const UsersTab = () => {
const [showAddModal, setShowAddModal] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [resetPasswordUser, setResetPasswordUser] = useState(null);
const queryClient = useQueryClient();
const { user: currentUser } = useAuth();
// Listen for the header button event to open add modal
useEffect(() => {
const handleOpenAddModal = () => setShowAddModal(true);
window.addEventListener("openAddUserModal", handleOpenAddModal);
return () =>
window.removeEventListener("openAddUserModal", handleOpenAddModal);
}, []);
// Fetch users
const {
data: users,
isLoading,
error,
} = useQuery({
queryKey: ["users"],
queryFn: () => adminUsersAPI.list().then((res) => res.data),
});
// Fetch available roles
const { data: roles } = useQuery({
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Delete user mutation
const deleteUserMutation = useMutation({
mutationFn: adminUsersAPI.delete,
onSuccess: () => {
queryClient.invalidateQueries(["users"]);
},
});
// Update user mutation
const updateUserMutation = useMutation({
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries(["users"]);
setEditingUser(null);
},
});
// Reset password mutation
const resetPasswordMutation = useMutation({
mutationFn: ({ userId, newPassword }) =>
adminUsersAPI.resetPassword(userId, newPassword),
onSuccess: () => {
queryClient.invalidateQueries(["users"]);
setResetPasswordUser(null);
},
});
const handleDeleteUser = async (userId, username) => {
if (
window.confirm(
`Are you sure you want to delete user "${username}"? This action cannot be undone.`,
)
) {
try {
await deleteUserMutation.mutateAsync(userId);
} catch (error) {
console.error("Failed to delete user:", error);
}
}
};
const handleUserCreated = () => {
queryClient.invalidateQueries(["users"]);
setShowAddModal(false);
};
const handleEditUser = (user) => {
setEditingUser(user);
};
const handleResetPassword = (user) => {
setResetPasswordUser(user);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<XCircle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading users
</h3>
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Users Table */}
<div className="bg-white dark:bg-secondary-800 shadow overflow-hidden sm:rounded-lg">
<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-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
User
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Role
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Last Login
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{users && Array.isArray(users) && users.length > 0 ? (
users.map((user) => (
<tr
key={user.id}
className="hover:bg-secondary-50 dark:hover:bg-secondary-700"
>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-primary-100 flex items-center justify-center">
<User className="h-5 w-5 text-primary-600" />
</div>
</div>
<div className="ml-4">
<div className="flex items-center">
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{user.username}
</div>
{user.id === currentUser?.id && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
You
</span>
)}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-secondary-500 dark:text-secondary-300">
<Mail className="h-4 w-4 mr-2" />
{user.email}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.role === "admin"
? "bg-primary-100 text-primary-800"
: user.role === "host_manager"
? "bg-green-100 text-green-800"
: user.role === "readonly"
? "bg-yellow-100 text-yellow-800"
: "bg-secondary-100 text-secondary-800"
}`}
>
<Shield className="h-3 w-3 mr-1" />
{user.role.charAt(0).toUpperCase() +
user.role.slice(1).replace("_", " ")}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{user.is_active ? (
<div className="flex items-center text-green-600">
<CheckCircle className="h-4 w-4 mr-1" />
<span className="text-sm">Active</span>
</div>
) : (
<div className="flex items-center text-red-600">
<XCircle className="h-4 w-4 mr-1" />
<span className="text-sm">Inactive</span>
</div>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-secondary-500 dark:text-secondary-300">
<Calendar className="h-4 w-4 mr-2" />
{new Date(user.created_at).toLocaleDateString()}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-500 dark:text-secondary-300">
{user.last_login ? (
new Date(user.last_login).toLocaleDateString()
) : (
<span className="text-secondary-400">Never</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end space-x-2">
<button
type="button"
onClick={() => handleEditUser(user)}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
title="Edit user"
>
<Edit className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleResetPassword(user)}
className="text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300 disabled:text-gray-300 disabled:cursor-not-allowed"
title={
!user.is_active
? "Cannot reset password for inactive user"
: "Reset password"
}
disabled={!user.is_active}
>
<Key className="h-4 w-4" />
</button>
<button
type="button"
onClick={() =>
handleDeleteUser(user.id, user.username)
}
className="text-danger-400 hover:text-danger-600 dark:text-danger-500 dark:hover:text-danger-400 disabled:text-gray-300 disabled:cursor-not-allowed"
title={
user.id === currentUser?.id
? "Cannot delete your own account"
: user.role === "admin" &&
users.filter((u) => u.role === "admin")
.length === 1
? "Cannot delete the last admin user"
: "Delete user"
}
disabled={
user.id === currentUser?.id ||
(user.role === "admin" &&
users.filter((u) => u.role === "admin").length ===
1)
}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))
) : (
<tr>
<td colSpan="7" className="px-6 py-12 text-center">
<User className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500 dark:text-secondary-300">
No users found
</p>
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
Click "Add User" to create the first user
</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Add User Modal */}
<AddUserModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onUserCreated={handleUserCreated}
roles={roles}
/>
{/* Edit User Modal */}
{editingUser && (
<EditUserModal
user={editingUser}
isOpen={!!editingUser}
onClose={() => setEditingUser(null)}
onUserUpdated={() => updateUserMutation.mutate()}
roles={roles}
/>
)}
{/* Reset Password Modal */}
{resetPasswordUser && (
<ResetPasswordModal
user={resetPasswordUser}
isOpen={!!resetPasswordUser}
onClose={() => setResetPasswordUser(null)}
onPasswordReset={resetPasswordMutation.mutate}
isLoading={resetPasswordMutation.isPending}
/>
)}
</div>
);
};
// Add User Modal Component
const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
const usernameId = useId();
const emailId = useId();
const firstNameId = useId();
const lastNameId = useId();
const passwordId = useId();
const roleId = useId();
const [formData, setFormData] = useState({
username: "",
email: "",
password: "",
first_name: "",
last_name: "",
role: "user",
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
// Only send role if roles are available from API
const payload = {
username: formData.username,
email: formData.email,
password: formData.password,
};
if (roles && Array.isArray(roles) && roles.length > 0) {
payload.role = formData.role;
}
await adminUsersAPI.create(payload);
onUserCreated();
} catch (err) {
setError(err.response?.data?.error || "Failed to create user");
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
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 p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Add New User
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={usernameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Username
</label>
<input
id={usernameId}
type="text"
name="username"
required
value={formData.username}
onChange={handleInputChange}
className="block w-full 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"
/>
</div>
<div>
<label
htmlFor={emailId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Email
</label>
<input
id={emailId}
type="email"
name="email"
required
value={formData.email}
onChange={handleInputChange}
className="block w-full 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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor={firstNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
First Name
</label>
<input
id={firstNameId}
type="text"
name="first_name"
value={formData.first_name}
onChange={handleInputChange}
className="block w-full 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"
/>
</div>
<div>
<label
htmlFor={lastNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Last Name
</label>
<input
id={lastNameId}
type="text"
name="last_name"
value={formData.last_name}
onChange={handleInputChange}
className="block w-full 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"
/>
</div>
</div>
<div>
<label
htmlFor={passwordId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Password
</label>
<input
id={passwordId}
type="password"
name="password"
required
minLength={6}
value={formData.password}
onChange={handleInputChange}
className="block w-full 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"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Minimum 6 characters
</p>
</div>
<div>
<label
htmlFor={roleId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Role
</label>
<select
id={roleId}
name="role"
value={formData.role}
onChange={handleInputChange}
className="block w-full 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"
>
{roles && Array.isArray(roles) && roles.length > 0 ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() +
role.role.slice(1).replace("_", " ")}
</option>
))
) : (
<>
<option value="user">User</option>
<option value="admin">Admin</option>
</>
)}
</select>
</div>
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? "Creating..." : "Create User"}
</button>
</div>
</form>
</div>
</div>
);
};
// Edit User Modal Component
const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
const editUsernameId = useId();
const editEmailId = useId();
const editFirstNameId = useId();
const editLastNameId = useId();
const editRoleId = useId();
const editActiveId = useId();
const [formData, setFormData] = useState({
username: user?.username || "",
email: user?.email || "",
first_name: user?.first_name || "",
last_name: user?.last_name || "",
role: user?.role || "user",
is_active: user?.is_active ?? true,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
await adminUsersAPI.update(user.id, formData);
onUserUpdated();
} catch (err) {
setError(err.response?.data?.error || "Failed to update user");
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData({
...formData,
[name]: type === "checkbox" ? checked : value,
});
};
if (!isOpen || !user) 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 p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Edit User
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={editUsernameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Username
</label>
<input
id={editUsernameId}
type="text"
name="username"
required
value={formData.username}
onChange={handleInputChange}
className="block w-full 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"
/>
</div>
<div>
<label
htmlFor={editEmailId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Email
</label>
<input
id={editEmailId}
type="email"
name="email"
required
value={formData.email}
onChange={handleInputChange}
className="block w-full 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"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label
htmlFor={editFirstNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
First Name
</label>
<input
id={editFirstNameId}
type="text"
name="first_name"
value={formData.first_name}
onChange={handleInputChange}
className="block w-full 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"
/>
</div>
<div>
<label
htmlFor={editLastNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Last Name
</label>
<input
id={editLastNameId}
type="text"
name="last_name"
value={formData.last_name}
onChange={handleInputChange}
className="block w-full 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"
/>
</div>
</div>
<div>
<label
htmlFor={editRoleId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Role
</label>
<select
id={editRoleId}
name="role"
value={formData.role}
onChange={handleInputChange}
className="block w-full 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"
>
{roles && Array.isArray(roles) ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() +
role.role.slice(1).replace("_", " ")}
</option>
))
) : (
<>
<option value="user">User</option>
<option value="admin">Admin</option>
</>
)}
</select>
</div>
<div className="flex items-center">
<input
id={editActiveId}
type="checkbox"
name="is_active"
checked={formData.is_active}
onChange={handleInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label
htmlFor={editActiveId}
className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200"
>
Active user
</label>
</div>
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? "Updating..." : "Update User"}
</button>
</div>
</form>
</div>
</div>
);
};
// Reset Password Modal Component
const ResetPasswordModal = ({
user,
isOpen,
onClose,
onPasswordReset,
isLoading,
}) => {
const newPasswordId = useId();
const confirmPasswordId = useId();
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
// Validate passwords
if (newPassword.length < 6) {
setError("Password must be at least 6 characters long");
return;
}
if (newPassword !== confirmPassword) {
setError("Passwords do not match");
return;
}
try {
await onPasswordReset({ userId: user.id, newPassword });
// Reset form on success
setNewPassword("");
setConfirmPassword("");
} catch (err) {
setError(err.response?.data?.error || "Failed to reset password");
}
};
const handleClose = () => {
setNewPassword("");
setConfirmPassword("");
setError("");
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Reset Password for {user.username}
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={newPasswordId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
New Password
</label>
<input
id={newPasswordId}
type="password"
required
minLength={6}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="block w-full 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"
placeholder="Enter new password (min 6 characters)"
/>
</div>
<div>
<label
htmlFor={confirmPasswordId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Confirm Password
</label>
<input
id={confirmPasswordId}
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="block w-full 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"
placeholder="Confirm new password"
/>
</div>
<div className="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-md p-3">
<div className="flex">
<div className="flex-shrink-0">
<Key className="h-5 w-5 text-yellow-400" />
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
Password Reset Warning
</h3>
<div className="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
<p>
This will immediately change the user's password. The user
will need to use the new password to login.
</p>
</div>
</div>
</div>
</div>
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={handleClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50 flex items-center"
>
{isLoading && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
)}
{isLoading ? "Resetting..." : "Reset Password"}
</button>
</div>
</form>
</div>
</div>
);
};
export default UsersTab;

View File

@@ -0,0 +1,573 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle,
Clock,
Code,
Download,
Save,
} from "lucide-react";
import { useEffect, useId, useState } from "react";
import { settingsAPI, versionAPI } from "../../utils/api";
const VersionUpdateTab = () => {
const repoPublicId = useId();
const repoPrivateId = useId();
const useCustomSshKeyId = useId();
const githubRepoUrlId = useId();
const sshKeyPathId = useId();
const [formData, setFormData] = useState({
githubRepoUrl: "git@github.com:9technologygroup/patchmon.net.git",
repositoryType: "public",
sshKeyPath: "",
useCustomSshKey: false,
});
const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false);
// Version checking state
const [versionInfo, setVersionInfo] = useState({
currentVersion: null,
latestVersion: null,
isUpdateAvailable: false,
checking: false,
error: null,
});
const [sshTestResult, setSshTestResult] = useState({
testing: false,
success: null,
message: null,
error: null,
});
const queryClient = useQueryClient();
// Fetch current settings
const {
data: settings,
isLoading,
error,
} = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
});
// Update form data when settings are loaded
useEffect(() => {
if (settings) {
const newFormData = {
githubRepoUrl:
settings.github_repo_url ||
"git@github.com:9technologygroup/patchmon.net.git",
repositoryType: settings.repository_type || "public",
sshKeyPath: settings.ssh_key_path || "",
useCustomSshKey: !!settings.ssh_key_path,
};
setFormData(newFormData);
setIsDirty(false);
}
}, [settings]);
// Update settings mutation
const updateSettingsMutation = useMutation({
mutationFn: (data) => {
return settingsAPI.update(data).then((res) => res.data);
},
onSuccess: () => {
queryClient.invalidateQueries(["settings"]);
setIsDirty(false);
setErrors({});
},
onError: (error) => {
if (error.response?.data?.errors) {
setErrors(
error.response.data.errors.reduce((acc, err) => {
acc[err.path] = err.msg;
return acc;
}, {}),
);
} else {
setErrors({
general: error.response?.data?.error || "Failed to update settings",
});
}
},
});
// Load current version on component mount
useEffect(() => {
const loadCurrentVersion = async () => {
try {
const response = await versionAPI.getCurrent();
const data = response.data;
setVersionInfo((prev) => ({
...prev,
currentVersion: data.version,
}));
} catch (error) {
console.error("Error loading current version:", error);
}
};
loadCurrentVersion();
}, []);
// Version checking functions
const checkForUpdates = async () => {
setVersionInfo((prev) => ({ ...prev, checking: true, error: null }));
try {
const response = await versionAPI.checkUpdates();
const data = response.data;
setVersionInfo({
currentVersion: data.currentVersion,
latestVersion: data.latestVersion,
isUpdateAvailable: data.isUpdateAvailable,
last_update_check: data.last_update_check,
checking: false,
error: null,
});
} catch (error) {
console.error("Version check error:", error);
setVersionInfo((prev) => ({
...prev,
checking: false,
error: error.response?.data?.error || "Failed to check for updates",
}));
}
};
const testSshKey = async () => {
if (!formData.sshKeyPath || !formData.githubRepoUrl) {
setSshTestResult({
testing: false,
success: false,
message: null,
error: "Please enter both SSH key path and GitHub repository URL",
});
return;
}
setSshTestResult({
testing: true,
success: null,
message: null,
error: null,
});
try {
const response = await versionAPI.testSshKey({
sshKeyPath: formData.sshKeyPath,
githubRepoUrl: formData.githubRepoUrl,
});
setSshTestResult({
testing: false,
success: true,
message: response.data.message,
error: null,
});
} catch (error) {
console.error("SSH key test error:", error);
setSshTestResult({
testing: false,
success: false,
message: null,
error: error.response?.data?.error || "Failed to test SSH key",
});
}
};
const handleInputChange = (field, value) => {
setFormData((prev) => ({
...prev,
[field]: value,
}));
setIsDirty(true);
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: null }));
}
};
const handleSave = () => {
// Only include sshKeyPath if the toggle is enabled
const dataToSubmit = { ...formData };
if (!dataToSubmit.useCustomSshKey) {
dataToSubmit.sshKeyPath = "";
}
// Remove the frontend-only field
delete dataToSubmit.useCustomSshKey;
updateSettingsMutation.mutate(dataToSubmit);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Error loading settings
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{error.response?.data?.error || "Failed to load settings"}
</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{errors.general && (
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<p className="text-sm text-red-700 dark:text-red-300">
{errors.general}
</p>
</div>
</div>
</div>
)}
<div className="flex items-center mb-6">
<Code className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Server Version Management
</h2>
</div>
<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">
Version Check Configuration
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
Configure automatic version checking against your GitHub repository to
notify users of available updates.
</p>
<div className="space-y-4">
<fieldset>
<legend className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Repository Type
</legend>
<div className="space-y-2">
<div className="flex items-center">
<input
type="radio"
id={repoPublicId}
name="repositoryType"
value="public"
checked={formData.repositoryType === "public"}
onChange={(e) =>
handleInputChange("repositoryType", e.target.value)
}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
/>
<label
htmlFor={repoPublicId}
className="ml-2 text-sm text-secondary-700 dark:text-secondary-200"
>
Public Repository (uses GitHub API - no authentication
required)
</label>
</div>
<div className="flex items-center">
<input
type="radio"
id={repoPrivateId}
name="repositoryType"
value="private"
checked={formData.repositoryType === "private"}
onChange={(e) =>
handleInputChange("repositoryType", e.target.value)
}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300"
/>
<label
htmlFor={repoPrivateId}
className="ml-2 text-sm text-secondary-700 dark:text-secondary-200"
>
Private Repository (uses SSH with deploy key)
</label>
</div>
</div>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Choose whether your repository is public or private to determine
the appropriate access method.
</p>
</fieldset>
<div>
<label
htmlFor={githubRepoUrlId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
GitHub Repository URL
</label>
<input
id={githubRepoUrlId}
type="text"
value={formData.githubRepoUrl || ""}
onChange={(e) =>
handleInputChange("githubRepoUrl", e.target.value)
}
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
placeholder="git@github.com:username/repository.git"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
SSH or HTTPS URL to your GitHub repository
</p>
</div>
{formData.repositoryType === "private" && (
<div>
<div className="flex items-center gap-3 mb-3">
<input
type="checkbox"
id={useCustomSshKeyId}
checked={formData.useCustomSshKey}
onChange={(e) => {
const checked = e.target.checked;
handleInputChange("useCustomSshKey", checked);
if (!checked) {
handleInputChange("sshKeyPath", "");
}
}}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
/>
<label
htmlFor={useCustomSshKeyId}
className="text-sm font-medium text-secondary-700 dark:text-secondary-200"
>
Set custom SSH key path
</label>
</div>
{formData.useCustomSshKey && (
<div>
<label
htmlFor={sshKeyPathId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2"
>
SSH Key Path
</label>
<input
id={sshKeyPathId}
type="text"
value={formData.sshKeyPath || ""}
onChange={(e) =>
handleInputChange("sshKeyPath", e.target.value)
}
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
placeholder="/root/.ssh/id_ed25519"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path to your SSH deploy key. If not set, will auto-detect
from common locations.
</p>
<div className="mt-3">
<button
type="button"
onClick={testSshKey}
disabled={
sshTestResult.testing ||
!formData.sshKeyPath ||
!formData.githubRepoUrl
}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{sshTestResult.testing ? "Testing..." : "Test SSH Key"}
</button>
{sshTestResult.success && (
<div className="mt-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md">
<div className="flex items-center">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400 mr-2" />
<p className="text-sm text-green-800 dark:text-green-200">
{sshTestResult.message}
</p>
</div>
</div>
)}
{sshTestResult.error && (
<div className="mt-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<div className="flex items-center">
<AlertCircle className="h-4 w-4 text-red-600 dark:text-red-400 mr-2" />
<p className="text-sm text-red-800 dark:text-red-200">
{sshTestResult.error}
</p>
</div>
</div>
)}
</div>
</div>
)}
{!formData.useCustomSshKey && (
<p className="text-xs text-secondary-500 dark:text-secondary-400">
Using auto-detection for SSH key location
</p>
)}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Current Version
</span>
</div>
<span className="text-lg font-mono text-secondary-900 dark:text-white">
{versionInfo.currentVersion}
</span>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<Download className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Latest Version
</span>
</div>
<span className="text-lg font-mono text-secondary-900 dark:text-white">
{versionInfo.checking ? (
<span className="text-blue-600 dark:text-blue-400">
Checking...
</span>
) : versionInfo.latestVersion ? (
<span
className={
versionInfo.isUpdateAvailable
? "text-orange-600 dark:text-orange-400"
: "text-green-600 dark:text-green-400"
}
>
{versionInfo.latestVersion}
{versionInfo.isUpdateAvailable && " (Update Available!)"}
</span>
) : (
<span className="text-secondary-500 dark:text-secondary-400">
Not checked
</span>
)}
</span>
</div>
</div>
{/* Last Checked Time */}
{versionInfo.last_update_check && (
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">
Last Checked
</span>
</div>
<span className="text-sm text-secondary-600 dark:text-secondary-400">
{new Date(versionInfo.last_update_check).toLocaleString()}
</span>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
Updates are checked automatically every 24 hours
</p>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
type="button"
onClick={checkForUpdates}
disabled={versionInfo.checking}
className="btn-primary flex items-center gap-2"
>
<Download className="h-4 w-4" />
{versionInfo.checking ? "Checking..." : "Check for Updates"}
</button>
</div>
{/* Save Button for Version Settings */}
<button
type="button"
onClick={handleSave}
disabled={!isDirty || updateSettingsMutation.isPending}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
!isDirty || updateSettingsMutation.isPending
? "bg-secondary-400 cursor-not-allowed"
: "bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
}`}
>
{updateSettingsMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Settings
</>
)}
</button>
</div>
{versionInfo.error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg p-4">
<div className="flex">
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">
Version Check Failed
</h3>
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
{versionInfo.error}
</p>
{versionInfo.error.includes("private") && (
<p className="mt-2 text-xs text-red-600 dark:text-red-400">
For private repositories, you may need to configure GitHub
authentication or make the repository public.
</p>
)}
</div>
</div>
</div>
)}
{/* Success Message for Version Settings */}
{updateSettingsMutation.isSuccess && (
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
<div className="ml-3">
<p className="text-sm text-green-700 dark:text-green-300">
Settings saved successfully!
</p>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default VersionUpdateTab;

View File

@@ -0,0 +1,29 @@
/**
* Authentication phases for the centralized auth state machine
*
* Flow: INITIALISING → CHECKING_SETUP → READY
*/
export const AUTH_PHASES = {
INITIALISING: "INITIALISING",
CHECKING_SETUP: "CHECKING_SETUP",
READY: "READY",
};
/**
* Helper functions for auth phase management
*/
export const isAuthPhase = {
initialising: (phase) => phase === AUTH_PHASES.INITIALISING,
checkingSetup: (phase) => phase === AUTH_PHASES.CHECKING_SETUP,
ready: (phase) => phase === AUTH_PHASES.READY,
};
/**
* Check if authentication is fully initialised and ready
* @param {string} phase - Current auth phase
* @param {boolean} isAuthenticated - Whether user is authenticated
* @returns {boolean} - True if auth is ready for other contexts to use
*/
export const isAuthReady = (phase, isAuthenticated) => {
return isAuthPhase.ready(phase) && isAuthenticated;
};

View File

@@ -1,246 +1,315 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
import {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import { flushSync } from "react-dom";
import { AUTH_PHASES, isAuthPhase } from "../constants/authPhases";
const AuthContext = createContext()
const AuthContext = createContext();
export const useAuth = () => {
const context = useContext(AuthContext)
if (!context) {
throw new Error('useAuth must be used within an AuthProvider')
}
return context
}
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
};
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null)
const [token, setToken] = useState(null)
const [permissions, setPermissions] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [user, setUser] = useState(null);
const [token, setToken] = useState(null);
const [permissions, setPermissions] = useState(null);
const [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false);
// Initialize auth state from localStorage
useEffect(() => {
const storedToken = localStorage.getItem('token')
const storedUser = localStorage.getItem('user')
const storedPermissions = localStorage.getItem('permissions')
// Authentication state machine phases
const [authPhase, setAuthPhase] = useState(AUTH_PHASES.INITIALISING);
const [permissionsLoading, setPermissionsLoading] = useState(false);
if (storedToken && storedUser) {
try {
setToken(storedToken)
setUser(JSON.parse(storedUser))
if (storedPermissions) {
setPermissions(JSON.parse(storedPermissions))
} else {
// Fetch permissions if not stored
fetchPermissions(storedToken)
}
} catch (error) {
console.error('Error parsing stored user data:', error)
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('permissions')
}
}
setIsLoading(false)
}, [])
// Define functions first
const fetchPermissions = useCallback(async (authToken) => {
try {
setPermissionsLoading(true);
const response = await fetch("/api/v1/permissions/user-permissions", {
headers: {
Authorization: `Bearer ${authToken}`,
},
});
// Periodically refresh permissions when user is logged in
useEffect(() => {
if (token && user) {
// Refresh permissions every 30 seconds
const interval = setInterval(() => {
refreshPermissions()
}, 30000)
if (response.ok) {
const data = await response.json();
setPermissions(data);
return data;
} else {
console.error("Failed to fetch permissions");
return null;
}
} catch (error) {
console.error("Error fetching permissions:", error);
return null;
} finally {
setPermissionsLoading(false);
}
}, []);
return () => clearInterval(interval)
}
}, [token, user])
const refreshPermissions = useCallback(async () => {
if (token) {
const updatedPermissions = await fetchPermissions(token);
return updatedPermissions;
}
return null;
}, [token, fetchPermissions]);
const fetchPermissions = async (authToken) => {
try {
const response = await fetch('/api/v1/permissions/user-permissions', {
headers: {
'Authorization': `Bearer ${authToken}`,
},
})
// Initialize auth state from localStorage
useEffect(() => {
const storedToken = localStorage.getItem("token");
const storedUser = localStorage.getItem("user");
if (response.ok) {
const data = await response.json()
setPermissions(data)
localStorage.setItem('permissions', JSON.stringify(data))
return data
} else {
console.error('Failed to fetch permissions')
return null
}
} catch (error) {
console.error('Error fetching permissions:', error)
return null
}
}
if (storedToken && storedUser) {
try {
setToken(storedToken);
setUser(JSON.parse(storedUser));
// Fetch permissions from backend
fetchPermissions(storedToken);
// User is authenticated, skip setup check
setAuthPhase(AUTH_PHASES.READY);
} catch (error) {
console.error("Error parsing stored user data:", error);
localStorage.removeItem("token");
localStorage.removeItem("user");
// Move to setup check phase
setAuthPhase(AUTH_PHASES.CHECKING_SETUP);
}
} else {
// No stored auth, check if setup is needed
setAuthPhase(AUTH_PHASES.CHECKING_SETUP);
}
}, [fetchPermissions]);
const refreshPermissions = async () => {
if (token) {
const updatedPermissions = await fetchPermissions(token)
return updatedPermissions
}
return null
}
const login = async (username, password) => {
try {
const response = await fetch("/api/v1/auth/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ username, password }),
});
const login = async (username, password) => {
try {
const response = await fetch('/api/v1/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ username, password }),
})
const data = await response.json();
const data = await response.json()
if (response.ok) {
// Check if TFA is required
if (data.requiresTfa) {
return { success: true, requiresTfa: true };
}
if (response.ok) {
setToken(data.token)
setUser(data.user)
localStorage.setItem('token', data.token)
localStorage.setItem('user', JSON.stringify(data.user))
// Fetch user permissions after successful login
const userPermissions = await fetchPermissions(data.token)
if (userPermissions) {
setPermissions(userPermissions)
}
return { success: true }
} else {
return { success: false, error: data.error || 'Login failed' }
}
} catch (error) {
return { success: false, error: 'Network error occurred' }
}
}
// Regular successful login
setToken(data.token);
setUser(data.user);
localStorage.setItem("token", data.token);
localStorage.setItem("user", JSON.stringify(data.user));
const logout = async () => {
try {
if (token) {
await fetch('/api/v1/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
}
} catch (error) {
console.error('Logout error:', error)
} finally {
setToken(null)
setUser(null)
setPermissions(null)
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('permissions')
}
}
// Fetch user permissions after successful login
const userPermissions = await fetchPermissions(data.token);
if (userPermissions) {
setPermissions(userPermissions);
}
const updateProfile = async (profileData) => {
try {
const response = await fetch('/api/v1/auth/profile', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(profileData),
})
return { success: true };
} else {
return { success: false, error: data.error || "Login failed" };
}
} catch {
return { success: false, error: "Network error occurred" };
}
};
const data = await response.json()
const logout = async () => {
try {
if (token) {
await fetch("/api/v1/auth/logout", {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
});
}
} catch (error) {
console.error("Logout error:", error);
} finally {
setToken(null);
setUser(null);
setPermissions(null);
localStorage.removeItem("token");
localStorage.removeItem("user");
}
};
if (response.ok) {
setUser(data.user)
localStorage.setItem('user', JSON.stringify(data.user))
return { success: true, user: data.user }
} else {
return { success: false, error: data.error || 'Update failed' }
}
} catch (error) {
return { success: false, error: 'Network error occurred' }
}
}
const updateProfile = async (profileData) => {
try {
const response = await fetch("/api/v1/auth/profile", {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(profileData),
});
const changePassword = async (currentPassword, newPassword) => {
try {
const response = await fetch('/api/v1/auth/change-password', {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ currentPassword, newPassword }),
})
const data = await response.json();
const data = await response.json()
if (response.ok) {
setUser(data.user);
localStorage.setItem("user", JSON.stringify(data.user));
return { success: true, user: data.user };
} else {
return { success: false, error: data.error || "Update failed" };
}
} catch {
return { success: false, error: "Network error occurred" };
}
};
if (response.ok) {
return { success: true }
} else {
return { success: false, error: data.error || 'Password change failed' }
}
} catch (error) {
return { success: false, error: 'Network error occurred' }
}
}
const changePassword = async (currentPassword, newPassword) => {
try {
const response = await fetch("/api/v1/auth/change-password", {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ currentPassword, newPassword }),
});
const isAuthenticated = () => {
return !!(token && user)
}
const data = await response.json();
const isAdmin = () => {
return user?.role === 'admin'
}
if (response.ok) {
return { success: true };
} else {
return {
success: false,
error: data.error || "Password change failed",
};
}
} catch {
return { success: false, error: "Network error occurred" };
}
};
// Permission checking functions
const hasPermission = (permission) => {
return permissions?.[permission] === true
}
const isAdmin = () => {
return user?.role === "admin";
};
const canViewDashboard = () => hasPermission('canViewDashboard')
const canViewHosts = () => hasPermission('canViewHosts')
const canManageHosts = () => hasPermission('canManageHosts')
const canViewPackages = () => hasPermission('canViewPackages')
const canManagePackages = () => hasPermission('canManagePackages')
const canViewUsers = () => hasPermission('canViewUsers')
const canManageUsers = () => hasPermission('canManageUsers')
const canViewReports = () => hasPermission('canViewReports')
const canExportData = () => hasPermission('canExportData')
const canManageSettings = () => hasPermission('canManageSettings')
// Permission checking functions
const hasPermission = (permission) => {
// If permissions are still loading, return false to show loading state
if (permissionsLoading) {
return false;
}
return permissions?.[permission] === true;
};
const value = {
user,
token,
permissions,
isLoading,
login,
logout,
updateProfile,
changePassword,
refreshPermissions,
isAuthenticated,
isAdmin,
hasPermission,
canViewDashboard,
canViewHosts,
canManageHosts,
canViewPackages,
canManagePackages,
canViewUsers,
canManageUsers,
canViewReports,
canExportData,
canManageSettings
}
const canViewDashboard = () => hasPermission("can_view_dashboard");
const canViewHosts = () => hasPermission("can_view_hosts");
const canManageHosts = () => hasPermission("can_manage_hosts");
const canViewPackages = () => hasPermission("can_view_packages");
const canManagePackages = () => hasPermission("can_manage_packages");
const canViewUsers = () => hasPermission("can_view_users");
const canManageUsers = () => hasPermission("can_manage_users");
const canViewReports = () => hasPermission("can_view_reports");
const canExportData = () => hasPermission("can_export_data");
const canManageSettings = () => hasPermission("can_manage_settings");
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}
// Check if any admin users exist (for first-time setup)
const checkAdminUsersExist = useCallback(async () => {
try {
const response = await fetch("/api/v1/auth/check-admin-users", {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (response.ok) {
const data = await response.json();
setNeedsFirstTimeSetup(!data.hasAdminUsers);
setAuthPhase(AUTH_PHASES.READY); // Setup check complete, move to ready phase
} else {
// If endpoint doesn't exist or fails, assume setup is needed
setNeedsFirstTimeSetup(true);
setAuthPhase(AUTH_PHASES.READY);
}
} catch (error) {
console.error("Error checking admin users:", error);
// If there's an error, assume setup is needed
setNeedsFirstTimeSetup(true);
setAuthPhase(AUTH_PHASES.READY);
}
}, []);
// Check for admin users ONLY when in CHECKING_SETUP phase
useEffect(() => {
if (isAuthPhase.checkingSetup(authPhase)) {
checkAdminUsersExist();
}
}, [authPhase, checkAdminUsersExist]);
const setAuthState = (authToken, authUser) => {
// Use flushSync to ensure all state updates are applied synchronously
flushSync(() => {
setToken(authToken);
setUser(authUser);
setNeedsFirstTimeSetup(false);
setAuthPhase(AUTH_PHASES.READY);
});
// Store in localStorage after state is updated
localStorage.setItem("token", authToken);
localStorage.setItem("user", JSON.stringify(authUser));
// Fetch permissions immediately for the new authenticated user
fetchPermissions(authToken);
};
// Computed loading state based on phase and permissions state
const isLoading = !isAuthPhase.ready(authPhase) || permissionsLoading;
// Function to check authentication status (maintains API compatibility)
const isAuthenticated = () => {
return !!(user && token && isAuthPhase.ready(authPhase));
};
const value = {
user,
token,
permissions,
isLoading,
needsFirstTimeSetup,
authPhase,
login,
logout,
updateProfile,
changePassword,
refreshPermissions,
setAuthState,
isAuthenticated,
isAdmin,
hasPermission,
canViewDashboard,
canViewHosts,
canManageHosts,
canViewPackages,
canManagePackages,
canViewUsers,
canManageUsers,
canViewReports,
canExportData,
canManageSettings,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

View File

@@ -1,54 +1,52 @@
import React, { createContext, useContext, useEffect, useState } from 'react'
import { createContext, useContext, useEffect, useState } from "react";
const ThemeContext = createContext()
const ThemeContext = createContext();
export const useTheme = () => {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
};
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState(() => {
// Check localStorage first, then system preference
const savedTheme = localStorage.getItem('theme')
if (savedTheme) {
return savedTheme
}
// Check system preference
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark'
}
return 'light'
})
const [theme, setTheme] = useState(() => {
// Check localStorage first, then system preference
const savedTheme = localStorage.getItem("theme");
if (savedTheme) {
return savedTheme;
}
// Check system preference
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
});
useEffect(() => {
// Apply theme to document
if (theme === 'dark') {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
// Save to localStorage
localStorage.setItem('theme', theme)
}, [theme])
useEffect(() => {
// Apply theme to document
if (theme === "dark") {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
const toggleTheme = () => {
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light')
}
// Save to localStorage
localStorage.setItem("theme", theme);
}, [theme]);
const value = {
theme,
toggleTheme,
isDark: theme === 'dark'
}
const toggleTheme = () => {
setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light"));
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
)
}
const value = {
theme,
toggleTheme,
isDark: theme === "dark",
};
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
};

View File

@@ -0,0 +1,75 @@
import { useQuery } from "@tanstack/react-query";
import { createContext, useContext, useMemo, useState } from "react";
import { isAuthReady } from "../constants/authPhases";
import { settingsAPI, versionAPI } from "../utils/api";
import { useAuth } from "./AuthContext";
const UpdateNotificationContext = createContext();
export const useUpdateNotification = () => {
const context = useContext(UpdateNotificationContext);
if (!context) {
throw new Error(
"useUpdateNotification must be used within an UpdateNotificationProvider",
);
}
return context;
};
export const UpdateNotificationProvider = ({ children }) => {
const [dismissed, setDismissed] = useState(false);
const { authPhase, isAuthenticated } = useAuth();
// Ensure settings are loaded - but only after auth is fully ready
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ["settings"],
queryFn: () => settingsAPI.get().then((res) => res.data),
staleTime: 5 * 60 * 1000, // Settings stay fresh for 5 minutes
refetchOnWindowFocus: false,
enabled: isAuthReady(authPhase, isAuthenticated()),
});
// Memoize the enabled condition to prevent unnecessary re-evaluations
const isQueryEnabled = useMemo(() => {
return (
isAuthReady(authPhase, isAuthenticated()) &&
!!settings &&
!settingsLoading
);
}, [authPhase, isAuthenticated, settings, settingsLoading]);
// Query for update information
const {
data: updateData,
isLoading,
error,
} = useQuery({
queryKey: ["updateCheck"],
queryFn: () => versionAPI.checkUpdates().then((res) => res.data),
staleTime: 10 * 60 * 1000, // Data stays fresh for 10 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
retry: 1,
enabled: isQueryEnabled,
});
const updateAvailable = updateData?.isUpdateAvailable && !dismissed;
const updateInfo = updateData;
const dismissNotification = () => {
setDismissed(true);
};
const value = {
updateAvailable,
updateInfo,
dismissNotification,
isLoading,
error,
};
return (
<UpdateNotificationContext.Provider value={value}>
{children}
</UpdateNotificationContext.Provider>
);
};

View File

@@ -1,127 +1,128 @@
/** biome-ignore-all lint/suspicious/noUnknownAtRules: Bogus */
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: Inter, ui-sans-serif, system-ui;
}
body {
@apply bg-secondary-50 dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 antialiased;
}
html {
font-family: Inter, ui-sans-serif, system-ui;
}
body {
@apply bg-secondary-50 dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 antialiased;
}
}
@layer components {
.btn {
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-150;
}
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
}
.btn-secondary {
@apply btn bg-secondary-600 text-white hover:bg-secondary-700 focus:ring-secondary-500;
}
.btn-success {
@apply btn bg-success-600 text-white hover:bg-success-700 focus:ring-success-500;
}
.btn-warning {
@apply btn bg-warning-600 text-white hover:bg-warning-700 focus:ring-warning-500;
}
.btn-danger {
@apply btn bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500;
}
.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;
}
.card {
@apply bg-white dark:bg-secondary-800 rounded-lg shadow-card dark:shadow-card-dark border border-secondary-200 dark:border-secondary-600;
}
.card-hover {
@apply card hover:shadow-card-hover transition-shadow duration-150;
}
.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;
}
.label {
@apply block text-sm font-medium text-secondary-700 dark:text-secondary-200;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-primary {
@apply badge bg-primary-100 text-primary-800;
}
.badge-secondary {
@apply badge bg-secondary-100 text-secondary-800;
}
.badge-success {
@apply badge bg-success-100 text-success-800;
}
.badge-warning {
@apply badge bg-warning-100 text-warning-800;
}
.badge-danger {
@apply badge bg-danger-100 text-danger-800;
}
.btn {
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-150;
}
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
}
.btn-secondary {
@apply btn bg-secondary-600 text-white hover:bg-secondary-700 focus:ring-secondary-500;
}
.btn-success {
@apply btn bg-success-600 text-white hover:bg-success-700 focus:ring-success-500;
}
.btn-warning {
@apply btn bg-warning-600 text-white hover:bg-warning-700 focus:ring-warning-500;
}
.btn-danger {
@apply btn bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500;
}
.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;
}
.card {
@apply bg-white dark:bg-secondary-800 rounded-lg shadow-card dark:shadow-card-dark border border-secondary-200 dark:border-secondary-600;
}
.card-hover {
@apply card hover:shadow-card-hover transition-shadow duration-150;
}
.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;
}
.label {
@apply block text-sm font-medium text-secondary-700 dark:text-secondary-200;
}
.badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.badge-primary {
@apply badge bg-primary-100 text-primary-800;
}
.badge-secondary {
@apply badge bg-secondary-100 text-secondary-800;
}
.badge-success {
@apply badge bg-success-100 text-success-800;
}
.badge-warning {
@apply badge bg-warning-100 text-warning-800;
}
.badge-danger {
@apply badge bg-danger-100 text-danger-800;
}
}
@layer utilities {
.text-shadow {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
}
.dark .scrollbar-thin {
scrollbar-color: #64748b #475569;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: #f1f5f9;
}
.dark .scrollbar-thin::-webkit-scrollbar-track {
background: #475569;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 3px;
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: #64748b;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: #94a3b8;
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: #94a3b8;
}
}
.text-shadow {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
}
.dark .scrollbar-thin {
scrollbar-color: #64748b #475569;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: #f1f5f9;
}
.dark .scrollbar-thin::-webkit-scrollbar-track {
background: #475569;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: #cbd5e1;
border-radius: 3px;
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: #64748b;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: #94a3b8;
}
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: #94a3b8;
}
}

View File

@@ -1,27 +1,27 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App.jsx'
import './index.css'
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.jsx";
import "./index.css";
// Create a client for React Query
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
})
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
},
},
});
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</BrowserRouter>
</React.StrictMode>,
)
ReactDOM.createRoot(document.getElementById("root")).render(
<StrictMode>
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</BrowserRouter>
</StrictMode>,
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,498 +1,530 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Plus,
Edit,
Trash2,
Server,
Users,
AlertTriangle,
CheckCircle
} from 'lucide-react'
import { hostGroupsAPI } from '../utils/api'
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Edit, Plus, Server, Trash2 } from "lucide-react";
import { useId, useState } from "react";
import { hostGroupsAPI } from "../utils/api";
const HostGroups = () => {
const [showCreateModal, setShowCreateModal] = useState(false)
const [showEditModal, setShowEditModal] = useState(false)
const [selectedGroup, setSelectedGroup] = useState(null)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [groupToDelete, setGroupToDelete] = useState(null)
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedGroup, setSelectedGroup] = useState(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [groupToDelete, setGroupToDelete] = useState(null);
const queryClient = useQueryClient()
const queryClient = useQueryClient();
// Fetch host groups
const { data: hostGroups, isLoading, error } = useQuery({
queryKey: ['hostGroups'],
queryFn: () => hostGroupsAPI.list().then(res => res.data),
})
// Fetch host groups
const {
data: hostGroups,
isLoading,
error,
} = useQuery({
queryKey: ["hostGroups"],
queryFn: () => hostGroupsAPI.list().then((res) => res.data),
});
// Create host group mutation
const createMutation = useMutation({
mutationFn: (data) => hostGroupsAPI.create(data),
onSuccess: () => {
queryClient.invalidateQueries(['hostGroups'])
setShowCreateModal(false)
},
onError: (error) => {
console.error('Failed to create host group:', error)
}
})
// Create host group mutation
const createMutation = useMutation({
mutationFn: (data) => hostGroupsAPI.create(data),
onSuccess: () => {
queryClient.invalidateQueries(["hostGroups"]);
setShowCreateModal(false);
},
onError: (error) => {
console.error("Failed to create host group:", error);
},
});
// Update host group mutation
const updateMutation = useMutation({
mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries(['hostGroups'])
setShowEditModal(false)
setSelectedGroup(null)
},
onError: (error) => {
console.error('Failed to update host group:', error)
}
})
// Update host group mutation
const updateMutation = useMutation({
mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries(["hostGroups"]);
setShowEditModal(false);
setSelectedGroup(null);
},
onError: (error) => {
console.error("Failed to update host group:", error);
},
});
// Delete host group mutation
const deleteMutation = useMutation({
mutationFn: (id) => hostGroupsAPI.delete(id),
onSuccess: () => {
queryClient.invalidateQueries(['hostGroups'])
setShowDeleteModal(false)
setGroupToDelete(null)
},
onError: (error) => {
console.error('Failed to delete host group:', error)
}
})
// Delete host group mutation
const deleteMutation = useMutation({
mutationFn: (id) => hostGroupsAPI.delete(id),
onSuccess: () => {
queryClient.invalidateQueries(["hostGroups"]);
setShowDeleteModal(false);
setGroupToDelete(null);
},
onError: (error) => {
console.error("Failed to delete host group:", error);
},
});
const handleCreate = (data) => {
createMutation.mutate(data)
}
const handleCreate = (data) => {
createMutation.mutate(data);
};
const handleEdit = (group) => {
setSelectedGroup(group)
setShowEditModal(true)
}
const handleEdit = (group) => {
setSelectedGroup(group);
setShowEditModal(true);
};
const handleUpdate = (data) => {
updateMutation.mutate({ id: selectedGroup.id, data })
}
const handleUpdate = (data) => {
updateMutation.mutate({ id: selectedGroup.id, data });
};
const handleDeleteClick = (group) => {
setGroupToDelete(group)
setShowDeleteModal(true)
}
const handleDeleteClick = (group) => {
setGroupToDelete(group);
setShowDeleteModal(true);
};
const handleDeleteConfirm = () => {
deleteMutation.mutate(groupToDelete.id)
}
const handleDeleteConfirm = () => {
deleteMutation.mutate(groupToDelete.id);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading host groups
</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || 'Failed to load host groups'}
</p>
</div>
</div>
</div>
)
}
if (error) {
return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading host groups
</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || "Failed to load host groups"}
</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-secondary-600 dark:text-secondary-300">
Organize your hosts into logical groups for better management
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Create Group
</button>
</div>
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-secondary-600 dark:text-secondary-300">
Organize your hosts into logical groups for better management
</p>
</div>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Create Group
</button>
</div>
{/* Host Groups Grid */}
{hostGroups && hostGroups.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{hostGroups.map((group) => (
<div key={group.id} className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }}
/>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
{group.name}
</h3>
{group.description && (
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-1">
{group.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => handleEdit(group)}
className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
title="Edit group"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDeleteClick(group)}
className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
title="Delete group"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-4 flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-300">
<div className="flex items-center gap-1">
<Server className="h-4 w-4" />
<span>{group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
No host groups yet
</h3>
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
Create your first host group to organize your hosts
</p>
<button
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2 mx-auto"
>
<Plus className="h-4 w-4" />
Create Group
</button>
</div>
)}
{/* Host Groups Grid */}
{hostGroups && hostGroups.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{hostGroups.map((group) => (
<div
key={group.id}
className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }}
/>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
{group.name}
</h3>
{group.description && (
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-1">
{group.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleEdit(group)}
className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
title="Edit group"
>
<Edit className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleDeleteClick(group)}
className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
title="Delete group"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
{/* Create Modal */}
{showCreateModal && (
<CreateHostGroupModal
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreate}
isLoading={createMutation.isPending}
/>
)}
<div className="mt-4 flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-300">
<div className="flex items-center gap-1">
<Server className="h-4 w-4" />
<span>
{group._count.hosts} host
{group._count.hosts !== 1 ? "s" : ""}
</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
No host groups yet
</h3>
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
Create your first host group to organize your hosts
</p>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2 mx-auto"
>
<Plus className="h-4 w-4" />
Create Group
</button>
</div>
)}
{/* Edit Modal */}
{showEditModal && selectedGroup && (
<EditHostGroupModal
group={selectedGroup}
onClose={() => {
setShowEditModal(false)
setSelectedGroup(null)
}}
onSubmit={handleUpdate}
isLoading={updateMutation.isPending}
/>
)}
{/* Create Modal */}
{showCreateModal && (
<CreateHostGroupModal
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreate}
isLoading={createMutation.isPending}
/>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && groupToDelete && (
<DeleteHostGroupModal
group={groupToDelete}
onClose={() => {
setShowDeleteModal(false)
setGroupToDelete(null)
}}
onConfirm={handleDeleteConfirm}
isLoading={deleteMutation.isPending}
/>
)}
</div>
)
}
{/* Edit Modal */}
{showEditModal && selectedGroup && (
<EditHostGroupModal
group={selectedGroup}
onClose={() => {
setShowEditModal(false);
setSelectedGroup(null);
}}
onSubmit={handleUpdate}
isLoading={updateMutation.isPending}
/>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && groupToDelete && (
<DeleteHostGroupModal
group={groupToDelete}
onClose={() => {
setShowDeleteModal(false);
setGroupToDelete(null);
}}
onConfirm={handleDeleteConfirm}
isLoading={deleteMutation.isPending}
/>
)}
</div>
);
};
// Create Host Group Modal
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
const [formData, setFormData] = useState({
name: '',
description: '',
color: '#3B82F6'
})
const nameId = useId();
const descriptionId = useId();
const colorId = useId();
const [formData, setFormData] = useState({
name: "",
description: "",
color: "#3B82F6",
});
const handleSubmit = (e) => {
e.preventDefault()
onSubmit(formData)
}
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(formData);
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
Create Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Name *
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="#3B82F6"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={isLoading}
>
{isLoading ? 'Creating...' : 'Create Group'}
</button>
</div>
</form>
</div>
</div>
)
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
Create Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={nameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Name *
</label>
<input
type="text"
id={nameId}
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label
htmlFor={descriptionId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Description
</label>
<textarea
id={descriptionId}
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label
htmlFor={colorId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
id={colorId}
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="#3B82F6"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button type="submit" className="btn-primary" disabled={isLoading}>
{isLoading ? "Creating..." : "Create Group"}
</button>
</div>
</form>
</div>
</div>
);
};
// Edit Host Group Modal
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
const [formData, setFormData] = useState({
name: group.name,
description: group.description || '',
color: group.color || '#3B82F6'
})
const editNameId = useId();
const editDescriptionId = useId();
const editColorId = useId();
const [formData, setFormData] = useState({
name: group.name,
description: group.description || "",
color: group.color || "#3B82F6",
});
const handleSubmit = (e) => {
e.preventDefault()
onSubmit(formData)
}
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(formData);
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
Edit Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Name *
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="#3B82F6"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={isLoading}
>
{isLoading ? 'Updating...' : 'Update Group'}
</button>
</div>
</form>
</div>
</div>
)
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
Edit Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={editNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Name *
</label>
<input
type="text"
id={editNameId}
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label
htmlFor={editDescriptionId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Description
</label>
<textarea
id={editDescriptionId}
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label
htmlFor={editColorId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
id={editColorId}
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
placeholder="#3B82F6"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button type="submit" className="btn-primary" disabled={isLoading}>
{isLoading ? "Updating..." : "Update Group"}
</button>
</div>
</form>
</div>
</div>
);
};
// Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Delete Host Group
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300">
This action cannot be undone
</p>
</div>
</div>
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-200">
Are you sure you want to delete the host group{' '}
<span className="font-semibold">"{group.name}"</span>?
</p>
{group._count.hosts > 0 && (
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
<p className="text-sm text-warning-800">
<strong>Warning:</strong> This group contains {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}.
You must move or remove these hosts before deleting the group.
</p>
</div>
)}
</div>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
onClick={onConfirm}
className="btn-danger"
disabled={isLoading || group._count.hosts > 0}
>
{isLoading ? 'Deleting...' : 'Delete Group'}
</button>
</div>
</div>
</div>
)
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Delete Host Group
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300">
This action cannot be undone
</p>
</div>
</div>
export default HostGroups
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-200">
Are you sure you want to delete the host group{" "}
<span className="font-semibold">"{group.name}"</span>?
</p>
{group._count.hosts > 0 && (
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
<p className="text-sm text-warning-800">
<strong>Warning:</strong> This group contains{" "}
{group._count.hosts} host
{group._count.hosts !== 1 ? "s" : ""}. You must move or remove
these hosts before deleting the group.
</p>
</div>
)}
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
className="btn-danger"
disabled={isLoading || group._count.hosts > 0}
>
{isLoading ? "Deleting..." : "Delete Group"}
</button>
</div>
</div>
</div>
);
};
export default HostGroups;

File diff suppressed because it is too large Load Diff

View File

@@ -1,158 +1,488 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Eye, EyeOff, Lock, User, AlertCircle } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import {
AlertCircle,
ArrowLeft,
Eye,
EyeOff,
Lock,
Mail,
Smartphone,
User,
} from "lucide-react";
import { useEffect, useId, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { authAPI } from "../utils/api";
const Login = () => {
const [formData, setFormData] = useState({
username: '',
password: ''
})
const [showPassword, setShowPassword] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const usernameId = useId();
const firstNameId = useId();
const lastNameId = useId();
const emailId = useId();
const passwordId = useId();
const tokenId = useId();
const { login, setAuthState } = useAuth();
const [isSignupMode, setIsSignupMode] = useState(false);
const [formData, setFormData] = useState({
username: "",
email: "",
password: "",
firstName: "",
lastName: "",
});
const [tfaData, setTfaData] = useState({
token: "",
});
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [requiresTfa, setRequiresTfa] = useState(false);
const [tfaUsername, setTfaUsername] = useState("");
const [signupEnabled, setSignupEnabled] = useState(false);
const navigate = useNavigate()
const { login } = useAuth()
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
// Check if signup is enabled
useEffect(() => {
const checkSignupEnabled = async () => {
try {
const response = await fetch("/api/v1/auth/signup-enabled");
if (response.ok) {
const data = await response.json();
setSignupEnabled(data.signupEnabled);
}
} catch (error) {
console.error("Failed to check signup status:", error);
// Default to disabled on error for security
setSignupEnabled(false);
}
};
checkSignupEnabled();
}, []);
try {
const result = await login(formData.username, formData.password)
if (result.success) {
// Redirect to dashboard
navigate('/')
} else {
setError(result.error || 'Login failed')
}
} catch (err) {
setError('Network error occurred')
} finally {
setIsLoading(false)
}
}
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
try {
// Use the AuthContext login function which handles everything
const result = await login(formData.username, formData.password);
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100">
<Lock className="h-6 w-6 text-primary-600" />
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900">
Sign in to PatchMon
</h2>
<p className="mt-2 text-center text-sm text-secondary-600">
Monitor and manage your Linux package updates
</p>
</div>
if (result.requiresTfa) {
setRequiresTfa(true);
setTfaUsername(formData.username);
setError("");
} else if (result.success) {
navigate("/");
} else {
setError(result.error || "Login failed");
}
} catch (err) {
setError(err.response?.data?.error || "Login failed");
} finally {
setIsLoading(false);
}
};
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-secondary-700">
Username or Email
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-secondary-400" />
</div>
<input
id="username"
name="username"
type="text"
required
value={formData.username}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Enter your username or email"
/>
</div>
</div>
const handleSignupSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
<div>
<label htmlFor="password" className="block text-sm font-medium text-secondary-700">
Password
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Lock className="h-5 w-5 text-secondary-400" />
</div>
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
required
value={formData.password}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-10 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Enter your password"
/>
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-secondary-400 hover:text-secondary-500"
>
{showPassword ? (
<EyeOff className="h-5 w-5" />
) : (
<Eye className="h-5 w-5" />
)}
</button>
</div>
</div>
</div>
</div>
try {
const response = await authAPI.signup(
formData.username,
formData.email,
formData.password,
formData.firstName,
formData.lastName,
);
if (response.data?.token) {
// Update AuthContext state and localStorage
setAuthState(response.data.token, response.data.user);
{error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<div className="flex">
<AlertCircle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<p className="text-sm text-danger-700">{error}</p>
</div>
</div>
</div>
)}
// Redirect to dashboard
navigate("/");
} else {
setError("Signup failed - invalid response");
}
} catch (err) {
console.error("Signup error:", err);
const errorMessage =
err.response?.data?.error ||
(err.response?.data?.errors && err.response.data.errors.length > 0
? err.response.data.errors.map((e) => e.msg).join(", ")
: err.message || "Signup failed");
setError(errorMessage);
} finally {
setIsLoading(false);
}
};
<div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Signing in...
</div>
) : (
'Sign in'
)}
</button>
</div>
const handleTfaSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
<div className="text-center">
<p className="text-sm text-secondary-600">
Need help? Contact your system administrator.
</p>
</div>
</form>
</div>
</div>
)
}
try {
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token);
export default Login
if (response.data?.token) {
// Update AuthContext with the new authentication state
setAuthState(response.data.token, response.data.user);
// Redirect to dashboard
navigate("/");
} else {
setError("TFA verification failed - invalid response");
}
} catch (err) {
console.error("TFA verification error:", err);
const errorMessage =
err.response?.data?.error || err.message || "TFA verification failed";
setError(errorMessage);
// Clear the token input for security
setTfaData({ token: "" });
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
const handleTfaInputChange = (e) => {
setTfaData({
...tfaData,
[e.target.name]: e.target.value.replace(/\D/g, "").slice(0, 6),
});
// Clear error when user starts typing
if (error) {
setError("");
}
};
const handleBackToLogin = () => {
setRequiresTfa(false);
setTfaData({ token: "" });
setError("");
};
const toggleMode = () => {
// Only allow signup mode if signup is enabled
if (!signupEnabled && !isSignupMode) {
return; // Don't allow switching to signup if disabled
}
setIsSignupMode(!isSignupMode);
setFormData({
username: "",
email: "",
password: "",
firstName: "",
lastName: "",
});
setError("");
};
return (
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100">
<Lock size={24} color="#2563eb" strokeWidth={2} />
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900">
{isSignupMode ? "Create PatchMon Account" : "Sign in to PatchMon"}
</h2>
<p className="mt-2 text-center text-sm text-secondary-600">
Monitor and manage your Linux package updates
</p>
</div>
{!requiresTfa ? (
<form
className="mt-8 space-y-6"
onSubmit={isSignupMode ? handleSignupSubmit : handleSubmit}
>
<div className="space-y-4">
<div>
<label
htmlFor={usernameId}
className="block text-sm font-medium text-secondary-700"
>
{isSignupMode ? "Username" : "Username or Email"}
</label>
<div className="mt-1 relative">
<input
id={usernameId}
name="username"
type="text"
required
value={formData.username}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder={
isSignupMode
? "Enter your username"
: "Enter your username or email"
}
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
<User size={20} color="#64748b" strokeWidth={2} />
</div>
</div>
</div>
{isSignupMode && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label
htmlFor={firstNameId}
className="block text-sm font-medium text-secondary-700"
>
First Name
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-secondary-400" />
</div>
<input
id={firstNameId}
name="firstName"
type="text"
required
value={formData.firstName}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Enter your first name"
/>
</div>
</div>
<div>
<label
htmlFor={lastNameId}
className="block text-sm font-medium text-secondary-700"
>
Last Name
</label>
<div className="mt-1 relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<User className="h-5 w-5 text-secondary-400" />
</div>
<input
id={lastNameId}
name="lastName"
type="text"
required
value={formData.lastName}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Enter your last name"
/>
</div>
</div>
</div>
<div>
<label
htmlFor={emailId}
className="block text-sm font-medium text-secondary-700"
>
Email
</label>
<div className="mt-1 relative">
<input
id={emailId}
name="email"
type="email"
required
value={formData.email}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Enter your email"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
<Mail size={20} color="#64748b" strokeWidth={2} />
</div>
</div>
</div>
</>
)}
<div>
<label
htmlFor={passwordId}
className="block text-sm font-medium text-secondary-700"
>
Password
</label>
<div className="mt-1 relative">
<input
id={passwordId}
name="password"
type={showPassword ? "text" : "password"}
required
value={formData.password}
onChange={handleInputChange}
className="appearance-none rounded-md relative block w-full pl-10 pr-10 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
placeholder="Enter your password"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none z-20 flex items-center">
<Lock size={20} color="#64748b" strokeWidth={2} />
</div>
<div className="absolute right-3 top-1/2 -translate-y-1/2 z-20 flex items-center">
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="bg-transparent border-none cursor-pointer p-1 flex items-center justify-center"
>
{showPassword ? (
<EyeOff size={20} color="#64748b" strokeWidth={2} />
) : (
<Eye size={20} color="#64748b" strokeWidth={2} />
)}
</button>
</div>
</div>
</div>
</div>
{error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<div className="flex">
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
<div className="ml-3">
<p className="text-sm text-danger-700">{error}</p>
</div>
</div>
</div>
)}
<div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
{isSignupMode ? "Creating account..." : "Signing in..."}
</div>
) : isSignupMode ? (
"Create Account"
) : (
"Sign in"
)}
</button>
</div>
{signupEnabled && (
<div className="text-center">
<p className="text-sm text-secondary-600">
{isSignupMode
? "Already have an account?"
: "Don't have an account?"}{" "}
<button
type="button"
onClick={toggleMode}
className="font-medium text-primary-600 hover:text-primary-500 focus:outline-none focus:underline"
>
{isSignupMode ? "Sign in" : "Sign up"}
</button>
</p>
</div>
)}
</form>
) : (
<form className="mt-8 space-y-6" onSubmit={handleTfaSubmit}>
<div className="text-center">
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
<Smartphone size={24} color="#2563eb" strokeWidth={2} />
</div>
<h3 className="mt-4 text-lg font-medium text-secondary-900">
Two-Factor Authentication
</h3>
<p className="mt-2 text-sm text-secondary-600">
Enter the 6-digit code from your authenticator app
</p>
</div>
<div>
<label
htmlFor={tokenId}
className="block text-sm font-medium text-secondary-700"
>
Verification Code
</label>
<div className="mt-1">
<input
id={tokenId}
name="token"
type="text"
required
value={tfaData.token}
onChange={handleTfaInputChange}
className="appearance-none rounded-md relative block w-full px-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm text-center text-lg font-mono tracking-widest"
placeholder="000000"
maxLength="6"
/>
</div>
</div>
{error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<div className="flex">
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
<div className="ml-3">
<p className="text-sm text-danger-700">{error}</p>
</div>
</div>
</div>
)}
<div className="space-y-3">
<button
type="submit"
disabled={isLoading || tfaData.token.length !== 6}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Verifying...
</div>
) : (
"Verify Code"
)}
</button>
<button
type="button"
onClick={handleBackToLogin}
className="group relative w-full flex justify-center py-2 px-4 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 items-center gap-2"
>
<ArrowLeft size={16} color="#475569" strokeWidth={2} />
Back to Login
</button>
</div>
<div className="text-center">
<p className="text-sm text-secondary-600">
Don't have access to your authenticator? Use a backup code.
</p>
</div>
</form>
)}
</div>
</div>
);
};
export default Login;

View File

@@ -0,0 +1,617 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
Edit,
Plus,
Server,
Settings,
Trash2,
Users,
} from "lucide-react";
import { useId, useState } from "react";
import { hostGroupsAPI } from "../utils/api";
const Options = () => {
const [activeTab, setActiveTab] = useState("hostgroups");
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedGroup, setSelectedGroup] = useState(null);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [groupToDelete, setGroupToDelete] = useState(null);
const queryClient = useQueryClient();
// Tab configuration
const tabs = [
{ id: "hostgroups", name: "Host Groups", icon: Users },
{
id: "notifications",
name: "Notifications",
icon: AlertTriangle,
comingSoon: true,
},
];
// Fetch host groups
const {
data: hostGroups,
isLoading,
error,
} = useQuery({
queryKey: ["hostGroups"],
queryFn: () => hostGroupsAPI.list().then((res) => res.data),
});
// Create host group mutation
const createMutation = useMutation({
mutationFn: (data) => hostGroupsAPI.create(data),
onSuccess: () => {
queryClient.invalidateQueries(["hostGroups"]);
setShowCreateModal(false);
},
onError: (error) => {
console.error("Failed to create host group:", error);
},
});
// Update host group mutation
const updateMutation = useMutation({
mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries(["hostGroups"]);
setShowEditModal(false);
setSelectedGroup(null);
},
onError: (error) => {
console.error("Failed to update host group:", error);
},
});
// Delete host group mutation
const deleteMutation = useMutation({
mutationFn: (id) => hostGroupsAPI.delete(id),
onSuccess: () => {
queryClient.invalidateQueries(["hostGroups"]);
setShowDeleteModal(false);
setGroupToDelete(null);
},
onError: (error) => {
console.error("Failed to delete host group:", error);
},
});
const handleCreate = (data) => {
createMutation.mutate(data);
};
const handleEdit = (group) => {
setSelectedGroup(group);
setShowEditModal(true);
};
const handleUpdate = (data) => {
updateMutation.mutate({ id: selectedGroup.id, data });
};
const handleDeleteClick = (group) => {
setGroupToDelete(group);
setShowDeleteModal(true);
};
const handleDeleteConfirm = () => {
deleteMutation.mutate(groupToDelete.id);
};
const renderHostGroupsTab = () => {
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading host groups
</h3>
<p className="text-sm text-danger-700 mt-1">
{error.message || "Failed to load host groups"}
</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">
Host Groups
</h2>
<p className="text-secondary-600 dark:text-secondary-300">
Organize your hosts into logical groups for better management
</p>
</div>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Create Group
</button>
</div>
{/* Host Groups Grid */}
{hostGroups && hostGroups.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{hostGroups.map((group) => (
<div
key={group.id}
className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: group.color }}
/>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
{group.name}
</h3>
{group.description && (
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-1">
{group.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleEdit(group)}
className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
title="Edit group"
>
<Edit className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => handleDeleteClick(group)}
className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
title="Delete group"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-4 flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-300">
<div className="flex items-center gap-1">
<Server className="h-4 w-4" />
<span>
{group._count.hosts} host
{group._count.hosts !== 1 ? "s" : ""}
</span>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
No host groups yet
</h3>
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
Create your first host group to organize your hosts
</p>
<button
type="button"
onClick={() => setShowCreateModal(true)}
className="btn-primary flex items-center gap-2 mx-auto"
>
<Plus className="h-4 w-4" />
Create Group
</button>
</div>
)}
</div>
);
};
const renderComingSoonTab = (tabName) => (
<div className="text-center py-12">
<Settings className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
{tabName} Coming Soon
</h3>
<p className="text-secondary-600 dark:text-secondary-300">
This feature is currently under development and will be available in a
future update.
</p>
</div>
);
return (
<div className="space-y-6">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
Options
</h1>
<p className="text-secondary-600 dark:text-secondary-300 mt-1">
Configure PatchMon parameters and user preferences
</p>
</div>
{/* Tabs */}
<div className="border-b border-secondary-200 dark:border-secondary-600">
<nav className="-mb-px flex space-x-8">
{tabs.map((tab) => {
const Icon = tab.icon;
return (
<button
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-primary-500 text-primary-600 dark:text-primary-400"
: "border-transparent text-secondary-500 hover:text-secondary-700 hover:border-secondary-300 dark:text-secondary-400 dark:hover:text-secondary-300"
}`}
>
<Icon className="h-4 w-4" />
{tab.name}
{tab.comingSoon && (
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
Soon
</span>
)}
</button>
);
})}
</nav>
</div>
{/* Tab Content */}
<div className="mt-6">
{activeTab === "hostgroups" && renderHostGroupsTab()}
{activeTab === "notifications" && renderComingSoonTab("Notifications")}
</div>
{/* Create Modal */}
{showCreateModal && (
<CreateHostGroupModal
onClose={() => setShowCreateModal(false)}
onSubmit={handleCreate}
isLoading={createMutation.isPending}
/>
)}
{/* Edit Modal */}
{showEditModal && selectedGroup && (
<EditHostGroupModal
group={selectedGroup}
onClose={() => {
setShowEditModal(false);
setSelectedGroup(null);
}}
onSubmit={handleUpdate}
isLoading={updateMutation.isPending}
/>
)}
{/* Delete Confirmation Modal */}
{showDeleteModal && groupToDelete && (
<DeleteHostGroupModal
group={groupToDelete}
onClose={() => {
setShowDeleteModal(false);
setGroupToDelete(null);
}}
onConfirm={handleDeleteConfirm}
isLoading={deleteMutation.isPending}
/>
)}
</div>
);
};
// Create Host Group Modal
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
const nameId = useId();
const descriptionId = useId();
const colorId = useId();
const [formData, setFormData] = useState({
name: "",
description: "",
color: "#3B82F6",
});
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(formData);
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
Create Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={nameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Name *
</label>
<input
type="text"
id={nameId}
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label
htmlFor={descriptionId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Description
</label>
<textarea
id={descriptionId}
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label
htmlFor={colorId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
id={colorId}
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="#3B82F6"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button type="submit" className="btn-primary" disabled={isLoading}>
{isLoading ? "Creating..." : "Create Group"}
</button>
</div>
</form>
</div>
</div>
);
};
// Edit Host Group Modal
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
const editNameId = useId();
const editDescriptionId = useId();
const editColorId = useId();
const [formData, setFormData] = useState({
name: group.name,
description: group.description || "",
color: group.color || "#3B82F6",
});
const handleSubmit = (e) => {
e.preventDefault();
onSubmit(formData);
};
const handleChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value,
});
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
Edit Host Group
</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={editNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Name *
</label>
<input
type="text"
id={editNameId}
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="e.g., Production Servers"
/>
</div>
<div>
<label
htmlFor={editDescriptionId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Description
</label>
<textarea
id={editDescriptionId}
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="Optional description for this group"
/>
</div>
<div>
<label
htmlFor={editColorId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Color
</label>
<div className="flex items-center gap-3">
<input
type="color"
id={editColorId}
name="color"
value={formData.color}
onChange={handleChange}
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
/>
<input
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
placeholder="#3B82F6"
/>
</div>
</div>
<div className="flex justify-end gap-3 pt-4">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button type="submit" className="btn-primary" disabled={isLoading}>
{isLoading ? "Updating..." : "Update Group"}
</button>
</div>
</form>
</div>
</div>
);
};
// Delete Confirmation Modal
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-danger-600" />
</div>
<div>
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
Delete Host Group
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300">
This action cannot be undone
</p>
</div>
</div>
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-200">
Are you sure you want to delete the host group{" "}
<span className="font-semibold">"{group.name}"</span>?
</p>
{group._count.hosts > 0 && (
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
<p className="text-sm text-warning-800">
<strong>Warning:</strong> This group contains{" "}
{group._count.hosts} host
{group._count.hosts !== 1 ? "s" : ""}. You must move or remove
these hosts before deleting the group.
</p>
</div>
)}
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
className="btn-outline"
disabled={isLoading}
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
className="btn-danger"
disabled={isLoading || group._count.hosts > 0}
>
{isLoading ? "Deleting..." : "Delete Group"}
</button>
</div>
</div>
</div>
);
};
export default Options;

View File

@@ -1,25 +1,26 @@
import React from 'react'
import { useParams } from 'react-router-dom'
import { Package } from 'lucide-react'
import { Package } from "lucide-react";
import { useParams } from "react-router-dom";
const PackageDetail = () => {
const { packageId } = useParams()
return (
<div className="space-y-6">
<div className="card p-8 text-center">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 mb-2">Package Details</h3>
<p className="text-secondary-600">
Detailed view for package: {packageId}
</p>
<p className="text-secondary-600 mt-2">
This page will show package information, affected hosts, version distribution, and more.
</p>
</div>
</div>
)
}
const { packageId } = useParams();
export default PackageDetail
return (
<div className="space-y-6">
<div className="card p-8 text-center">
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-secondary-900 mb-2">
Package Details
</h3>
<p className="text-secondary-600">
Detailed view for package: {packageId}
</p>
<p className="text-secondary-600 mt-2">
This page will show package information, affected hosts, version
distribution, and more.
</p>
</div>
</div>
);
};
export default PackageDetail;

View File

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

View File

@@ -1,389 +1,497 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Shield,
Settings,
Users,
Server,
Package,
BarChart3,
Download,
Eye,
Edit,
Trash2,
Plus,
Save,
X,
AlertTriangle,
RefreshCw
} from 'lucide-react'
import { permissionsAPI } from '../utils/api'
import { useAuth } from '../contexts/AuthContext'
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
BarChart3,
Download,
Edit,
Package,
Plus,
RefreshCw,
Save,
Server,
Settings,
Shield,
Trash2,
Users,
X,
} from "lucide-react";
import { useEffect, useId, useState } from "react";
import { useAuth } from "../contexts/AuthContext";
import { permissionsAPI } from "../utils/api";
const Permissions = () => {
const [editingRole, setEditingRole] = useState(null)
const [showAddModal, setShowAddModal] = useState(false)
const queryClient = useQueryClient()
const { refreshPermissions } = useAuth()
const [editingRole, setEditingRole] = useState(null);
const [showAddModal, setShowAddModal] = useState(false);
const queryClient = useQueryClient();
const { refreshPermissions } = useAuth();
// Fetch all role permissions
const { data: roles, isLoading, error } = useQuery({
queryKey: ['rolePermissions'],
queryFn: () => permissionsAPI.getRoles().then(res => res.data)
})
// Fetch all role permissions
const {
data: roles,
isLoading,
error,
} = useQuery({
queryKey: ["rolePermissions"],
queryFn: () => permissionsAPI.getRoles().then((res) => res.data),
});
// Update role permissions mutation
const updateRoleMutation = useMutation({
mutationFn: ({ role, permissions }) => permissionsAPI.updateRole(role, permissions),
onSuccess: () => {
queryClient.invalidateQueries(['rolePermissions'])
setEditingRole(null)
// Refresh user permissions to apply changes immediately
refreshPermissions()
}
})
// Update role permissions mutation
const updateRoleMutation = useMutation({
mutationFn: ({ role, permissions }) =>
permissionsAPI.updateRole(role, permissions),
onSuccess: () => {
queryClient.invalidateQueries(["rolePermissions"]);
setEditingRole(null);
// Refresh user permissions to apply changes immediately
refreshPermissions();
},
});
// Delete role mutation
const deleteRoleMutation = useMutation({
mutationFn: (role) => permissionsAPI.deleteRole(role),
onSuccess: () => {
queryClient.invalidateQueries(['rolePermissions'])
}
})
// Delete role mutation
const deleteRoleMutation = useMutation({
mutationFn: (role) => permissionsAPI.deleteRole(role),
onSuccess: () => {
queryClient.invalidateQueries(["rolePermissions"]);
},
});
const handleSavePermissions = async (role, permissions) => {
try {
await updateRoleMutation.mutateAsync({ role, permissions })
} catch (error) {
console.error('Failed to update permissions:', error)
}
}
const handleSavePermissions = async (role, permissions) => {
try {
await updateRoleMutation.mutateAsync({ role, permissions });
} catch (error) {
console.error("Failed to update permissions:", error);
}
};
const handleDeleteRole = async (role) => {
if (window.confirm(`Are you sure you want to delete the "${role}" role? This action cannot be undone.`)) {
try {
await deleteRoleMutation.mutateAsync(role)
} catch (error) {
console.error('Failed to delete role:', error)
}
}
}
const handleDeleteRole = async (role) => {
if (
window.confirm(
`Are you sure you want to delete the "${role}" role? This action cannot be undone.`,
)
) {
try {
await deleteRoleMutation.mutateAsync(role);
} catch (error) {
console.error("Failed to delete role:", error);
}
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">Error loading permissions</h3>
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div>
</div>
</div>
)
}
if (error) {
return (
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-danger-400" />
<div className="ml-3">
<h3 className="text-sm font-medium text-danger-800">
Error loading permissions
</h3>
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-end items-center">
<div className="flex space-x-3">
<button
onClick={() => refreshPermissions()}
className="inline-flex items-center px-4 py-2 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh Permissions
</button>
<button
onClick={() => setShowAddModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<Plus className="h-4 w-4 mr-2" />
Add Role
</button>
</div>
</div>
return (
<div className="space-y-6">
{/* Header */}
<div className="flex justify-end items-center">
<div className="flex space-x-3">
<button
type="button"
onClick={() => refreshPermissions()}
className="inline-flex items-center px-4 py-2 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh Permissions
</button>
<button
type="button"
onClick={() => setShowAddModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
>
<Plus className="h-4 w-4 mr-2" />
Add Role
</button>
</div>
</div>
{/* Roles List */}
<div className="space-y-4">
{roles && Array.isArray(roles) && roles.map((role) => (
<RolePermissionsCard
key={role.id}
role={role}
isEditing={editingRole === role.role}
onEdit={() => setEditingRole(role.role)}
onCancel={() => setEditingRole(null)}
onSave={handleSavePermissions}
onDelete={handleDeleteRole}
/>
))}
</div>
{/* Roles List */}
<div className="space-y-4">
{roles &&
Array.isArray(roles) &&
roles.map((role) => (
<RolePermissionsCard
key={role.id}
role={role}
isEditing={editingRole === role.role}
onEdit={() => setEditingRole(role.role)}
onCancel={() => setEditingRole(null)}
onSave={handleSavePermissions}
onDelete={handleDeleteRole}
/>
))}
</div>
{/* Add Role Modal */}
<AddRoleModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSuccess={() => {
queryClient.invalidateQueries(['rolePermissions'])
setShowAddModal(false)
}}
/>
</div>
)
}
{/* Add Role Modal */}
<AddRoleModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
onSuccess={() => {
queryClient.invalidateQueries(["rolePermissions"]);
setShowAddModal(false);
}}
/>
</div>
);
};
// Role Permissions Card Component
const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDelete }) => {
const [permissions, setPermissions] = useState(role)
const RolePermissionsCard = ({
role,
isEditing,
onEdit,
onCancel,
onSave,
onDelete,
}) => {
const [permissions, setPermissions] = useState(role);
const permissionFields = [
{ key: 'canViewDashboard', label: 'View Dashboard', icon: BarChart3, description: 'Access to the main dashboard' },
{ key: 'canViewHosts', label: 'View Hosts', icon: Server, description: 'See host information and status' },
{ key: 'canManageHosts', label: 'Manage Hosts', icon: Edit, description: 'Add, edit, and delete hosts' },
{ key: 'canViewPackages', label: 'View Packages', icon: Package, description: 'See package information' },
{ key: 'canManagePackages', label: 'Manage Packages', icon: Settings, description: 'Edit package details' },
{ key: 'canViewUsers', label: 'View Users', icon: Users, description: 'See user list and details' },
{ key: 'canManageUsers', label: 'Manage Users', icon: Shield, description: 'Add, edit, and delete users' },
{ key: 'canViewReports', label: 'View Reports', icon: BarChart3, description: 'Access to reports and analytics' },
{ key: 'canExportData', label: 'Export Data', icon: Download, description: 'Download data and reports' },
{ key: 'canManageSettings', label: 'Manage Settings', icon: Settings, description: 'System configuration access' }
]
// Sync permissions state with role prop when it changes
useEffect(() => {
setPermissions(role);
}, [role]);
const handlePermissionChange = (key, value) => {
setPermissions(prev => ({
...prev,
[key]: value
}))
}
const permissionFields = [
{
key: "can_view_dashboard",
label: "View Dashboard",
icon: BarChart3,
description: "Access to the main dashboard",
},
{
key: "can_view_hosts",
label: "View Hosts",
icon: Server,
description: "See host information and status",
},
{
key: "can_manage_hosts",
label: "Manage Hosts",
icon: Edit,
description: "Add, edit, and delete hosts",
},
{
key: "can_view_packages",
label: "View Packages",
icon: Package,
description: "See package information",
},
{
key: "can_manage_packages",
label: "Manage Packages",
icon: Settings,
description: "Edit package details",
},
{
key: "can_view_users",
label: "View Users",
icon: Users,
description: "See user list and details",
},
{
key: "can_manage_users",
label: "Manage Users",
icon: Shield,
description: "Add, edit, and delete users",
},
{
key: "can_view_reports",
label: "View Reports",
icon: BarChart3,
description: "Access to reports and analytics",
},
{
key: "can_export_data",
label: "Export Data",
icon: Download,
description: "Download data and reports",
},
{
key: "can_manage_settings",
label: "Manage Settings",
icon: Settings,
description: "System configuration access",
},
];
const handleSave = () => {
onSave(role.role, permissions)
}
const handlePermissionChange = (key, value) => {
setPermissions((prev) => ({
...prev,
[key]: value,
}));
};
const isAdminRole = role.role === 'admin'
const handleSave = () => {
onSave(role.role, permissions);
};
return (
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<div className="flex items-center">
<Shield className="h-5 w-5 text-primary-600 mr-3" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white capitalize">{role.role}</h3>
{isAdminRole && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
System Role
</span>
)}
</div>
<div className="flex items-center space-x-2">
{isEditing ? (
<>
<button
onClick={handleSave}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700"
>
<Save className="h-4 w-4 mr-1" />
Save
</button>
<button
onClick={onCancel}
className="inline-flex items-center px-3 py-1 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50"
>
<X className="h-4 w-4 mr-1" />
Cancel
</button>
</>
) : (
<>
<button
onClick={onEdit}
disabled={isAdminRole}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</button>
{!isAdminRole && (
<button
onClick={() => onDelete(role.role)}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</button>
)}
</>
)}
</div>
</div>
</div>
const isBuiltInRole = role.role === "admin" || role.role === "user";
<div className="px-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{permissionFields.map((field) => {
const Icon = field.icon
const isChecked = permissions[field.key]
return (
<div key={field.key} className="flex items-start">
<div className="flex items-center h-5">
<input
type="checkbox"
checked={isChecked}
onChange={(e) => handlePermissionChange(field.key, e.target.checked)}
disabled={!isEditing || (isAdminRole && field.key === 'canManageUsers')}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50"
/>
</div>
<div className="ml-3">
<div className="flex items-center">
<Icon className="h-4 w-4 text-secondary-400 mr-2" />
<label className="text-sm font-medium text-secondary-900 dark:text-white">
{field.label}
</label>
</div>
<p className="text-xs text-secondary-500 mt-1">
{field.description}
</p>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
return (
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<div className="flex items-center">
<Shield className="h-5 w-5 text-primary-600 mr-3" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white capitalize">
{role.role}
</h3>
{isBuiltInRole && (
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
Built-in Role
</span>
)}
</div>
<div className="flex items-center space-x-2">
{isEditing ? (
<>
<button
type="button"
onClick={handleSave}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700"
>
<Save className="h-4 w-4 mr-1" />
Save
</button>
<button
type="button"
onClick={onCancel}
className="inline-flex items-center px-3 py-1 border border-secondary-300 dark:border-secondary-600 text-sm font-medium rounded-md text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
<X className="h-4 w-4 mr-1" />
Cancel
</button>
</>
) : (
<>
<button
type="button"
onClick={onEdit}
disabled={isBuiltInRole}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</button>
{!isBuiltInRole && (
<button
type="button"
onClick={() => onDelete(role.role)}
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</button>
)}
</>
)}
</div>
</div>
</div>
<div className="px-6 py-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{permissionFields.map((field) => {
const Icon = field.icon;
const isChecked = permissions[field.key];
return (
<div key={field.key} className="flex items-start">
<div className="flex items-center h-5">
<input
id={`${role.role}-${field.key}`}
type="checkbox"
checked={isChecked}
onChange={(e) =>
handlePermissionChange(field.key, e.target.checked)
}
disabled={
!isEditing ||
(isBuiltInRole && field.key === "can_manage_users")
}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50"
/>
</div>
<div className="ml-3">
<div className="flex items-center">
<Icon className="h-4 w-4 text-secondary-400 mr-2" />
<label
htmlFor={`${role.role}-${field.key}`}
className="text-sm font-medium text-secondary-900 dark:text-white"
>
{field.label}
</label>
</div>
<p className="text-xs text-secondary-500 mt-1">
{field.description}
</p>
</div>
</div>
);
})}
</div>
</div>
</div>
);
};
// Add Role Modal Component
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
const [formData, setFormData] = useState({
role: '',
canViewDashboard: true,
canViewHosts: true,
canManageHosts: false,
canViewPackages: true,
canManagePackages: false,
canViewUsers: false,
canManageUsers: false,
canViewReports: true,
canExportData: false,
canManageSettings: false
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const roleNameInputId = useId();
const [formData, setFormData] = useState({
role: "",
can_view_dashboard: true,
can_view_hosts: true,
can_manage_hosts: false,
can_view_packages: true,
can_manage_packages: false,
can_view_users: false,
can_manage_users: false,
can_view_reports: true,
can_export_data: false,
can_manage_settings: false,
});
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
setError("");
try {
await permissionsAPI.updateRole(formData.role, formData)
onSuccess()
} catch (err) {
setError(err.response?.data?.error || 'Failed to create role')
} finally {
setIsLoading(false)
}
}
try {
await permissionsAPI.updateRole(formData.role, formData);
onSuccess();
} catch (err) {
setError(err.response?.data?.error || "Failed to create role");
} finally {
setIsLoading(false);
}
};
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target
setFormData({
...formData,
[name]: type === 'checkbox' ? checked : value
})
}
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData({
...formData,
[name]: type === "checkbox" ? checked : value,
});
};
if (!isOpen) return null
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 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-medium text-secondary-900 mb-4">Add New Role</h3>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
Role Name
</label>
<input
type="text"
name="role"
required
value={formData.role}
onChange={handleInputChange}
className="block w-full border-secondary-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
placeholder="e.g., host_manager, readonly"
/>
<p className="mt-1 text-xs text-secondary-500">Use lowercase with underscores (e.g., host_manager)</p>
</div>
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
Add New Role
</h3>
<div className="space-y-3">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white">Permissions</h4>
{[
{ key: 'canViewDashboard', label: 'View Dashboard' },
{ key: 'canViewHosts', label: 'View Hosts' },
{ key: 'canManageHosts', label: 'Manage Hosts' },
{ key: 'canViewPackages', label: 'View Packages' },
{ key: 'canManagePackages', label: 'Manage Packages' },
{ key: 'canViewUsers', label: 'View Users' },
{ key: 'canManageUsers', label: 'Manage Users' },
{ key: 'canViewReports', label: 'View Reports' },
{ key: 'canExportData', label: 'Export Data' },
{ key: 'canManageSettings', label: 'Manage Settings' }
].map((permission) => (
<div key={permission.key} className="flex items-center">
<input
type="checkbox"
name={permission.key}
checked={formData[permission.key]}
onChange={handleInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label className="ml-2 block text-sm text-secondary-700">
{permission.label}
</label>
</div>
))}
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={roleNameInputId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1"
>
Role Name
</label>
<input
id={roleNameInputId}
type="text"
name="role"
required
value={formData.role}
onChange={handleInputChange}
className="block w-full 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"
placeholder="e.g., host_manager, readonly"
/>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Use lowercase with underscores (e.g., host_manager)
</p>
</div>
{error && (
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
<p className="text-sm text-danger-700">{error}</p>
</div>
)}
<div className="space-y-3">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white">
Permissions
</h4>
{[
{ key: "can_view_dashboard", label: "View Dashboard" },
{ key: "can_view_hosts", label: "View Hosts" },
{ key: "can_manage_hosts", label: "Manage Hosts" },
{ key: "can_view_packages", label: "View Packages" },
{ key: "can_manage_packages", label: "Manage Packages" },
{ key: "can_view_users", label: "View Users" },
{ key: "can_manage_users", label: "Manage Users" },
{ key: "can_view_reports", label: "View Reports" },
{ key: "can_export_data", label: "Export Data" },
{ key: "can_manage_settings", label: "Manage Settings" },
].map((permission) => (
<div key={permission.key} className="flex items-center">
<input
id={`add-role-${permission.key}`}
type="checkbox"
name={permission.key}
checked={formData[permission.key]}
onChange={handleInputChange}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label
htmlFor={`add-role-${permission.key}`}
className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200"
>
{permission.label}
</label>
</div>
))}
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 bg-white border border-secondary-300 rounded-md hover:bg-secondary-50"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? 'Creating...' : 'Create Role'}
</button>
</div>
</form>
</div>
</div>
)
}
{error && (
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
<p className="text-sm text-danger-700 dark:text-danger-300">
{error}
</p>
</div>
)}
export default Permissions
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
>
Cancel
</button>
<button
type="submit"
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
>
{isLoading ? "Creating..." : "Create Role"}
</button>
</div>
</form>
</div>
</div>
);
};
export default Permissions;

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,369 +1,451 @@
import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
ArrowLeft,
Server,
Shield,
ShieldOff,
AlertTriangle,
Users,
Globe,
Lock,
Unlock,
Database,
Calendar,
Activity
} from 'lucide-react';
import { repositoryAPI } from '../utils/api';
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
ArrowLeft,
Calendar,
Database,
Globe,
Lock,
Server,
Shield,
ShieldOff,
Unlock,
Users,
} from "lucide-react";
import { useId, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { repositoryAPI } from "../utils/api";
const RepositoryDetail = () => {
const { repositoryId } = useParams();
const queryClient = useQueryClient();
const [editMode, setEditMode] = useState(false);
const [formData, setFormData] = useState({});
const isActiveId = useId();
const repositoryNameId = useId();
const priorityId = useId();
const descriptionId = useId();
const { repositoryId } = useParams();
const queryClient = useQueryClient();
const [editMode, setEditMode] = useState(false);
const [formData, setFormData] = useState({});
// Fetch repository details
const { data: repository, isLoading, error } = useQuery({
queryKey: ['repository', repositoryId],
queryFn: () => repositoryAPI.getById(repositoryId).then(res => res.data),
enabled: !!repositoryId
});
// Fetch repository details
const {
data: repository,
isLoading,
error,
} = useQuery({
queryKey: ["repository", repositoryId],
queryFn: () => repositoryAPI.getById(repositoryId).then((res) => res.data),
enabled: !!repositoryId,
});
// Update repository mutation
const updateRepositoryMutation = useMutation({
mutationFn: (data) => repositoryAPI.update(repositoryId, data),
onSuccess: () => {
queryClient.invalidateQueries(['repository', repositoryId]);
queryClient.invalidateQueries(['repositories']);
setEditMode(false);
}
});
// Update repository mutation
const updateRepositoryMutation = useMutation({
mutationFn: (data) => repositoryAPI.update(repositoryId, data),
onSuccess: () => {
queryClient.invalidateQueries(["repository", repositoryId]);
queryClient.invalidateQueries(["repositories"]);
setEditMode(false);
},
});
const handleEdit = () => {
setFormData({
name: repository.name,
description: repository.description || "",
is_active: repository.is_active,
priority: repository.priority || "",
});
setEditMode(true);
};
const handleEdit = () => {
setFormData({
name: repository.name,
description: repository.description || '',
isActive: repository.isActive,
priority: repository.priority || ''
});
setEditMode(true);
};
const handleSave = () => {
updateRepositoryMutation.mutate(formData);
};
const handleSave = () => {
updateRepositoryMutation.mutate(formData);
};
const handleCancel = () => {
setEditMode(false);
setFormData({});
};
const handleCancel = () => {
setEditMode(false);
setFormData({});
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link
to="/repositories"
className="btn-outline flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to Repositories
</Link>
</div>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center">
<AlertTriangle className="h-5 w-5 text-red-400 mr-2" />
<span className="text-red-700 dark:text-red-300">
Failed to load repository: {error.message}
</span>
</div>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
);
}
if (!repository) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link
to="/repositories"
className="btn-outline flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to Repositories
</Link>
</div>
<div className="text-center py-12">
<Database className="mx-auto h-12 w-12 text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
Repository not found
</h3>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
The repository you're looking for doesn't exist.
</p>
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link
to="/repositories"
className="btn-outline flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to Repositories
</Link>
</div>
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<div className="flex items-center">
<AlertTriangle className="h-5 w-5 text-red-400 mr-2" />
<span className="text-red-700 dark:text-red-300">
Failed to load repository: {error.message}
</span>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
to="/repositories"
className="btn-outline flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Link>
<div>
<div className="flex items-center gap-3">
{repository.isSecure ? (
<Lock className="h-6 w-6 text-green-600" />
) : (
<Unlock className="h-6 w-6 text-orange-600" />
)}
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
{repository.name}
</h1>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
repository.is_active
? "bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300"
: "bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300"
}`}
>
{repository.is_active ? "Active" : "Inactive"}
</span>
</div>
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
Repository configuration and host assignments
</p>
</div>
</div>
<div className="flex items-center gap-2">
{editMode ? (
<>
<button
type="button"
onClick={handleCancel}
className="btn-outline"
disabled={updateRepositoryMutation.isPending}
>
Cancel
</button>
<button
type="button"
onClick={handleSave}
className="btn-primary"
disabled={updateRepositoryMutation.isPending}
>
{updateRepositoryMutation.isPending
? "Saving..."
: "Save Changes"}
</button>
</>
) : (
<button type="button" onClick={handleEdit} className="btn-primary">
Edit Repository
</button>
)}
</div>
</div>
if (!repository) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Link
to="/repositories"
className="btn-outline flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back to Repositories
</Link>
</div>
<div className="text-center py-12">
<Database className="mx-auto h-12 w-12 text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">Repository not found</h3>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
The repository you're looking for doesn't exist.
</p>
</div>
</div>
);
}
{/* Repository Information */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Repository Information
</h2>
</div>
<div className="px-6 py-4 space-y-4">
{editMode ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label
htmlFor={repositoryNameId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1"
>
Repository Name
</label>
<input
type="text"
id={repositoryNameId}
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
/>
</div>
<div>
<label
htmlFor={priorityId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1"
>
Priority
</label>
<input
type="number"
id={priorityId}
value={formData.priority}
onChange={(e) =>
setFormData({ ...formData, priority: e.target.value })
}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
placeholder="Optional priority"
/>
</div>
<div className="md:col-span-2">
<label
htmlFor={descriptionId}
className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1"
>
Description
</label>
<textarea
id={descriptionId}
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
rows="3"
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
placeholder="Optional description"
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id={isActiveId}
checked={formData.is_active}
onChange={(e) =>
setFormData({ ...formData, is_active: e.target.checked })
}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label
htmlFor={isActiveId}
className="ml-2 block text-sm text-secondary-900 dark:text-white"
>
Repository is active
</label>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
URL
</span>
<div className="flex items-center mt-1">
<Globe className="h-4 w-4 text-secondary-400 mr-2" />
<span className="text-secondary-900 dark:text-white">
{repository.url}
</span>
</div>
</div>
<div>
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Distribution
</span>
<p className="text-secondary-900 dark:text-white mt-1">
{repository.distribution}
</p>
</div>
<div>
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Components
</span>
<p className="text-secondary-900 dark:text-white mt-1">
{repository.components}
</p>
</div>
<div>
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Repository Type
</span>
<p className="text-secondary-900 dark:text-white mt-1">
{repository.repoType}
</p>
</div>
</div>
<div className="space-y-4">
<div>
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Security
</span>
<div className="flex items-center mt-1">
{repository.isSecure ? (
<>
<Shield className="h-4 w-4 text-green-600 mr-2" />
<span className="text-green-600">Secure (HTTPS)</span>
</>
) : (
<>
<ShieldOff className="h-4 w-4 text-orange-600 mr-2" />
<span className="text-orange-600">Insecure (HTTP)</span>
</>
)}
</div>
</div>
{repository.priority && (
<div>
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Priority
</span>
<p className="text-secondary-900 dark:text-white mt-1">
{repository.priority}
</p>
</div>
)}
{repository.description && (
<div>
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Description
</span>
<p className="text-secondary-900 dark:text-white mt-1">
{repository.description}
</p>
</div>
)}
<div>
<span className="text-sm font-medium text-secondary-500 dark:text-secondary-400">
Created
</span>
<div className="flex items-center mt-1">
<Calendar className="h-4 w-4 text-secondary-400 mr-2" />
<span className="text-secondary-900 dark:text-white">
{new Date(repository.created_at).toLocaleDateString()}
</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
to="/repositories"
className="btn-outline flex items-center gap-2"
>
<ArrowLeft className="h-4 w-4" />
Back
</Link>
<div>
<div className="flex items-center gap-3">
{repository.isSecure ? (
<Lock className="h-6 w-6 text-green-600" />
) : (
<Unlock className="h-6 w-6 text-orange-600" />
)}
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
{repository.name}
</h1>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
repository.isActive
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
}`}>
{repository.isActive ? 'Active' : 'Inactive'}
</span>
</div>
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
Repository configuration and host assignments
</p>
</div>
</div>
<div className="flex items-center gap-2">
{editMode ? (
<>
<button
onClick={handleCancel}
className="btn-outline"
disabled={updateRepositoryMutation.isPending}
>
Cancel
</button>
<button
onClick={handleSave}
className="btn-primary"
disabled={updateRepositoryMutation.isPending}
>
{updateRepositoryMutation.isPending ? 'Saving...' : 'Save Changes'}
</button>
</>
) : (
<button
onClick={handleEdit}
className="btn-primary"
>
Edit Repository
</button>
)}
</div>
</div>
{/* Repository Information */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
Repository Information
</h2>
</div>
<div className="px-6 py-4 space-y-4">
{editMode ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Repository Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Priority
</label>
<input
type="number"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
placeholder="Optional priority"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows="3"
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
placeholder="Optional description"
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="isActive"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
/>
<label htmlFor="isActive" className="ml-2 block text-sm text-secondary-900 dark:text-white">
Repository is active
</label>
</div>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">URL</label>
<div className="flex items-center mt-1">
<Globe className="h-4 w-4 text-secondary-400 mr-2" />
<span className="text-secondary-900 dark:text-white">{repository.url}</span>
</div>
</div>
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Distribution</label>
<p className="text-secondary-900 dark:text-white mt-1">{repository.distribution}</p>
</div>
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Components</label>
<p className="text-secondary-900 dark:text-white mt-1">{repository.components}</p>
</div>
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Repository Type</label>
<p className="text-secondary-900 dark:text-white mt-1">{repository.repoType}</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Security</label>
<div className="flex items-center mt-1">
{repository.isSecure ? (
<>
<Shield className="h-4 w-4 text-green-600 mr-2" />
<span className="text-green-600">Secure (HTTPS)</span>
</>
) : (
<>
<ShieldOff className="h-4 w-4 text-orange-600 mr-2" />
<span className="text-orange-600">Insecure (HTTP)</span>
</>
)}
</div>
</div>
{repository.priority && (
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Priority</label>
<p className="text-secondary-900 dark:text-white mt-1">{repository.priority}</p>
</div>
)}
{repository.description && (
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Description</label>
<p className="text-secondary-900 dark:text-white mt-1">{repository.description}</p>
</div>
)}
<div>
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Created</label>
<div className="flex items-center mt-1">
<Calendar className="h-4 w-4 text-secondary-400 mr-2" />
<span className="text-secondary-900 dark:text-white">
{new Date(repository.createdAt).toLocaleDateString()}
</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
{/* Hosts Using This Repository */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white flex items-center gap-2">
<Users className="h-5 w-5" />
Hosts Using This Repository ({repository.hostRepositories?.length || 0})
</h2>
</div>
{!repository.hostRepositories || repository.hostRepositories.length === 0 ? (
<div className="px-6 py-12 text-center">
<Server className="mx-auto h-12 w-12 text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">No hosts using this repository</h3>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
This repository hasn't been reported by any hosts yet.
</p>
</div>
) : (
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
{repository.hostRepositories.map((hostRepo) => (
<div key={hostRepo.id} className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-3 h-3 rounded-full ${
hostRepo.host.status === 'active'
? 'bg-green-500'
: hostRepo.host.status === 'pending'
? 'bg-yellow-500'
: 'bg-red-500'
}`} />
<div>
<Link
to={`/hosts/${hostRepo.host.id}`}
className="text-primary-600 hover:text-primary-700 font-medium"
>
{hostRepo.host.hostname}
</Link>
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
<span>IP: {hostRepo.host.ip}</span>
<span>OS: {hostRepo.host.osType} {hostRepo.host.osVersion}</span>
<span>Last Update: {new Date(hostRepo.host.lastUpdate).toLocaleDateString()}</span>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-xs text-secondary-500 dark:text-secondary-400">Last Checked</div>
<div className="text-sm text-secondary-900 dark:text-white">
{new Date(hostRepo.lastChecked).toLocaleDateString()}
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
{/* Hosts Using This Repository */}
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white flex items-center gap-2">
<Users className="h-5 w-5" />
Hosts Using This Repository (
{repository.host_repositories?.length || 0})
</h2>
</div>
{!repository.host_repositories ||
repository.host_repositories.length === 0 ? (
<div className="px-6 py-12 text-center">
<Server className="mx-auto h-12 w-12 text-secondary-400" />
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">
No hosts using this repository
</h3>
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
This repository hasn't been reported by any hosts yet.
</p>
</div>
) : (
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
{repository.host_repositories.map((hostRepo) => (
<div
key={hostRepo.id}
className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className={`w-3 h-3 rounded-full ${
hostRepo.hosts.status === "active"
? "bg-green-500"
: hostRepo.hosts.status === "pending"
? "bg-yellow-500"
: "bg-red-500"
}`}
/>
<div>
<Link
to={`/hosts/${hostRepo.hosts.id}`}
className="text-primary-600 hover:text-primary-700 font-medium"
>
{hostRepo.hosts.friendly_name}
</Link>
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
<span>IP: {hostRepo.hosts.ip}</span>
<span>
OS: {hostRepo.hosts.os_type}{" "}
{hostRepo.hosts.os_version}
</span>
<span>
Last Update:{" "}
{new Date(
hostRepo.hosts.last_update,
).toLocaleDateString()}
</span>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-xs text-secondary-500 dark:text-secondary-400">
Last Checked
</div>
<div className="text-sm text-secondary-900 dark:text-white">
{new Date(hostRepo.last_checked).toLocaleDateString()}
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
};
export default RepositoryDetail;

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