Compare commits

..

104 Commits

Author SHA1 Message Date
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
Muhammad Ibrahim
5bdd0b5830 upgraded version 2025-09-18 02:09:42 +01:00
Muhammad Ibrahim
98cadb1ff1 added the right version of patchmon 2025-09-18 01:50:15 +01:00
Muhammad Ibrahim
42a6b7e19c added ability to save the custom ssh id path 2025-09-18 01:31:07 +01:00
Muhammad Ibrahim
e35f96d30f added deploy key custom ssh path 2025-09-18 01:19:43 +01:00
Muhammad Ibrahim
08f82bc795 added ability to specify deploy key path 2025-09-18 01:02:51 +01:00
Muhammad Ibrahim
c497c1db2a chore: Remove manage-patchmon.sh from Git tracking
- Remove manage-patchmon.sh from Git tracking as it's in .gitignore
- File should only exist locally and be hosted separately
- This prevents accidental commits of the management script
2025-09-17 23:44:48 +01:00
Muhammad Ibrahim
5b7e7216e8 fix: Improve Prisma migration execution with multiple fallback methods
- Add multiple fallback methods to run Prisma migrations
- Try npx first, then direct binary paths with chmod +x
- Add fallback to install Prisma if not found
- Apply same fixes to both update_instance and small management script
- Resolves 'Permission denied' error when running prisma migrate deploy

This ensures migrations work even when npx has permission issues
or when Prisma binaries need executable permissions.
2025-09-17 23:43:23 +01:00
Muhammad Ibrahim
fe5fb92e48 fix: Add git safe.directory configuration for update commands
- Add 'git config --global --add safe.directory' before git pull in update_instance
- Add same fix to small management script update command
- Resolves 'dubious ownership' error when updating repositories
  that were cloned as root but accessed by different users

This fixes the git ownership security warning that prevents
git pull from working during updates.
2025-09-17 23:41:54 +01:00
Muhammad Ibrahim
c8d54facb9 fix: Use .env file for database credentials in update commands
- Update update_instance function to read database credentials from .env file
- Fix database backup to use credentials from .env instead of hardcoded values
- Update migration commands to load environment variables from .env
- Fix small management script update command to use .env credentials

This resolves the 'password authentication failed' error when running
update commands on existing instances where database credentials
are stored in the .env file rather than hardcoded.
2025-09-17 23:39:00 +01:00
Muhammad Ibrahim
f97b300158 fix: Add database migrations to update command
- Add 'npx prisma migrate deploy' to the update command in manage.sh
- Ensures database schema is updated when running update command
- Fixes issue where new features (like version checking) wouldn't work
  after updating without running migrations

The update_instance function already had migrations, but the
manage.sh update command was missing this crucial step.
2025-09-17 22:21:35 +01:00
Muhammad Ibrahim
17ffa48158 fix: Fix SSH command escaping for version checking
- Fix sed command escaping in git ls-remote command
- Add explicit SSH key path and GIT_SSH_COMMAND environment
- Add debug logging for troubleshooting SSH issues
- Ensure proper SSH authentication for private repositories

This resolves the 'Failed to fetch repository information' error
when checking for updates from private GitHub repositories.
2025-09-17 22:19:12 +01:00
Muhammad Ibrahim
16821d6b5e feat: Implement SSH-based version checking for private repositories
- Replace HTTPS GitHub API calls with SSH git commands
- Use existing deploy key for private repository access
- Add proper error handling for SSH authentication issues
- Support fetching latest tags via git ls-remote
- Maintain compatibility with private repositories using deploy keys

This allows the version checking system to work with private repositories
that have SSH deploy keys configured, using the same authentication
as the local git operations.
2025-09-17 22:16:52 +01:00
76 changed files with 12609 additions and 2324 deletions

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

@@ -0,0 +1,63 @@
name: Build and Push Docker Images
on:
pull_request:
branches:
- main
- dev
release:
types:
- published
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
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
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 }}

3
.gitignore vendored
View File

@@ -140,6 +140,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/agents

366
README.md Normal file
View File

@@ -0,0 +1,366 @@
## 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.
## Features
### Users & Authentication
- Multi-user accounts (admin and standard users)
- Email/username-based login
- Optional TwoFactor Authentication (TFA/MFA) with verification flow
- Firsttime admin bootstrap flow (no default credentials; secure setup)
- Selfregistration toggle in settings (enable/disable public signup)
### Roles, Permissions & RBAC
- Builtin roles: `admin`, `user`
- Finegrained permission flags (e.g., view/manage hosts, packages, users, reports, settings)
- Serverside enforcement for protected routes and UI guards per permission
### Dashboard
- Customisable dashboard with peruser card layout and ordering
- Role/permissionaware defaults on first login
- “Reset to Defaults” uses consistent serverprovided defaults
- Cards include: Total Hosts, Needs Updating, UptoDate Hosts, Host Groups, Outdated Packages, Security Updates, Package Priority, Repositories, Users, OS Distribution (pie/bar), Update Status, Recent Collection, Recent Users, Quick Stats
### Hosts & Inventory
- Host inventory with key attributes and OS details
- Host grouping (create and manage host groups)
- OS distribution summaries and visualisations
- Recent telemetry collection indicator
### Packages & Updates
- Package inventory across hosts
- Outdated packages overview and counts
- Security updates highlight
- Update status breakdown (uptodate vs needs updates)
### Repositories
- Repositories per host tracking
- Repository module pages and totals
### Agent & Data Collection
- Outboundonly agent communication (no inbound ports required on hosts)
- Agent version management and script content stored in DB
- Version marking (current/default) with update history
### Settings & Configuration
- Server URL/protocol/host/port
- Update interval and autoupdate toggle
- Public signup toggle and default user role selection
- Repository settings: GitHub repo URL, repository type, SSH key path
- Ratelimit windows and thresholds for API/auth/agent
### Admin & User Management
- Admin user CRUD (create, list, update, delete)
- Password reset (admininitiated)
- Role assignment on user create/update
### Reporting & Analytics
- Dashboard stats and cardlevel metrics
- OS distribution charts (pie/bar)
- Update status and recent activity summaries
### API & Integrations
- REST API under `/api/v1` with JWT auth
- Consistent JSON responses; errors with appropriate status codes
- CORS configured per server settings
### Security
- JWTsecured API with short, scoped tokens
- Permissions enforced serverside on every route
- Rate limiting for general, auth, and agent endpoints
- Outboundonly agent model reduces attack surface
### Deployment & Operations
- Oneline selfhost installer (Ubuntu/Debian)
- Automated provisioning: Node.js, PostgreSQL, nginx
- Prisma migrations and client generation
- systemd service for backend lifecycle
- nginx vhost for frontend + API proxy; optional Lets Encrypt integration
- Consolidated deployment info file with commands and paths
### UX & Frontend
- Vite + React singlepage app
- Protected routes with permission checks
- Theming and modern components (icons, modals, notifications)
### Observability & Logging
- Structured server logs
- Deployment logs copied to instance dir for later review
### RoadReadiness
- Works for internal (HTTP) and public (HTTPS) deployments
- Defaults safe for firsttime setup; admin created interactively
## 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
```
## Getting Started
### PatchMon Cloud (coming soon)
Managed, zero-maintenance PatchMon hosting. Stay tuned.
### Self-hosted Installation
Run on a clean Ubuntu/Debian server with internet access:
```bash
curl -fsSL -o setup.sh https://raw.githubusercontent.com/9technologygroup/patchmon.net/main/setup.sh && chmod +x && bash setup.sh
```
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`
## Support
- Discord: https://discord.gg/S7RXUHwg
- Email: support@patchmon.net
## Roadmap
- PatchMon Cloud (managed offering)
- Additional dashboards and reporting widgets
- More OS distributions and agent enhancements
- Advanced workflow automations and approvals
Roadmap board: https://github.com/users/9technologygroup/projects/1
## Security
- Outbound-only agent communications; no inbound ports on monitored hosts
- JWT-based API auth, rate limiting, role/permission checks
- Follow least-privilege defaults; sensitive operations audited
## Support Methods
- Community: Discord for quick questions and feedback
- Email: SLA-backed assistance for incidents and issues
- GitHub Issues: bug reports and feature requests
## License
AGPLv3 (More information on this soon)
## Links
- Repository: https://github.com/9technologygroup/patchmon.net/
- Raw installer: https://raw.githubusercontent.com/9technologygroup/patchmon.net/main/setup.sh
---
# PatchMon
[![Discord](https://img.shields.io/badge/Discord-Join%20Server-blue?style=for-the-badge&logo=discord)](https://discord.gg/S7RXUHwg)
[![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)
---
## 🤝 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. **Make Your Changes**
- Write clean, well-documented code
- Follow existing code style and patterns
- Add tests for new functionality
- Update documentation as needed
5. **Test Your Changes**
```bash
# Run backend tests
cd backend
npm test
# Run frontend tests
cd ../frontend
npm test
```
6. **Commit and Push**
```bash
git add .
git commit -m "Add: descriptive commit message"
git push origin feature/your-feature-name
```
7. **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 ESLint 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
### Areas We Need Help With
- 🐳 **Docker & Containerization** (led by @Adam20054)
- 🔄 **CI/CD Pipelines** (led by @tigattack)
- 🔒 **Security Improvements** - Security audits, vulnerability assessments, and security feature enhancements
-**Performance for Large Scale Deployments** - Database optimization, caching strategies, and horizontal scaling
- 📚 **Documentation** improvements
- 🧪 **Testing** coverage
- 🌐 **Internationalization** (i18n)
- 📱 **Mobile** responsive improvements
-
---
## 🗺️ Roadmap
Check out our [public roadmap](https://github.com/users/9technologygroup/projects/1) to see what we're working on and what's coming next!
**Upcoming Features:**
- 🐳 Docker Compose deployment
- 🔄 Automated CI/CD pipelines
- 📊 Advanced reporting and analytics
- 🔔 Enhanced notification system
- 📱 Mobile application
- 🔄 Patch management workflows and policies
- 👥 Users inventory management
- 🔍 Services and ports monitoring
- 🖥️ Proxmox integration for auto LXC discovery and registration
- 📧 Notifications via Slack/Email
---
## 🏢 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*
---
---
## 📞 Support & Community
### Get Help
- 💬 **Discord Community**: [Join our Discord](https://discord.gg/S7RXUHwg) for real-time support and discussions
- 📧 **Email Support**: support@patchmon.net
- 📚 **Documentation**: Check our wiki and documentation
- 🐛 **Bug Reports**: Use GitHub Issues
### Community
- 🌟 **Star the Project**: Show your support by starring this repository
- 🍴 **Fork & Contribute**: Help improve PatchMon
- 📢 **Share**: Tell others about PatchMon
- 💡 **Feature Requests**: Suggest new features via GitHub Issues
---
## 🙏 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
### Contributors
Thank you to all our contributors who help make PatchMon better every day!
## 🔗 Links
- **Website**: [patchmon.net](https://patchmon.net)
- **Discord**: [discord.gg/S7RXUHwg](https://discord.gg/S7RXUHwg)
- **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://discord.gg/S7RXUHwg)
[![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,12 +1,12 @@
#!/bin/bash
# PatchMon Agent Script
# PatchMon Agent Script v1.2.6
# 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.3"
AGENT_VERSION="1.2.6"
CONFIG_FILE="/etc/patchmon/agent.conf"
CREDENTIALS_FILE="/etc/patchmon/credentials"
LOG_FILE="/var/log/patchmon-agent.log"
@@ -656,6 +656,114 @@ 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
ram_installed=$(free -g | grep "^Mem:" | awk '{print $2}')
swap_size=$(free -g | grep "^Swap:" | awk '{print $2}')
elif [[ -f /proc/meminfo ]]; then
ram_installed=$(grep "MemTotal" /proc/meminfo | awk '{print int($2/1024/1024)}')
swap_size=$(grep "SwapTotal" /proc/meminfo | awk '{print int($2/1024/1024)}')
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:]')
elif [[ -f /etc/selinux/config ]]; then
selinux_status=$(grep "^SELINUX=" /etc/selinux/config | cut -d'=' -f2 | tr '[:upper:]' '[:lower:]')
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
@@ -666,14 +774,27 @@ send_update() {
info "Collecting repository information..."
local repositories_json=$(get_repository_info)
info "Collecting hardware information..."
local hardware_json=$(get_hardware_info)
info "Collecting network information..."
local network_json=$(get_network_info)
info "Collecting system information..."
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,6 +802,10 @@ send_update() {
EOF
)
# Merge the base payload with the system information
local payload=$(echo "$base_payload $merged_json" | jq -s '.[0] * .[1]')
local response=$(curl -s -X POST \
-H "Content-Type: application/json" \
-H "X-API-ID: $API_ID" \
@@ -888,8 +1013,9 @@ update_crontab() {
# 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"
# Hourly updates starting at current minute
local current_minute=$(date +%M)
expected_crontab="$current_minute * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1"
else
# Custom interval updates
expected_crontab="*/$update_interval * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1"
@@ -1094,4 +1220,4 @@ main() {
}
# Run main function
main "$@"
main "$@"

View File

@@ -35,6 +35,40 @@ if [[ $EUID -ne 0 ]]; then
error "This script must be run as root (use sudo)"
fi
# Install required dependencies
info "📦 Installing required dependencies..."
# Detect package manager and install jq
if command -v apt-get >/dev/null 2>&1; then
# Debian/Ubuntu
apt-get update >/dev/null 2>&1
apt-get install -y jq curl >/dev/null 2>&1
elif command -v yum >/dev/null 2>&1; then
# CentOS/RHEL 7
yum install -y jq curl >/dev/null 2>&1
elif command -v dnf >/dev/null 2>&1; then
# CentOS/RHEL 8+/Fedora
dnf install -y jq curl >/dev/null 2>&1
elif command -v zypper >/dev/null 2>&1; then
# openSUSE
zypper install -y jq curl >/dev/null 2>&1
elif command -v pacman >/dev/null 2>&1; then
# Arch Linux
pacman -S --noconfirm jq curl >/dev/null 2>&1
elif command -v apk >/dev/null 2>&1; then
# Alpine Linux
apk add --no-cache jq curl >/dev/null 2>&1
else
warning "Could not detect package manager. Please ensure 'jq' and 'curl' are installed manually."
fi
# Verify jq installation
if ! command -v jq >/dev/null 2>&1; then
error "Failed to install 'jq'. Please install it manually: https://stedolan.github.io/jq/download/"
fi
success "Dependencies installed successfully!"
# Default server URL (will be replaced by backend with configured URL)
PATCHMON_URL="http://localhost:3001"
@@ -137,6 +171,7 @@ fi
success "🎉 PatchMon Agent installation complete!"
echo ""
echo "📋 Installation Summary:"
echo " • Dependencies installed: jq, curl"
echo " • Agent installed: /usr/local/bin/patchmon-agent.sh"
echo " • Agent version: $AGENT_VERSION"
if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then

View File

@@ -9,9 +9,17 @@ 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

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon-backend",
"version": "1.0.0",
"version": "1.2.6",
"description": "Backend API for Linux Patch Monitoring System",
"main": "src/server.js",
"scripts": {
@@ -23,6 +23,8 @@
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"qrcode": "^1.5.4",
"speakeasy": "^2.0.0",
"uuid": "^9.0.1",
"winston": "^3.11.0"
},

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "settings" ADD COLUMN "ssh_key_path" TEXT;

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

@@ -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,209 +7,195 @@ 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 agent_versions {
id String @id
version String @unique
is_current Boolean @default(false)
release_notes String?
download_url String?
min_server_version String?
created_at DateTime @default(now())
updated_at DateTime
is_default Boolean @default(false)
script_content String?
}
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 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 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_groups {
id String @id
name String @unique
description String?
color String? @default("#3B82F6")
created_at DateTime @default(now())
updated_at DateTime
hosts hosts[]
}
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_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 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 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 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 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?
host_packages host_packages[]
host_repositories host_repositories[]
host_groups host_groups? @relation(fields: [host_group_id], references: [id])
update_history update_history[]
}
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 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 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[]
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[]
@@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
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")
}
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
first_name String?
last_name 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?
dashboard_preferences dashboard_preferences[]
}

View File

@@ -0,0 +1,108 @@
/**
* 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.NODE_ENV === 'development'
? ['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) || 30;
const waitInterval = options.waitInterval || parseInt(process.env.PM_DB_CONN_WAIT_INTERVAL) || 2;
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) {
console.log(`Database connected successfully after ${attempt} attempt(s)`);
return true;
}
} catch (error) {
// checkDatabaseConnection already logs the error
}
if (attempt < maxAttempts) {
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

@@ -17,26 +17,31 @@ const authenticateToken = async (req, res, next) => {
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
// Get user from database
const user = await prisma.user.findUnique({
const user = await prisma.users.findUnique({
where: { id: decoded.userId },
select: {
id: true,
username: true,
email: true,
role: true,
isActive: true,
lastLogin: true
is_active: true,
last_login: true,
created_at: true,
updated_at: true
}
});
if (!user || !user.isActive) {
if (!user || !user.is_active) {
return res.status(401).json({ error: 'Invalid or inactive user' });
}
// Update last login
await prisma.user.update({
await prisma.users.update({
where: { id: user.id },
data: { lastLogin: new Date() }
data: {
last_login: new Date(),
updated_at: new Date()
}
});
req.user = user;
@@ -69,18 +74,21 @@ const optionalAuth = async (req, res, next) => {
if (token) {
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
const user = await prisma.user.findUnique({
const user = await prisma.users.findUnique({
where: { id: decoded.userId },
select: {
id: true,
username: true,
email: true,
role: true,
isActive: true
is_active: true,
last_login: true,
created_at: true,
updated_at: true
}
});
if (user && user.isActive) {
if (user && user.is_active) {
req.user = user;
}
}

View File

@@ -6,7 +6,7 @@ const requirePermission = (permission) => {
return async (req, res, next) => {
try {
// Get user's role permissions
const rolePermissions = await prisma.rolePermissions.findUnique({
const rolePermissions = await prisma.role_permissions.findUnique({
where: { role: req.user.role }
});
@@ -20,7 +20,7 @@ const requirePermission = (permission) => {
if (!rolePermissions[permission]) {
return res.status(403).json({
error: 'Insufficient permissions',
message: `You don't have permission to ${permission.replace('can', '').toLowerCase()}`
message: `You don't have permission to ${permission.replace('can_', '').replace('_', ' ')}`
});
}
@@ -32,17 +32,17 @@ const requirePermission = (permission) => {
};
};
// 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,

View File

@@ -5,10 +5,120 @@ const { PrismaClient } = require('@prisma/client');
const { body, validationResult } = require('express-validator');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const { requireViewUsers, requireManageUsers } = require('../middleware/permissions');
const { v4: uuidv4 } = require('uuid');
const { createDefaultDashboardPreferences } = require('./dashboardPreferencesRoutes');
const router = express.Router();
const prisma = new PrismaClient();
// Check if any admin users exist (for first-time setup)
router.get('/check-admin-users', async (req, res) => {
try {
const adminCount = await prisma.users.count({
where: { role: 'admin' }
});
res.json({
hasAdminUsers: adminCount > 0,
adminCount: adminCount
});
} catch (error) {
console.error('Error checking admin users:', error);
res.status(500).json({
error: 'Failed to check admin users',
hasAdminUsers: true // Assume admin exists for security
});
}
});
// Create first admin user (for first-time setup)
router.post('/setup-admin', [
body('firstName').isLength({ min: 1 }).withMessage('First name is required'),
body('lastName').isLength({ min: 1 }).withMessage('Last name is required'),
body('username').isLength({ min: 1 }).withMessage('Username is required'),
body('email').isEmail().withMessage('Valid email is required'),
body('password').isLength({ min: 8 }).withMessage('Password must be at least 8 characters for security')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
error: 'Validation failed',
details: errors.array()
});
}
const { firstName, lastName, username, email, password } = req.body;
// Check if any admin users already exist
const adminCount = await prisma.users.count({
where: { role: 'admin' }
});
if (adminCount > 0) {
return res.status(400).json({
error: 'Admin users already exist. This endpoint is only for first-time setup.'
});
}
// Check if username or email already exists
const existingUser = await prisma.users.findFirst({
where: {
OR: [
{ username: username.trim() },
{ email: email.trim() }
]
}
});
if (existingUser) {
return res.status(400).json({
error: 'Username or email already exists'
});
}
// Hash password
const passwordHash = await bcrypt.hash(password, 12);
// Create admin user
const user = await prisma.users.create({
data: {
id: uuidv4(),
username: username.trim(),
email: email.trim(),
password_hash: passwordHash,
first_name: firstName.trim(),
last_name: lastName.trim(),
role: 'admin',
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
select: {
id: true,
username: true,
email: true,
role: true,
created_at: true
}
});
// Create default dashboard preferences for the new admin user
await createDefaultDashboardPreferences(user.id, 'admin');
res.status(201).json({
message: 'Admin user created successfully',
user: user
});
} catch (error) {
console.error('Error creating admin user:', error);
res.status(500).json({
error: 'Failed to create admin user'
});
}
});
// Generate JWT token
const generateToken = (userId) => {
return jwt.sign(
@@ -21,19 +131,19 @@ const generateToken = (userId) => {
// Admin endpoint to list all users
router.get('/admin/users', authenticateToken, requireViewUsers, async (req, res) => {
try {
const users = await prisma.user.findMany({
const users = await prisma.users.findMany({
select: {
id: true,
username: true,
email: true,
role: true,
isActive: true,
lastLogin: true,
createdAt: true,
updatedAt: true
is_active: true,
last_login: true,
created_at: true,
updated_at: true
},
orderBy: {
createdAt: 'desc'
created_at: 'desc'
}
})
@@ -49,9 +159,14 @@ router.post('/admin/users', authenticateToken, requireManageUsers, [
body('username').isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
body('email').isEmail().withMessage('Valid email is required'),
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'),
body('first_name').optional().isLength({ min: 1 }).withMessage('First name must be at least 1 character'),
body('last_name').optional().isLength({ min: 1 }).withMessage('Last name must be at least 1 character'),
body('role').optional().custom(async (value) => {
if (!value) return true; // Optional field
const rolePermissions = await prisma.rolePermissions.findUnique({
// Allow built-in roles even if not in role_permissions table yet
const builtInRoles = ['admin', 'user'];
if (builtInRoles.includes(value)) return true;
const rolePermissions = await prisma.role_permissions.findUnique({
where: { role: value }
});
if (!rolePermissions) {
@@ -66,10 +181,17 @@ router.post('/admin/users', authenticateToken, requireManageUsers, [
return res.status(400).json({ errors: errors.array() });
}
const { username, email, password, role = 'user' } = req.body;
const { username, email, password, first_name, last_name, role } = req.body;
// Get default user role from settings if no role specified
let userRole = role;
if (!userRole) {
const settings = await prisma.settings.findFirst();
userRole = settings?.default_user_role || 'user';
}
// Check if user already exists
const existingUser = await prisma.user.findFirst({
const existingUser = await prisma.users.findFirst({
where: {
OR: [
{ username },
@@ -86,23 +208,32 @@ router.post('/admin/users', authenticateToken, requireManageUsers, [
const passwordHash = await bcrypt.hash(password, 12);
// Create user
const user = await prisma.user.create({
const user = await prisma.users.create({
data: {
id: uuidv4(),
username,
email,
passwordHash,
role
password_hash: passwordHash,
first_name: first_name || null,
last_name: last_name || null,
role: userRole,
updated_at: new Date()
},
select: {
id: true,
username: true,
email: true,
first_name: true,
last_name: true,
role: true,
isActive: true,
createdAt: true
is_active: true,
created_at: true
}
});
// Create default dashboard preferences for the new user
await createDefaultDashboardPreferences(user.id, userRole);
res.status(201).json({
message: 'User created successfully',
user
@@ -119,7 +250,7 @@ router.put('/admin/users/:userId', authenticateToken, requireManageUsers, [
body('email').optional().isEmail().withMessage('Valid email is required'),
body('role').optional().custom(async (value) => {
if (!value) return true; // Optional field
const rolePermissions = await prisma.rolePermissions.findUnique({
const rolePermissions = await prisma.role_permissions.findUnique({
where: { role: value }
});
if (!rolePermissions) {
@@ -143,10 +274,10 @@ router.put('/admin/users/:userId', authenticateToken, requireManageUsers, [
if (username) updateData.username = username;
if (email) updateData.email = email;
if (role) updateData.role = role;
if (typeof isActive === 'boolean') updateData.isActive = isActive;
if (typeof isActive === 'boolean') updateData.is_active = isActive;
// Check if user exists
const existingUser = await prisma.user.findUnique({
const existingUser = await prisma.users.findUnique({
where: { id: userId }
});
@@ -156,7 +287,7 @@ router.put('/admin/users/:userId', authenticateToken, requireManageUsers, [
// Check if username/email already exists (excluding current user)
if (username || email) {
const duplicateUser = await prisma.user.findFirst({
const duplicateUser = await prisma.users.findFirst({
where: {
AND: [
{ id: { not: userId } },
@@ -177,10 +308,10 @@ router.put('/admin/users/:userId', authenticateToken, requireManageUsers, [
// Prevent deactivating the last admin
if (isActive === false && existingUser.role === 'admin') {
const adminCount = await prisma.user.count({
const adminCount = await prisma.users.count({
where: {
role: 'admin',
isActive: true
is_active: true
}
});
@@ -190,7 +321,7 @@ router.put('/admin/users/:userId', authenticateToken, requireManageUsers, [
}
// Update user
const updatedUser = await prisma.user.update({
const updatedUser = await prisma.users.update({
where: { id: userId },
data: updateData,
select: {
@@ -198,10 +329,10 @@ router.put('/admin/users/:userId', authenticateToken, requireManageUsers, [
username: true,
email: true,
role: true,
isActive: true,
lastLogin: true,
createdAt: true,
updatedAt: true
is_active: true,
last_login: true,
created_at: true,
updated_at: true
}
});
@@ -226,7 +357,7 @@ router.delete('/admin/users/:userId', authenticateToken, requireManageUsers, asy
}
// Check if user exists
const user = await prisma.user.findUnique({
const user = await prisma.users.findUnique({
where: { id: userId }
});
@@ -236,10 +367,10 @@ router.delete('/admin/users/:userId', authenticateToken, requireManageUsers, asy
// Prevent deleting the last admin
if (user.role === 'admin') {
const adminCount = await prisma.user.count({
const adminCount = await prisma.users.count({
where: {
role: 'admin',
isActive: true
is_active: true
}
});
@@ -249,7 +380,7 @@ router.delete('/admin/users/:userId', authenticateToken, requireManageUsers, asy
}
// Delete user
await prisma.user.delete({
await prisma.users.delete({
where: { id: userId }
});
@@ -277,14 +408,14 @@ router.post('/admin/users/:userId/reset-password', authenticateToken, requireMan
const { newPassword } = req.body;
// Check if user exists
const user = await prisma.user.findUnique({
const user = await prisma.users.findUnique({
where: { id: userId },
select: {
id: true,
username: true,
email: true,
role: true,
isActive: true
is_active: true
}
});
@@ -293,7 +424,7 @@ router.post('/admin/users/:userId/reset-password', authenticateToken, requireMan
}
// Prevent resetting password of inactive users
if (!user.isActive) {
if (!user.is_active) {
return res.status(400).json({ error: 'Cannot reset password for inactive user' });
}
@@ -301,9 +432,9 @@ router.post('/admin/users/:userId/reset-password', authenticateToken, requireMan
const passwordHash = await bcrypt.hash(newPassword, 12);
// Update user password
await prisma.user.update({
await prisma.users.update({
where: { id: userId },
data: { passwordHash }
data: { password_hash: passwordHash }
});
// Log the password reset action (you might want to add an audit log table)
@@ -323,6 +454,107 @@ router.post('/admin/users/:userId/reset-password', authenticateToken, requireMan
}
});
// Check if signup is enabled (public endpoint)
router.get('/signup-enabled', async (req, res) => {
try {
const settings = await prisma.settings.findFirst();
res.json({ signupEnabled: settings?.signup_enabled || false });
} catch (error) {
console.error('Error checking signup status:', error);
res.status(500).json({ error: 'Failed to check signup status' });
}
});
// Public signup endpoint
router.post('/signup', [
body('firstName').isLength({ min: 1 }).withMessage('First name is required'),
body('lastName').isLength({ min: 1 }).withMessage('Last name is required'),
body('username').isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
body('email').isEmail().withMessage('Valid email is required'),
body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters')
], async (req, res) => {
try {
// Check if signup is enabled
const settings = await prisma.settings.findFirst();
if (!settings?.signup_enabled) {
return res.status(403).json({ error: 'User signup is currently disabled' });
}
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { firstName, lastName, username, email, password } = req.body;
// Check if user already exists
const existingUser = await prisma.users.findFirst({
where: {
OR: [
{ username },
{ email }
]
}
});
if (existingUser) {
return res.status(409).json({ error: 'Username or email already exists' });
}
// Hash password
const passwordHash = await bcrypt.hash(password, 12);
// Get default user role from settings or environment variable
const defaultRole = settings?.default_user_role || process.env.DEFAULT_USER_ROLE || 'user';
// Create user with default role from settings
const user = await prisma.users.create({
data: {
id: uuidv4(),
username,
email,
password_hash: passwordHash,
first_name: firstName.trim(),
last_name: lastName.trim(),
role: defaultRole,
updated_at: new Date()
},
select: {
id: true,
username: true,
email: true,
role: true,
is_active: true,
created_at: true
}
});
// Create default dashboard preferences for the new user
await createDefaultDashboardPreferences(user.id, defaultRole);
console.log(`New user registered: ${user.username} (${user.email})`);
// Generate token for immediate login
const token = generateToken(user.id);
res.status(201).json({
message: 'Account created successfully',
token,
user: {
id: user.id,
username: user.username,
email: user.email,
role: user.role
}
});
} catch (error) {
console.error('Signup error:', error);
console.error('Signup error message:', error.message);
console.error('Signup error stack:', error.stack);
res.status(500).json({ error: 'Failed to create account' });
}
});
// Login
router.post('/login', [
body('username').notEmpty().withMessage('Username is required'),
@@ -337,13 +569,27 @@ router.post('/login', [
const { username, password } = req.body;
// Find user by username or email
const user = await prisma.user.findFirst({
const user = await prisma.users.findFirst({
where: {
OR: [
{ username },
{ email: username }
],
isActive: true
is_active: true
},
select: {
id: true,
username: true,
email: true,
first_name: true,
last_name: true,
password_hash: true,
role: true,
is_active: true,
last_login: true,
created_at: true,
updated_at: true,
tfa_enabled: true
}
});
@@ -352,15 +598,27 @@ router.post('/login', [
}
// Verify password
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
const isValidPassword = await bcrypt.compare(password, user.password_hash);
if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Check if TFA is enabled
if (user.tfa_enabled) {
return res.status(200).json({
message: 'TFA verification required',
requiresTfa: true,
username: user.username
});
}
// Update last login
await prisma.user.update({
await prisma.users.update({
where: { id: user.id },
data: { lastLogin: new Date() }
data: {
last_login: new Date(),
updated_at: new Date()
}
});
// Generate token
@@ -373,7 +631,11 @@ router.post('/login', [
id: user.id,
username: user.username,
email: user.email,
role: user.role
role: user.role,
is_active: user.is_active,
last_login: user.last_login,
created_at: user.created_at,
updated_at: user.updated_at
}
});
} catch (error) {
@@ -382,6 +644,104 @@ router.post('/login', [
}
});
// TFA verification for login
router.post('/verify-tfa', [
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;
// Find user
const user = await prisma.users.findFirst({
where: {
OR: [
{ username },
{ email: username }
],
is_active: true,
tfa_enabled: true
},
select: {
id: true,
username: true,
email: true,
role: true,
tfa_secret: true,
tfa_backup_codes: true
}
});
if (!user) {
return res.status(401).json({ error: 'Invalid credentials or TFA not enabled' });
}
// Verify TFA token using the TFA routes logic
const speakeasy = require('speakeasy');
// 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(401).json({ error: 'Invalid verification code' });
}
// Update last login
await prisma.users.update({
where: { id: user.id },
data: { last_login: new Date() }
});
// Generate token
const jwtToken = generateToken(user.id);
res.json({
message: 'Login successful',
token: jwtToken,
user: {
id: user.id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role
}
});
} catch (error) {
console.error('TFA verification error:', error);
res.status(500).json({ error: 'TFA verification failed' });
}
});
// Get current user profile
router.get('/profile', authenticateToken, async (req, res) => {
try {
@@ -397,7 +757,9 @@ router.get('/profile', authenticateToken, async (req, res) => {
// Update user profile
router.put('/profile', authenticateToken, [
body('username').optional().isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
body('email').optional().isEmail().withMessage('Valid email is required')
body('email').optional().isEmail().withMessage('Valid email is required'),
body('first_name').optional().isLength({ min: 1 }).withMessage('First name must be at least 1 character'),
body('last_name').optional().isLength({ min: 1 }).withMessage('Last name must be at least 1 character')
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -405,15 +767,17 @@ router.put('/profile', authenticateToken, [
return res.status(400).json({ errors: errors.array() });
}
const { username, email } = req.body;
const { username, email, first_name, last_name } = req.body;
const updateData = {};
if (username) updateData.username = username;
if (email) updateData.email = email;
if (first_name !== undefined) updateData.first_name = first_name || null;
if (last_name !== undefined) updateData.last_name = last_name || null;
// Check if username/email already exists (excluding current user)
if (username || email) {
const existingUser = await prisma.user.findFirst({
const existingUser = await prisma.users.findFirst({
where: {
AND: [
{ id: { not: req.user.id } },
@@ -432,17 +796,19 @@ router.put('/profile', authenticateToken, [
}
}
const updatedUser = await prisma.user.update({
const updatedUser = await prisma.users.update({
where: { id: req.user.id },
data: updateData,
select: {
id: true,
username: true,
email: true,
first_name: true,
last_name: true,
role: true,
isActive: true,
lastLogin: true,
updatedAt: true
is_active: true,
last_login: true,
updated_at: true
}
});
@@ -470,12 +836,12 @@ router.put('/change-password', authenticateToken, [
const { currentPassword, newPassword } = req.body;
// Get user with password hash
const user = await prisma.user.findUnique({
const user = await prisma.users.findUnique({
where: { id: req.user.id }
});
// Verify current password
const isValidPassword = await bcrypt.compare(currentPassword, user.passwordHash);
const isValidPassword = await bcrypt.compare(currentPassword, user.password_hash);
if (!isValidPassword) {
return res.status(401).json({ error: 'Current password is incorrect' });
}
@@ -484,9 +850,9 @@ router.put('/change-password', authenticateToken, [
const newPasswordHash = await bcrypt.hash(newPassword, 12);
// Update password
await prisma.user.update({
await prisma.users.update({
where: { id: req.user.id },
data: { passwordHash: newPasswordHash }
data: { password_hash: newPasswordHash }
});
res.json({

View File

@@ -2,15 +2,123 @@ 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: 'recentCollection', requiredPermission: 'can_view_hosts', order: 10 },
{ cardId: 'updateStatus', requiredPermission: 'can_view_reports', order: 11 },
{ cardId: 'packagePriority', requiredPermission: 'can_view_packages', order: 12 },
{ cardId: 'recentUsers', requiredPermission: 'can_view_users', order: 13 },
{ cardId: 'quickStats', requiredPermission: 'can_view_dashboard', order: 14 }
];
// 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 },
const preferences = await prisma.dashboard_preferences.findMany({
where: { user_id: req.user.id },
orderBy: { order: 'asc' }
});
@@ -38,19 +146,21 @@ router.put('/', authenticateToken, [
const userId = req.user.id;
// Delete existing preferences for this user
await prisma.dashboardPreferences.deleteMany({
where: { userId }
await prisma.dashboard_preferences.deleteMany({
where: { user_id: userId }
});
// Create new preferences
const newPreferences = preferences.map(pref => ({
userId,
cardId: pref.cardId,
id: require('uuid').v4(),
user_id: userId,
card_id: pref.cardId,
enabled: pref.enabled,
order: pref.order
order: pref.order,
updated_at: new Date()
}));
const createdPreferences = await prisma.dashboardPreferences.createMany({
const createdPreferences = await prisma.dashboard_preferences.createMany({
data: newPreferences
});
@@ -67,16 +177,23 @@ router.put('/', authenticateToken, [
// Get default dashboard card configuration
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: '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 }
{ cardId: 'totalHostGroups', title: 'Host Groups', icon: 'Folder', enabled: true, order: 4 },
{ cardId: 'hostsNeedingUpdates', 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: 'recentCollection', title: 'Recent Collection', icon: 'Server', enabled: true, order: 10 },
{ cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 11 },
{ cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 12 },
{ cardId: 'recentUsers', title: 'Recent Users Logged in', icon: 'Users', enabled: true, order: 13 },
{ cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 14 }
];
res.json(defaultCards);
@@ -86,4 +203,4 @@ router.get('/defaults', authenticateToken, async (req, res) => {
}
});
module.exports = router;
module.exports = { router, createDefaultDashboardPreferences };

View File

@@ -5,7 +5,8 @@ const { authenticateToken } = require('../middleware/auth');
const {
requireViewDashboard,
requireViewHosts,
requireViewPackages
requireViewPackages,
requireViewUsers
} = require('../middleware/permissions');
const router = express.Router();
@@ -18,7 +19,7 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
// Get the agent update interval setting
const settings = await prisma.settings.findFirst();
const updateIntervalMinutes = settings?.updateInterval || 60; // Default to 60 minutes if no setting
const updateIntervalMinutes = settings?.update_interval || 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
@@ -32,60 +33,80 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
totalOutdatedPackages,
erroredHosts,
securityUpdates,
offlineHosts,
totalHostGroups,
totalUsers,
totalRepos,
osDistribution,
updateTrends
] = await Promise.all([
// Total hosts count
prisma.host.count({
where: { status: 'active' }
}),
// Total hosts count (all hosts regardless of status)
prisma.hosts.count(),
// Hosts needing updates (distinct hosts with packages needing updates)
prisma.host.count({
prisma.hosts.count({
where: {
status: 'active',
hostPackages: {
host_packages: {
some: {
needsUpdate: true
needs_update: true
}
}
}
}),
// Total outdated packages across all hosts
prisma.hostPackage.count({
where: { needsUpdate: true }
prisma.host_packages.count({
where: { needs_update: true }
}),
// Errored hosts (not updated within threshold based on update interval)
prisma.host.count({
prisma.hosts.count({
where: {
status: 'active',
lastUpdate: {
last_update: {
lt: thresholdTime
}
}
}),
// Security updates count
prisma.hostPackage.count({
prisma.host_packages.count({
where: {
needsUpdate: true,
isSecurityUpdate: true
needs_update: true,
is_security_update: true
}
}),
// 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()
}
}
}),
// Total host groups count
prisma.host_groups.count(),
// Total users count
prisma.users.count(),
// Total repositories count
prisma.repositories.count(),
// OS distribution for pie chart
prisma.host.groupBy({
by: ['osType'],
prisma.hosts.groupBy({
by: ['os_type'],
where: { status: 'active' },
_count: {
osType: true
os_type: true
}
}),
// Update trends for the last 7 days
prisma.updateHistory.groupBy({
prisma.update_history.groupBy({
by: ['timestamp'],
where: {
timestamp: {
@@ -96,16 +117,16 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
id: true
},
_sum: {
packagesCount: true,
securityCount: true
packages_count: true,
security_count: true
}
})
]);
// Format OS distribution for pie chart
const osDistributionFormatted = osDistribution.map(item => ({
name: item.osType,
count: item._count.osType
name: item.os_type,
count: item._count.os_type
}));
// Calculate update status distribution
@@ -125,9 +146,14 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
cards: {
totalHosts,
hostsNeedingUpdates,
upToDateHosts: Math.max(totalHosts - hostsNeedingUpdates, 0),
totalOutdatedPackages,
erroredHosts,
securityUpdates
securityUpdates,
offlineHosts,
totalHostGroups,
totalUsers,
totalRepos
},
charts: {
osDistribution: osDistributionFormatted,
@@ -146,19 +172,20 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
// Get hosts with their update status
router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
try {
const hosts = await prisma.host.findMany({
const hosts = await prisma.hosts.findMany({
// Show all hosts regardless of status
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
lastUpdate: true,
os_type: true,
os_version: true,
last_update: true,
status: true,
agentVersion: true,
autoUpdate: true,
hostGroup: {
agent_version: true,
auto_update: true,
host_groups: {
select: {
id: true,
name: true,
@@ -167,34 +194,41 @@ router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
},
_count: {
select: {
hostPackages: {
host_packages: {
where: {
needsUpdate: true
needs_update: true
}
}
}
}
},
orderBy: { lastUpdate: 'desc' }
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({
const updatesCount = await prisma.host_packages.count({
where: {
hostId: host.id,
needsUpdate: true
host_id: host.id,
needs_update: true
}
});
// Get total packages count for this host
const totalPackagesCount = await prisma.host_packages.count({
where: {
host_id: host.id
}
});
// Get the agent update interval setting for stale calculation
const settings = await prisma.settings.findFirst();
const updateIntervalMinutes = settings?.updateInterval || 60;
const updateIntervalMinutes = settings?.update_interval || 60;
const thresholdMinutes = updateIntervalMinutes * 2;
// Calculate effective status based on reporting interval
const isStale = moment(host.lastUpdate).isBefore(moment().subtract(thresholdMinutes, 'minutes'));
const isStale = moment(host.last_update).isBefore(moment().subtract(thresholdMinutes, 'minutes'));
let effectiveStatus = host.status;
// Override status if host hasn't reported within threshold
@@ -205,6 +239,7 @@ router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
return {
...host,
updatesCount,
totalPackagesCount,
isStale,
effectiveStatus
};
@@ -221,11 +256,11 @@ router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
// Get packages that need updates across all hosts
router.get('/packages', authenticateToken, requireViewPackages, async (req, res) => {
try {
const packages = await prisma.package.findMany({
const packages = await prisma.packages.findMany({
where: {
hostPackages: {
host_packages: {
some: {
needsUpdate: true
needs_update: true
}
}
},
@@ -234,18 +269,18 @@ router.get('/packages', authenticateToken, requireViewPackages, async (req, res)
name: true,
description: true,
category: true,
latestVersion: true,
hostPackages: {
where: { needsUpdate: true },
latest_version: true,
host_packages: {
where: { needs_update: true },
select: {
currentVersion: true,
availableVersion: true,
isSecurityUpdate: true,
host: {
current_version: true,
available_version: true,
is_security_update: true,
hosts: {
select: {
id: true,
hostname: true,
osType: true
friendly_name: true,
os_type: true
}
}
}
@@ -261,16 +296,16 @@ router.get('/packages', authenticateToken, requireViewPackages, async (req, res)
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
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
}))
}));
@@ -286,25 +321,25 @@ router.get('/hosts/:hostId', authenticateToken, requireViewHosts, async (req, re
try {
const { hostId } = req.params;
const host = await prisma.host.findUnique({
const host = await prisma.hosts.findUnique({
where: { id: hostId },
include: {
hostGroup: {
host_groups: {
select: {
id: true,
name: true,
color: true
}
},
hostPackages: {
host_packages: {
include: {
package: true
packages: true
},
orderBy: {
needsUpdate: 'desc'
needs_update: 'desc'
}
},
updateHistory: {
update_history: {
orderBy: {
timestamp: 'desc'
},
@@ -320,9 +355,9 @@ router.get('/hosts/:hostId', authenticateToken, requireViewHosts, async (req, re
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
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
}
};
@@ -333,4 +368,59 @@ router.get('/hosts/:hostId', authenticateToken, requireViewHosts, async (req, re
}
});
// 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,6 +1,7 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { PrismaClient } = require('@prisma/client');
const { randomUUID } = require('crypto');
const { authenticateToken } = require('../middleware/auth');
const { requireManageHosts } = require('../middleware/permissions');
@@ -10,7 +11,7 @@ const prisma = new PrismaClient();
// Get all host groups
router.get('/', authenticateToken, async (req, res) => {
try {
const hostGroups = await prisma.hostGroup.findMany({
const hostGroups = await prisma.host_groups.findMany({
include: {
_count: {
select: {
@@ -35,18 +36,19 @@ router.get('/:id', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const hostGroup = await prisma.hostGroup.findUnique({
const hostGroup = await prisma.host_groups.findUnique({
where: { id },
include: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
os_type: true,
os_version: true,
status: true,
lastUpdate: true
last_update: true
}
}
}
@@ -78,7 +80,7 @@ router.post('/', authenticateToken, requireManageHosts, [
const { name, description, color } = req.body;
// Check if host group with this name already exists
const existingGroup = await prisma.hostGroup.findUnique({
const existingGroup = await prisma.host_groups.findUnique({
where: { name }
});
@@ -86,11 +88,13 @@ router.post('/', authenticateToken, requireManageHosts, [
return res.status(400).json({ error: 'A host group with this name already exists' });
}
const hostGroup = await prisma.hostGroup.create({
const hostGroup = await prisma.host_groups.create({
data: {
id: randomUUID(),
name,
description: description || null,
color: color || '#3B82F6'
color: color || '#3B82F6',
updated_at: new Date()
}
});
@@ -117,7 +121,7 @@ router.put('/:id', authenticateToken, requireManageHosts, [
const { name, description, color } = req.body;
// Check if host group exists
const existingGroup = await prisma.hostGroup.findUnique({
const existingGroup = await prisma.host_groups.findUnique({
where: { id }
});
@@ -126,7 +130,7 @@ router.put('/:id', authenticateToken, requireManageHosts, [
}
// Check if another host group with this name already exists
const duplicateGroup = await prisma.hostGroup.findFirst({
const duplicateGroup = await prisma.host_groups.findFirst({
where: {
name,
id: { not: id }
@@ -137,12 +141,13 @@ router.put('/:id', authenticateToken, requireManageHosts, [
return res.status(400).json({ error: 'A host group with this name already exists' });
}
const hostGroup = await prisma.hostGroup.update({
const hostGroup = await prisma.host_groups.update({
where: { id },
data: {
name,
description: description || null,
color: color || '#3B82F6'
color: color || '#3B82F6',
updated_at: new Date()
}
});
@@ -159,7 +164,7 @@ router.delete('/:id', authenticateToken, requireManageHosts, async (req, res) =>
const { id } = req.params;
// Check if host group exists
const existingGroup = await prisma.hostGroup.findUnique({
const existingGroup = await prisma.host_groups.findUnique({
where: { id },
include: {
_count: {
@@ -181,7 +186,7 @@ router.delete('/:id', authenticateToken, requireManageHosts, async (req, res) =>
});
}
await prisma.hostGroup.delete({
await prisma.host_groups.delete({
where: { id }
});
@@ -197,21 +202,21 @@ router.get('/:id/hosts', authenticateToken, async (req, res) => {
try {
const { id } = req.params;
const hosts = await prisma.host.findMany({
where: { hostGroupId: id },
const hosts = await prisma.hosts.findMany({
where: { host_group_id: id },
select: {
id: true,
hostname: true,
friendly_name: true,
ip: true,
osType: true,
osVersion: true,
os_type: true,
os_version: true,
architecture: true,
status: true,
lastUpdate: true,
createdAt: true
last_update: true,
created_at: true
},
orderBy: {
hostname: 'asc'
friendly_name: 'asc'
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -34,16 +34,16 @@ router.get('/', async (req, res) => {
category ? { category: { equals: category } } : {},
// Update status filters
needsUpdate ? {
hostPackages: {
host_packages: {
some: {
needsUpdate: needsUpdate === 'true'
needs_update: needsUpdate === 'true'
}
}
} : {},
isSecurityUpdate ? {
hostPackages: {
host_packages: {
some: {
isSecurityUpdate: isSecurityUpdate === 'true'
is_security_update: isSecurityUpdate === 'true'
}
}
} : {}
@@ -52,17 +52,17 @@ router.get('/', async (req, res) => {
// Get packages with counts
const [packages, totalCount] = await Promise.all([
prisma.package.findMany({
prisma.packages.findMany({
where,
select: {
id: true,
name: true,
description: true,
category: true,
latestVersion: true,
createdAt: true,
latest_version: true,
created_at: true,
_count: {
hostPackages: true
host_packages: true
}
},
skip,
@@ -71,37 +71,38 @@ router.get('/', async (req, res) => {
name: 'asc'
}
}),
prisma.package.count({ where })
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({
prisma.host_packages.count({
where: {
packageId: pkg.id,
needsUpdate: true
package_id: pkg.id,
needs_update: true
}
}),
prisma.hostPackage.count({
prisma.host_packages.count({
where: {
packageId: pkg.id,
needsUpdate: true,
isSecurityUpdate: true
package_id: pkg.id,
needs_update: true,
is_security_update: true
}
}),
prisma.hostPackage.findMany({
prisma.host_packages.findMany({
where: {
packageId: pkg.id,
needsUpdate: true
package_id: pkg.id,
needs_update: true
},
select: {
host: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
osType: true
os_type: true
}
}
},
@@ -111,11 +112,19 @@ router.get('/', async (req, res) => {
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,
affectedHosts: affectedHosts.map(hp => hp.host)
securityUpdates: securityCount
}
};
})
@@ -141,10 +150,10 @@ router.get('/:packageId', async (req, res) => {
try {
const { packageId } = req.params;
const packageData = await prisma.package.findUnique({
const packageData = await prisma.packages.findUnique({
where: { id: packageId },
include: {
hostPackages: {
host_packages: {
include: {
host: {
select: {
@@ -170,21 +179,21 @@ router.get('/:packageId', async (req, res) => {
// 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
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 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 osDistribution = packageData.host_packages.reduce((acc, hp) => {
const osType = hp.host.osType;
acc[osType] = (acc[osType] || 0) + 1;
return acc;

View File

@@ -1,15 +1,15 @@
const express = require('express');
const { PrismaClient } = require('@prisma/client');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const { requireManageSettings } = require('../middleware/permissions');
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) => {
// 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.rolePermissions.findMany({
const permissions = await prisma.role_permissions.findMany({
orderBy: {
role: 'asc'
}
@@ -27,7 +27,7 @@ router.get('/roles/:role', authenticateToken, requireManageSettings, async (req,
try {
const { role } = req.params;
const permissions = await prisma.rolePermissions.findUnique({
const permissions = await prisma.role_permissions.findUnique({
where: { role }
});
@@ -47,49 +47,52 @@ router.put('/roles/:role', authenticateToken, requireManageSettings, async (req,
try {
const { role } = req.params;
const {
canViewDashboard,
canViewHosts,
canManageHosts,
canViewPackages,
canManagePackages,
canViewUsers,
canManageUsers,
canViewReports,
canExportData,
canManageSettings
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({
const permissions = await prisma.role_permissions.upsert({
where: { role },
update: {
canViewDashboard,
canViewHosts,
canManageHosts,
canViewPackages,
canManagePackages,
canViewUsers,
canManageUsers,
canViewReports,
canExportData,
canManageSettings
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,
canViewDashboard,
canViewHosts,
canManageHosts,
canViewPackages,
canManagePackages,
canViewUsers,
canManageUsers,
canViewReports,
canExportData,
canManageSettings
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()
}
});
@@ -108,13 +111,13 @@ router.delete('/roles/:role', authenticateToken, requireManageSettings, async (r
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({
const usersWithRole = await prisma.users.count({
where: { role }
});
@@ -124,7 +127,7 @@ router.delete('/roles/:role', authenticateToken, requireManageSettings, async (r
});
}
await prisma.rolePermissions.delete({
await prisma.role_permissions.delete({
where: { role }
});
@@ -142,7 +145,7 @@ router.get('/user-permissions', authenticateToken, async (req, res) => {
try {
const userRole = req.user.role;
const permissions = await prisma.rolePermissions.findUnique({
const permissions = await prisma.role_permissions.findUnique({
where: { role: userRole }
});
@@ -150,16 +153,16 @@ router.get('/user-permissions', authenticateToken, async (req, res) => {
// 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,
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,
});
}

View File

@@ -10,14 +10,14 @@ const prisma = new PrismaClient();
// Get all repositories with host count
router.get('/', authenticateToken, requireViewHosts, async (req, res) => {
try {
const repositories = await prisma.repository.findMany({
const repositories = await prisma.repositories.findMany({
include: {
hostRepositories: {
host_repositories: {
include: {
host: {
hosts: {
select: {
id: true,
hostname: true,
friendly_name: true,
status: true
}
}
@@ -25,7 +25,7 @@ router.get('/', authenticateToken, requireViewHosts, async (req, res) => {
},
_count: {
select: {
hostRepositories: true
host_repositories: true
}
}
},
@@ -38,15 +38,15 @@ router.get('/', authenticateToken, requireViewHosts, async (req, res) => {
// 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
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
}))
}));
@@ -62,19 +62,19 @@ router.get('/host/:hostId', authenticateToken, requireViewHosts, async (req, res
try {
const { hostId } = req.params;
const hostRepositories = await prisma.hostRepository.findMany({
where: { hostId },
const hostRepositories = await prisma.host_repositories.findMany({
where: { host_id: hostId },
include: {
repository: true,
host: {
repositories: true,
hosts: {
select: {
id: true,
hostname: true
friendly_name: true
}
}
},
orderBy: {
repository: {
repositories: {
name: 'asc'
}
}
@@ -92,26 +92,27 @@ router.get('/:repositoryId', authenticateToken, requireViewHosts, async (req, re
try {
const { repositoryId } = req.params;
const repository = await prisma.repository.findUnique({
const repository = await prisma.repositories.findUnique({
where: { id: repositoryId },
include: {
hostRepositories: {
host_repositories: {
include: {
host: {
hosts: {
select: {
id: true,
friendly_name: true,
hostname: true,
ip: true,
osType: true,
osVersion: true,
os_type: true,
os_version: true,
status: true,
lastUpdate: true
last_update: true
}
}
},
orderBy: {
host: {
hostname: 'asc'
hosts: {
friendly_name: 'asc'
}
}
}
@@ -145,18 +146,18 @@ router.put('/:repositoryId', authenticateToken, requireManageHosts, [
const { repositoryId } = req.params;
const { name, description, isActive, priority } = req.body;
const repository = await prisma.repository.update({
const repository = await prisma.repositories.update({
where: { id: repositoryId },
data: {
...(name && { name }),
...(description !== undefined && { description }),
...(isActive !== undefined && { isActive }),
...(isActive !== undefined && { is_active: isActive }),
...(priority !== undefined && { priority })
},
include: {
_count: {
select: {
hostRepositories: true
host_repositories: true
}
}
}
@@ -182,29 +183,29 @@ router.patch('/host/:hostId/repository/:repositoryId', authenticateToken, requir
const { hostId, repositoryId } = req.params;
const { isEnabled } = req.body;
const hostRepository = await prisma.hostRepository.update({
const hostRepository = await prisma.host_repositories.update({
where: {
hostId_repositoryId: {
hostId,
repositoryId
host_id_repository_id: {
host_id: hostId,
repository_id: repositoryId
}
},
data: {
isEnabled,
lastChecked: new Date()
is_enabled: isEnabled,
last_checked: new Date()
},
include: {
repository: true,
host: {
repositories: true,
hosts: {
select: {
hostname: true
friendly_name: true
}
}
}
});
res.json({
message: `Repository ${isEnabled ? 'enabled' : 'disabled'} for host ${hostRepository.host.hostname}`,
message: `Repository ${isEnabled ? 'enabled' : 'disabled'} for host ${hostRepository.hosts.friendly_name}`,
hostRepository
});
} catch (error) {
@@ -216,25 +217,25 @@ router.patch('/host/:hostId/repository/:repositoryId', authenticateToken, requir
// Get repository statistics
router.get('/stats/summary', authenticateToken, requireViewHosts, async (req, res) => {
try {
const stats = await prisma.repository.aggregate({
const stats = await prisma.repositories.aggregate({
_count: true
});
const hostRepoStats = await prisma.hostRepository.aggregate({
const hostRepoStats = await prisma.host_repositories.aggregate({
_count: {
isEnabled: true
is_enabled: true
},
where: {
isEnabled: true
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({
@@ -256,9 +257,9 @@ router.delete('/cleanup/orphaned', authenticateToken, requireManageHosts, async
console.log('Cleaning up orphaned repositories...');
// Find repositories with no host relationships
const orphanedRepos = await prisma.repository.findMany({
const orphanedRepos = await prisma.repositories.findMany({
where: {
hostRepositories: {
host_repositories: {
none: {}
}
}
@@ -273,7 +274,7 @@ router.delete('/cleanup/orphaned', authenticateToken, requireManageHosts, async
}
// Delete orphaned repositories
const deleteResult = await prisma.repository.deleteMany({
const deleteResult = await prisma.repositories.deleteMany({
where: {
hostRepositories: {
none: {}

View File

@@ -3,6 +3,7 @@ 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();
@@ -11,44 +12,47 @@ const prisma = new PrismaClient();
async function triggerCrontabUpdates() {
try {
console.log('Triggering crontab updates on all hosts with auto-update enabled...');
// Get current settings for server URL
const settings = await getSettings();
const serverUrl = settings.server_url;
// Get all hosts that have auto-update enabled
const hosts = await prisma.host.findMany({
where: {
autoUpdate: true,
const hosts = await prisma.hosts.findMany({
where: {
auto_update: true,
status: 'active' // Only update active hosts
},
select: {
id: true,
hostname: true,
apiId: true,
apiKey: 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.hostname}`);
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('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),
@@ -57,30 +61,30 @@ async function triggerCrontabUpdates() {
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
'X-API-ID': host.apiId,
'X-API-KEY': host.apiKey
'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.hostname}`);
console.log(`Successfully triggered crontab update for ${host.friendly_name}`);
} else {
console.error(`Failed to trigger crontab update for ${host.hostname}: ${res.statusCode}`);
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.hostname}:`, error.message);
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.hostname}:`, error.message);
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);
@@ -90,23 +94,7 @@ async function triggerCrontabUpdates() {
// 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
}
});
}
const settings = await getSettings();
console.log('Returning settings:', settings);
res.json(settings);
} catch (error) {
@@ -120,80 +108,60 @@ 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('signupEnabled').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 {
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() });
}
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl } = req.body;
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl });
// 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'
}
});
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'
}
});
const { serverProtocol, serverHost, serverPort, updateInterval, autoUpdate, 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;
// Update settings using the service
const updatedSettings = await updateSettings(currentSettings.id, {
server_protocol: serverProtocol,
server_host: serverHost,
server_port: serverPort,
update_interval: updateInterval || 60,
auto_update: autoUpdate || false,
signup_enabled: signupEnabled || false,
default_user_role: defaultUserRole || process.env.DEFAULT_USER_ROLE || 'user',
github_repo_url: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
repository_type: repositoryType || 'public',
ssh_key_path: sshKeyPath || null,
});
console.log('Settings updated successfully:', updatedSettings);
// 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();
}
res.json({
message: 'Settings updated successfully',
settings
settings: updatedSettings
});
} catch (error) {
console.error('Settings update error:', error);
@@ -204,31 +172,22 @@ router.put('/', authenticateToken, requireManageSettings, [
// 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 });
const settings = await getSettings();
const serverUrl = settings.server_url;
res.json({ server_url: serverUrl });
} catch (error) {
console.error('Server URL fetch error:', error);
res.json({ serverUrl: 'http://localhost:3001' });
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
const settings = await getSettings();
res.json({
updateInterval: settings.update_interval,
cronExpression: `*/${settings.update_interval} * * * *` // Generate cron expression
});
} catch (error) {
console.error('Update interval fetch error:', error);
@@ -239,14 +198,9 @@ router.get('/update-interval', async (req, res) => {
// 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
const settings = await getSettings();
res.json({
autoUpdate: settings.auto_update || false
});
} catch (error) {
console.error('Auto-update fetch error:', error);

View File

@@ -0,0 +1,309 @@
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 } = 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'
});
}
// Note: 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: id },
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

@@ -0,0 +1,204 @@
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 prisma = new PrismaClient();
const execAsync = promisify(exec);
const router = express.Router();
// Get current version info
router.get('/current', authenticateToken, async (req, res) => {
try {
// Read version from package.json dynamically
let currentVersion = '1.2.6'; // fallback
try {
const packageJson = require('../../package.json');
if (packageJson && 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
});
}
});
// Check for updates from GitHub
router.get('/check-updates', authenticateToken, requireManageSettings, async (req, res) => {
try {
// Get cached update information from settings
const settings = await prisma.settings.findFirst();
if (!settings) {
return res.status(400).json({ error: 'Settings not found' });
}
const currentVersion = '1.2.6';
const latestVersion = settings.latest_version || currentVersion;
const isUpdateAvailable = settings.update_available || false;
const lastUpdateCheck = settings.last_update_check || null;
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' });
}
});
// 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;
}
module.exports = router;

View File

@@ -3,7 +3,7 @@ const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const { PrismaClient } = require('@prisma/client');
const { createPrismaClient, waitForDatabase, disconnectPrisma } = require('./config/database');
const winston = require('winston');
// Import routes
@@ -14,11 +14,284 @@ 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 { router: dashboardPreferencesRoutes, createDefaultDashboardPreferences } = require('./routes/dashboardPreferencesRoutes');
const repositoryRoutes = require('./routes/repositoryRoutes');
const versionRoutes = require('./routes/versionRoutes');
const tfaRoutes = require('./routes/tfaRoutes');
const updateScheduler = require('./services/updateScheduler');
const { initSettings } = require('./services/settingsService');
// Initialize Prisma client
const prisma = new PrismaClient();
// Initialize Prisma client with optimized connection pooling for multiple instances
const prisma = createPrismaClient();
// Simple version comparison function for semantic versioning
function compareVersions(version1, version2) {
const v1Parts = version1.split('.').map(Number);
const v2Parts = version2.split('.').map(Number);
// Ensure both arrays have the same length
const maxLength = Math.max(v1Parts.length, v2Parts.length);
while (v1Parts.length < maxLength) v1Parts.push(0);
while (v2Parts.length < maxLength) v2Parts.push(0);
for (let i = 0; i < maxLength; i++) {
if (v1Parts[i] > v2Parts[i]) return true;
if (v1Parts[i] < v2Parts[i]) return false;
}
return false; // versions are equal
}
// Function to check and import agent version on startup
async function checkAndImportAgentVersion() {
console.log('🔍 Starting agent version auto-import check...');
// Skip if auto-import is disabled
if (process.env.AUTO_IMPORT_AGENT_VERSION === 'false') {
console.log('❌ Auto-import of agent version is disabled');
if (process.env.ENABLE_LOGGING === 'true') {
logger.info('Auto-import of agent version is disabled');
}
return;
}
try {
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
// Read and validate agent script
const agentScriptPath = path.join(__dirname, '../../agents/patchmon-agent.sh');
console.log('📁 Agent script path:', agentScriptPath);
// Check if file exists
if (!fs.existsSync(agentScriptPath)) {
console.log('❌ Agent script file not found, skipping version check');
if (process.env.ENABLE_LOGGING === 'true') {
logger.warn('Agent script file not found, skipping version check');
}
return;
}
console.log('✅ Agent script file found');
// Read the file content
const scriptContent = fs.readFileSync(agentScriptPath, 'utf8');
// Extract version from script content
const versionMatch = scriptContent.match(/AGENT_VERSION="([^"]+)"/);
if (!versionMatch) {
console.log('❌ Could not extract version from agent script, skipping version check');
if (process.env.ENABLE_LOGGING === 'true') {
logger.warn('Could not extract version from agent script, skipping version check');
}
return;
}
const localVersion = versionMatch[1];
console.log('📋 Local version:', localVersion);
// Check if this version already exists in database
const existingVersion = await prisma.agent_versions.findUnique({
where: { version: localVersion }
});
if (existingVersion) {
console.log(`✅ Agent version ${localVersion} already exists in database`);
if (process.env.ENABLE_LOGGING === 'true') {
logger.info(`Agent version ${localVersion} already exists in database`);
}
return;
}
console.log(`🆕 Agent version ${localVersion} not found in database`);
// Get existing versions for comparison
const allVersions = await prisma.agent_versions.findMany({
select: { version: true },
orderBy: { created_at: 'desc' }
});
// Determine version flags and whether to proceed
const isFirstVersion = allVersions.length === 0;
const isNewerVersion = !isFirstVersion && compareVersions(localVersion, allVersions[0].version);
if (!isFirstVersion && !isNewerVersion) {
console.log(`❌ Agent version ${localVersion} is not newer than existing versions, skipping import`);
if (process.env.ENABLE_LOGGING === 'true') {
logger.info(`Agent version ${localVersion} is not newer than existing versions, skipping import`);
}
return;
}
const shouldSetAsCurrent = isFirstVersion || isNewerVersion;
const shouldSetAsDefault = isFirstVersion;
console.log(isFirstVersion ?
`📊 No existing versions found in database` :
`📊 Found ${allVersions.length} existing versions in database, latest: ${allVersions[0].version}`
);
if (!isFirstVersion) {
console.log(`🔄 Version comparison: ${localVersion} > ${allVersions[0].version} = ${isNewerVersion}`);
}
// Clear existing flags if needed
const updatePromises = [];
if (shouldSetAsCurrent) {
updatePromises.push(prisma.agent_versions.updateMany({
where: { is_current: true },
data: { is_current: false }
}));
}
if (shouldSetAsDefault) {
updatePromises.push(prisma.agent_versions.updateMany({
where: { is_default: true },
data: { is_default: false }
}));
}
if (updatePromises.length > 0) {
await Promise.all(updatePromises);
}
// Create new version
await prisma.agent_versions.create({
data: {
id: crypto.randomUUID(),
version: localVersion,
release_notes: `Auto-imported on startup (${new Date().toISOString()})`,
script_content: scriptContent,
is_default: shouldSetAsDefault,
is_current: shouldSetAsCurrent,
updated_at: new Date()
}
});
console.log(`🎉 Successfully auto-imported new agent version ${localVersion} on startup`);
if (shouldSetAsCurrent) {
console.log(`✅ Set version ${localVersion} as current version`);
}
if (shouldSetAsDefault) {
console.log(`✅ Set version ${localVersion} as default version`);
}
if (process.env.ENABLE_LOGGING === 'true') {
logger.info(`✅ Auto-imported new agent version ${localVersion} on startup (current: ${shouldSetAsCurrent}, default: ${shouldSetAsDefault})`);
}
} catch (error) {
console.error('❌ Failed to check/import agent version on startup:', error.message);
if (process.env.ENABLE_LOGGING === 'true') {
logger.error('Failed to check/import agent version on startup:', error.message);
}
}
}
// 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('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({
@@ -28,10 +301,7 @@ const logger = process.env.ENABLE_LOGGING === 'true' ? winston.createLogger({
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' }),
],
transports: [],
}) : {
info: () => {},
error: () => {},
@@ -39,10 +309,34 @@ const logger = process.env.ENABLE_LOGGING === 'true' ? winston.createLogger({
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();
@@ -50,17 +344,41 @@ 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);
const trustProxyValue = process.env.TRUST_PROXY;
// 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
// 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.',
message: {
error: 'Too many requests from this IP, please try again later.',
retryAfter: Math.ceil((parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000) / 1000)
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true, // Don't count successful requests
skipFailedRequests: false, // Count failed requests
});
// Middleware
@@ -86,7 +404,7 @@ app.use(helmet({
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) {
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);
@@ -101,8 +419,13 @@ app.use(express.urlencoded({ extended: true, limit: process.env.JSON_BODY_LIMIT
// 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}`);
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();
});
}
@@ -115,16 +438,31 @@ app.get('/health', (req, res) => {
// API routes
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
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX) || 20,
message: {
error: 'Too many authentication requests, please try again later.',
retryAfter: Math.ceil((parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS) || 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
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX) || 120,
message: {
error: 'Too many agent requests, please try again later.',
retryAfter: Math.ceil((parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS) || 60 * 1000) / 1000)
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: true,
});
app.use(`/api/${apiVersion}/auth`, authLimiter, authRoutes);
app.use(`/api/${apiVersion}/hosts`, agentLimiter, hostRoutes);
app.use(`/api/${apiVersion}/host-groups`, hostGroupRoutes);
@@ -134,15 +472,17 @@ app.use(`/api/${apiVersion}/permissions`, permissionsRoutes);
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);
// 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
res.status(500).json({
error: 'Something went wrong!',
message: process.env.NODE_ENV === 'development' ? err.message : undefined
});
});
@@ -152,28 +492,267 @@ app.use('*', (req, res) => {
});
// 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');
}
await prisma.$disconnect();
updateScheduler.stop();
await disconnectPrisma(prisma);
process.exit(0);
});
// Start server
app.listen(PORT, () => {
process.on('SIGTERM', async () => {
if (process.env.ENABLE_LOGGING === 'true') {
logger.info(`Server running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV}`);
logger.info('SIGTERM received, shutting down gracefully');
}
updateScheduler.stop();
await disconnectPrisma(prisma);
process.exit(0);
});
module.exports = app;
// Initialize dashboard preferences for all users
async function initializeDashboardPreferences() {
try {
console.log('🔧 Initializing dashboard preferences for all users...');
// Get all users
const users = await prisma.users.findMany({
select: {
id: true,
username: true,
email: true,
role: true,
dashboard_preferences: {
select: {
card_id: true
}
}
}
});
if (users.length === 0) {
console.log(' No users found in database');
return;
}
console.log(`📊 Found ${users.length} users to initialize`);
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
console.log(`⚙️ Creating preferences for ${user.username} (${user.role})`);
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++;
console.log(` ✅ Created ${expectedCardCount} cards based on permissions`);
} else {
// User already has preferences - check if they need updating
const currentCardCount = user.dashboard_preferences.length;
if (currentCardCount !== expectedCardCount) {
console.log(`🔄 Updating preferences for ${user.username} (${user.role}) - ${currentCardCount}${expectedCardCount} cards`);
// 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++;
console.log(` ✅ Updated to ${expectedCardCount} cards based on permissions`);
} else {
console.log(`${user.username} already has correct preferences (${currentCardCount} cards)`);
}
}
}
console.log(`\n📋 Dashboard Preferences Initialization Complete:`);
console.log(` - New users initialized: ${initializedCount}`);
console.log(` - Existing users updated: ${updatedCount}`);
console.log(` - Users with correct preferences: ${users.length - initializedCount - updatedCount}`);
console.log(`\n🎯 Permission-based preferences:`);
console.log(` - Cards are now assigned based on actual user permissions`);
console.log(` - Each card requires specific permissions (can_view_hosts, can_view_users, etc.)`);
console.log(` - Users only see cards they have permission to access`);
} 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: 'recentCollection', requiredPermission: 'can_view_hosts', order: 10 }, // Collection is host-related
{ cardId: 'updateStatus', requiredPermission: 'can_view_reports', order: 11 },
{ cardId: 'packagePriority', requiredPermission: 'can_view_packages', order: 12 },
{ cardId: 'recentUsers', requiredPermission: 'can_view_users', order: 13 },
{ cardId: 'quickStats', requiredPermission: 'can_view_dashboard', order: 14 }
];
// 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 import agent version on startup
await checkAndImportAgentVersion();
// Check and create default role permissions on startup
await checkAndCreateRolePermissions();
// Initialize dashboard preferences for all users
await initializeDashboardPreferences();
app.listen(PORT, () => {
if (process.env.ENABLE_LOGGING === 'true') {
logger.info(`Server running on port ${PORT}`);
logger.info(`Environment: ${process.env.NODE_ENV}`);
}
// Start update scheduler
updateScheduler.start();
});
} catch (error) {
console.error('❌ Failed to start server:', error.message);
process.exit(1);
}
}
startServer();
module.exports = app;

View File

@@ -0,0 +1,183 @@
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,
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;
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;
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()
}
});
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,267 @@
const { PrismaClient } = require('@prisma/client');
const { exec } = require('child_process');
const { promisify } = require('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.6'; // fallback
try {
const packageJson = require('../../package.json');
if (packageJson && 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('fs').accessSync(path);
sshKeyPath = path;
break;
} catch (e) {
// 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.6'; // fallback
try {
const packageJson = require('../../package.json');
if (packageJson && 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;

148
docker/README.md Normal file
View File

@@ -0,0 +1,148 @@
# 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`
- **Frontend**: `ghcr.io/9technologygroup/patchmon-frontend:latest`
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. Configure environment variables (see [Configuration](#configuration) section)
3. Start the application:
```bash
docker compose up -d
```
4. Access the application at `http://localhost:3000`
## 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)
### Security Configuration
**⚠️ IMPORTANT**: Before deploying to production, you MUST:
1. Change the default database password in `docker-compose.yml`:
```yaml
environment:
POSTGRES_PASSWORD: YOUR_SECURE_PASSWORD_HERE
```
2. Update the corresponding `DATABASE_URL` in the backend service:
```yaml
environment:
DATABASE_URL: postgresql://patchmon_user:YOUR_SECURE_PASSWORD_HERE@database:5432/patchmon_db
```
---
# 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
# Attached, live log output, services stopped on Ctrl+C
docker compose -f docker/docker-compose.dev.yml up
# Detached
docker compose -f docker/docker-compose.dev.yml up -d
```
## Development Docker Compose
The development compose file (`docker/docker-compose.dev.yml`):
- Builds images locally from source
- Enables development workflow
- Supports live reload and debugging
## Building Images Locally
```
docker build -t patchmon-backend:dev -f docker/backend.Dockerfile .
docker build -t patchmon-frontend:dev -f docker/frontend.Dockerfile .
```
## Running in Docker Compose
For development or custom builds:
```bash
# Build backend image
docker build -f docker/backend.Dockerfile -t patchmon-backend:dev .
# Build frontend image
docker build -f docker/frontend.Dockerfile -t patchmon-frontend:dev .
```
## Development Commands
### 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 Workflow
1. **Code Changes**: Edit source files
2. **Rebuild**: `docker compose -f docker/docker-compose.dev.yml up -d --build`
3. **Test**: Access application and verify changes
4. **Debug**: Check logs with `docker compose logs -f`

47
docker/backend.Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
FROM node:lts-alpine AS builder
RUN apk add --no-cache openssl
WORKDIR /app
COPY --chown=node:node package*.json /app/
COPY --chown=node:node backend/ /app/backend/
WORKDIR /app/backend
RUN npm ci &&\
npx prisma generate &&\
npm prune --omit=dev &&\
npm cache clean --force
FROM node:lts-alpine
ENV NODE_ENV=production \
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 /app/backend
COPY --from=builder /app/node_modules /app/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,29 @@
#!/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..."
log "Running database migrations..."
npx prisma migrate deploy
log "Starting application..."
exec npm start

View File

@@ -0,0 +1,54 @@
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: 10s
timeout: 5s
retries: 5
backend:
build:
context: ..
dockerfile: docker/backend.Dockerfile
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:
- ./agents:/app/agents
depends_on:
database:
condition: service_healthy
frontend:
build:
context: ..
dockerfile: docker/frontend.Dockerfile
restart: unless-stopped
environment:
BACKEND_HOST: backend
BACKEND_PORT: 3001
ports:
- "3000:3000"
depends_on:
backend:
condition: service_healthy
volumes:
postgres_data:

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

@@ -0,0 +1,47 @@
services:
database:
image: postgres:17-alpine3.22
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: 10s
timeout: 5s
retries: 5
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:
- ./agents:/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:

View File

@@ -0,0 +1,24 @@
FROM node:lts-alpine AS builder
WORKDIR /app
COPY package*.json ./
COPY frontend/package*.json ./frontend/
RUN npm ci
COPY frontend/ ./frontend/
RUN npm run build:frontend
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,7 +1,7 @@
{
"name": "patchmon-frontend",
"private": true,
"version": "1.0.0",
"version": "1.2.6",
"type": "module",
"scripts": {
"dev": "vite",
@@ -17,11 +17,15 @@
"axios": "^1.6.2",
"chart.js": "^4.4.0",
"clsx": "^2.0.0",
"cors": "^2.8.5",
"date-fns": "^2.30.0",
"express": "^4.18.2",
"http-proxy-middleware": "^2.0.6",
"lucide-react": "^0.294.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.20.1"
},
"devDependencies": {
@@ -38,6 +42,6 @@
"vite": "^7.1.5"
},
"overrides": {
"esbuild": "^0.24.4"
"esbuild": "^0.25.10"
}
}

45
frontend/server.js Normal file
View File

@@ -0,0 +1,45 @@
import express from 'express';
import path from 'path';
import cors from 'cors';
import { fileURLToPath } from 'url';
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,99 +1,119 @@
import React from 'react'
import { Routes, Route } from 'react-router-dom'
import { AuthProvider } from './contexts/AuthContext'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import { ThemeProvider } from './contexts/ThemeContext'
import { UpdateNotificationProvider } from './contexts/UpdateNotificationContext'
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 Options from './pages/Options'
import Profile from './pages/Profile'
import HostDetail from './pages/HostDetail'
import PackageDetail from './pages/PackageDetail'
import FirstTimeAdminSetup from './components/FirstTimeAdminSetup'
function AppRoutes() {
const { needsFirstTimeSetup, checkingSetup, isAuthenticated } = useAuth()
const isAuth = isAuthenticated() // Call the function to get boolean value
// Show loading while checking if setup is needed
if (checkingSetup) {
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 />
}
function App() {
return (
<ThemeProvider>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<ProtectedRoute requirePermission="canViewDashboard">
<Layout>
<Dashboard />
</Layout>
</ProtectedRoute>
} />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<ProtectedRoute requirePermission="can_view_dashboard">
<Layout>
<Dashboard />
</Layout>
</ProtectedRoute>
} />
<Route path="/hosts" element={
<ProtectedRoute requirePermission="canViewHosts">
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<Hosts />
</Layout>
</ProtectedRoute>
} />
<Route path="/hosts/:hostId" element={
<ProtectedRoute requirePermission="canViewHosts">
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<HostDetail />
</Layout>
</ProtectedRoute>
} />
<Route path="/host-groups" element={
<ProtectedRoute requirePermission="canManageHosts">
<Layout>
<HostGroups />
</Layout>
</ProtectedRoute>
} />
<Route path="/packages" element={
<ProtectedRoute requirePermission="canViewPackages">
<ProtectedRoute requirePermission="can_view_packages">
<Layout>
<Packages />
</Layout>
</ProtectedRoute>
} />
<Route path="/repositories" element={
<ProtectedRoute requirePermission="canViewHosts">
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<Repositories />
</Layout>
</ProtectedRoute>
} />
<Route path="/repositories/:repositoryId" element={
<ProtectedRoute requirePermission="canViewHosts">
<ProtectedRoute requirePermission="can_view_hosts">
<Layout>
<RepositoryDetail />
</Layout>
</ProtectedRoute>
} />
<Route path="/users" element={
<ProtectedRoute requirePermission="canViewUsers">
<ProtectedRoute requirePermission="can_view_users">
<Layout>
<Users />
</Layout>
</ProtectedRoute>
} />
<Route path="/permissions" element={
<ProtectedRoute requirePermission="canManageSettings">
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<Permissions />
</Layout>
</ProtectedRoute>
} />
<Route path="/settings" element={
<ProtectedRoute requirePermission="canManageSettings">
<ProtectedRoute requirePermission="can_manage_settings">
<Layout>
<Settings />
</Layout>
</ProtectedRoute>
} />
<Route path="/options" element={
<ProtectedRoute requirePermission="can_manage_hosts">
<Layout>
<Options />
</Layout>
</ProtectedRoute>
} />
<Route path="/profile" element={
<ProtectedRoute>
<Layout>
@@ -102,13 +122,23 @@ function App() {
</ProtectedRoute>
} />
<Route path="/packages/:packageId" element={
<ProtectedRoute requirePermission="canViewPackages">
<ProtectedRoute requirePermission="can_view_packages">
<Layout>
<PackageDetail />
</Layout>
</ProtectedRoute>
} />
</Routes>
</Routes>
)
}
function App() {
return (
<ThemeProvider>
<AuthProvider>
<UpdateNotificationProvider>
<AppRoutes />
</UpdateNotificationProvider>
</AuthProvider>
</ThemeProvider>
)

View File

@@ -28,9 +28,11 @@ import {
Settings as SettingsIcon
} from 'lucide-react';
import { dashboardPreferencesAPI } from '../utils/api';
import { useTheme } from '../contexts/ThemeContext';
// Sortable Card Item Component
const SortableCardItem = ({ card, onToggle }) => {
const { isDark } = useTheme();
const {
attributes,
listeners,
@@ -50,7 +52,7 @@ const SortableCardItem = ({ card, onToggle }) => {
<div
ref={setNodeRef}
style={style}
className={`flex items-center justify-between p-3 bg-white border border-secondary-200 rounded-lg ${
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'
}`}
>
@@ -58,13 +60,16 @@ const SortableCardItem = ({ card, onToggle }) => {
<button
{...attributes}
{...listeners}
className="text-secondary-400 hover:text-secondary-600 cursor-grab active:cursor-grabbing"
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">
<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>
@@ -73,8 +78,8 @@ const SortableCardItem = ({ card, onToggle }) => {
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'
? '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 ? (
@@ -97,6 +102,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
const [cards, setCards] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const queryClient = useQueryClient();
const { isDark } = useTheme();
const sensors = useSensors(
useSensor(PointerSensor),
@@ -138,15 +144,39 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
// 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 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 === '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;
};
// 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);
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);
setCards(mergedCards);
}
@@ -212,24 +242,24 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
<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="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">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Dashboard Settings
</h3>
</div>
<button
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600"
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 mb-6">
<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>
@@ -259,7 +289,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
)}
</div>
<div className="bg-secondary-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<div className="bg-secondary-50 dark:bg-secondary-700 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<button
onClick={handleSave}
disabled={!hasChanges || updatePreferencesMutation.isPending}
@@ -284,7 +314,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
<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"
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
@@ -292,7 +322,7 @@ const DashboardSettingsModal = ({ isOpen, onClose }) => {
<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"
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>

View File

@@ -0,0 +1,297 @@
import React, { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { UserPlus, Shield, CheckCircle, AlertCircle } from 'lucide-react'
const FirstTimeAdminSetup = () => {
const { login } = useAuth()
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)
// Auto-login the user after successful setup
setTimeout(() => {
login(formData.username.trim(), formData.password)
}, 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. You will be automatically logged in shortly.
</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="firstName" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
First Name
</label>
<input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your first name"
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Last Name
</label>
<input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your last name"
required
disabled={isLoading}
/>
</div>
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Username
</label>
<input
type="text"
id="username"
name="username"
value={formData.username}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your username"
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Email Address
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
className="input w-full"
placeholder="Enter your email"
required
disabled={isLoading}
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Password
</label>
<input
type="password"
id="password"
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="confirmPassword" className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-2">
Confirm Password
</label>
<input
type="password"
id="confirmPassword"
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,157 @@
import React, { useState, useRef, useEffect } from 'react';
import { Edit2, Check, X } from 'lucide-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
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
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
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,257 @@
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Edit2, Check, X, ChevronDown } from 'lucide-react';
const InlineGroupEdit = ({
value,
onSave,
onCancel,
options = [],
className = "",
disabled = false,
placeholder = "Select group..."
}) => {
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 = () => {
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]);
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
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
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
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

@@ -19,12 +19,23 @@ import {
RefreshCw,
GitBranch,
Wrench,
Plus
Container,
Plus,
Activity,
Cog,
FileText,
Github,
MessageCircle,
Mail,
Star,
Globe
} from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '../contexts/AuthContext'
import { dashboardAPI, formatRelativeTime } from '../utils/api'
import { useUpdateNotification } from '../contexts/UpdateNotificationContext'
import { dashboardAPI, formatRelativeTime, versionAPI } from '../utils/api'
import UpgradeNotificationIcon from './UpgradeNotificationIcon'
const Layout = ({ children }) => {
const [sidebarOpen, setSidebarOpen] = useState(false)
@@ -34,45 +45,124 @@ const Layout = ({ children }) => {
return saved ? JSON.parse(saved) : false
})
const [userMenuOpen, setUserMenuOpen] = useState(false)
const [githubStars, setGithubStars] = useState(null)
const location = useLocation()
const { user, logout, canViewHosts, canManageHosts, canViewPackages, canViewUsers, canManageUsers, canManageSettings } = useAuth()
const { user, logout, canViewDashboard, canViewHosts, canManageHosts, canViewPackages, canViewUsers, canManageUsers, canViewReports, canExportData, canManageSettings } = useAuth()
const { updateAvailable } = useUpdateNotification()
const userMenuRef = useRef(null)
// Fetch dashboard stats for the "Last updated" info
const { data: stats, refetch } = useQuery({
const { data: stats, refetch, isFetching } = useQuery({
queryKey: ['dashboardStats'],
queryFn: () => dashboardAPI.getStats().then(res => res.data),
refetchInterval: 60000, // Refresh every minute
staleTime: 30000, // Consider data stale after 30 seconds
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
})
const navigation = [
{ name: 'Dashboard', href: '/', icon: Home },
{
section: 'Inventory',
items: [
...(canViewHosts() ? [{ name: 'Hosts', href: '/hosts', icon: Server }] : []),
...(canManageHosts() ? [{ name: 'Host Groups', href: '/host-groups', icon: Users }] : []),
...(canViewPackages() ? [{ name: 'Packages', href: '/packages', icon: Package }] : []),
...(canViewHosts() ? [{ name: 'Repos', href: '/repositories', icon: GitBranch }] : []),
{ name: 'Services', href: '/services', icon: Wrench, comingSoon: true },
{ name: 'Reporting', href: '/reporting', icon: BarChart3, comingSoon: true },
]
},
{
section: 'PatchMon Users',
items: [
...(canViewUsers() ? [{ name: 'Users', href: '/users', icon: Users }] : []),
...(canManageSettings() ? [{ name: 'Permissions', href: '/permissions', icon: Shield }] : []),
]
},
{
section: 'Settings',
items: [
...(canManageSettings() ? [{ name: 'Server Config', href: '/settings', icon: Settings }] : []),
]
// Fetch version info
const { data: versionInfo } = useQuery({
queryKey: ['versionInfo'],
queryFn: () => versionAPI.getCurrent().then(res => res.data),
staleTime: 300000, // Consider data stale after 5 minutes
})
// Build navigation based on permissions
const buildNavigation = () => {
const nav = []
// Dashboard - only show if user can view dashboard
if (canViewDashboard()) {
nav.push({ name: 'Dashboard', href: '/', icon: Home })
}
]
// Inventory section - only show if user has any inventory permissions
if (canViewHosts() || canViewPackages() || canViewReports()) {
const inventoryItems = []
if (canViewHosts()) {
inventoryItems.push({ name: 'Hosts', href: '/hosts', icon: Server })
inventoryItems.push({ name: 'Repos', href: '/repositories', icon: GitBranch })
}
if (canViewPackages()) {
inventoryItems.push({ name: 'Packages', href: '/packages', icon: Package })
}
if (canViewReports()) {
inventoryItems.push(
{ name: 'Services', href: '/services', icon: Activity, comingSoon: true },
{ name: 'Docker', href: '/docker', icon: Container, comingSoon: true },
{ name: 'Reporting', href: '/reporting', icon: BarChart3, comingSoon: true }
)
}
if (inventoryItems.length > 0) {
nav.push({
section: 'Inventory',
items: inventoryItems
})
}
}
// PatchMon Users section - only show if user can view/manage users
if (canViewUsers() || canManageUsers()) {
const userItems = []
if (canViewUsers()) {
userItems.push({ name: 'Users', href: '/users', icon: Users })
}
if (canManageSettings()) {
userItems.push({ name: 'Permissions', href: '/permissions', icon: Shield })
}
if (userItems.length > 0) {
nav.push({
section: 'PatchMon Users',
items: userItems
})
}
}
// Settings section - only show if user has any settings permissions
if (canManageSettings() || canViewReports() || canExportData()) {
const settingsItems = []
if (canManageSettings()) {
settingsItems.push({
name: 'PatchMon Options',
href: '/options',
icon: Settings
})
settingsItems.push({
name: 'Server Config',
href: '/settings',
icon: Wrench,
showUpgradeIcon: updateAvailable
})
}
if (canViewReports() || canExportData()) {
settingsItems.push({
name: 'Audit Log',
href: '/audit-log',
icon: FileText,
comingSoon: true
})
}
if (settingsItems.length > 0) {
nav.push({
section: 'Settings',
items: settingsItems
})
}
}
return nav
}
const navigation = buildNavigation()
const isActive = (path) => location.pathname === path
@@ -82,13 +172,15 @@ const Layout = ({ children }) => {
if (path === '/') return 'Dashboard'
if (path === '/hosts') return 'Hosts'
if (path === '/host-groups') return 'Host Groups'
if (path === '/packages') return 'Packages'
if (path === '/repositories' || path.startsWith('/repositories/')) return 'Repositories'
if (path === '/services') return 'Services'
if (path === '/docker') return 'Docker'
if (path === '/users') return 'Users'
if (path === '/permissions') return 'Permissions'
if (path === '/settings') return 'Settings'
if (path === '/options') return 'PatchMon Options'
if (path === '/audit-log') return 'Audit Log'
if (path === '/profile') return 'My Profile'
if (path.startsWith('/hosts/')) return 'Host Details'
if (path.startsWith('/packages/')) return 'Package Details'
@@ -106,10 +198,31 @@ const Layout = ({ children }) => {
window.location.href = '/hosts?action=add'
}
// Fetch GitHub stars count
const fetchGitHubStars = async () => {
try {
const response = await fetch('https://api.github.com/repos/9technologygroup/patchmon.net')
if (response.ok) {
const data = await response.json()
setGithubStars(data.stargazers_count)
}
} catch (error) {
console.error('Failed to fetch GitHub stars:', error)
}
}
// Short format for navigation area
const formatRelativeTimeShort = (date) => {
if (!date) return 'Never'
const now = new Date()
const diff = now - new Date(date)
const dateObj = new Date(date)
// Check if date is valid
if (isNaN(dateObj.getTime())) return 'Invalid date'
const diff = now - dateObj
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
@@ -140,6 +253,11 @@ const Layout = ({ children }) => {
}
}, [])
// Fetch GitHub stars on component mount
useEffect(() => {
fetchGitHubStars()
}, [])
return (
<div className="min-h-screen bg-secondary-50">
{/* Mobile sidebar */}
@@ -162,6 +280,15 @@ const Layout = ({ children }) => {
</div>
</div>
<nav className="mt-8 flex-1 space-y-6 px-2">
{/* Show message for users with very limited permissions */}
{navigation.length === 0 && (
<div className="px-2 py-4 text-center">
<div className="text-sm text-secondary-500 dark:text-secondary-400">
<p className="mb-2">Limited access</p>
<p className="text-xs">Contact your administrator for additional permissions</p>
</div>
</div>
)}
{navigation.map((item, index) => {
if (item.name) {
// Single item (Dashboard)
@@ -267,7 +394,7 @@ const Layout = ({ children }) => {
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
title="Expand sidebar"
>
<ChevronRight className="h-5 w-5 text-white" />
<ChevronRight className="h-5 w-5 text-secondary-700 dark:text-white" />
</button>
) : (
<>
@@ -280,13 +407,22 @@ const Layout = ({ children }) => {
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
title="Collapse sidebar"
>
<ChevronLeft className="h-5 w-5 text-white" />
<ChevronLeft className="h-5 w-5 text-secondary-700 dark:text-white" />
</button>
</>
)}
</div>
<nav className="flex flex-1 flex-col">
<ul className="flex flex-1 flex-col gap-y-6">
{/* Show message for users with very limited permissions */}
{navigation.length === 0 && (
<li className="px-2 py-4 text-center">
<div className="text-sm text-secondary-500 dark:text-secondary-400">
<p className="mb-2">Limited access</p>
<p className="text-xs">Contact your administrator for additional permissions</p>
</div>
</li>
)}
{navigation.map((item, index) => {
if (item.name) {
// Single item (Dashboard)
@@ -360,13 +496,18 @@ const Layout = ({ children }) => {
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'
} ${sidebarCollapsed ? 'justify-center p-2' : 'p-2'} ${
} ${sidebarCollapsed ? 'justify-center p-2 relative' : 'p-2'} ${
subItem.comingSoon ? 'opacity-50 cursor-not-allowed' : ''
}`}
title={sidebarCollapsed ? subItem.name : ''}
onClick={subItem.comingSoon ? (e) => e.preventDefault() : undefined}
>
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
<div className={`flex items-center ${sidebarCollapsed ? 'justify-center' : ''}`}>
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
{sidebarCollapsed && subItem.showUpgradeIcon && (
<UpgradeNotificationIcon className="h-3 w-3 absolute -top-1 -right-1" />
)}
</div>
{!sidebarCollapsed && (
<span className="truncate flex items-center gap-2">
{subItem.name}
@@ -375,6 +516,9 @@ const Layout = ({ children }) => {
Soon
</span>
)}
{subItem.showUpgradeIcon && (
<UpgradeNotificationIcon className="h-3 w-3" />
)}
</span>
)}
</Link>
@@ -390,6 +534,7 @@ const Layout = ({ children }) => {
</ul>
</nav>
{/* Profile Section - Bottom of Sidebar */}
<div className="border-t border-secondary-200 dark:border-secondary-600">
{!sidebarCollapsed ? (
@@ -416,7 +561,7 @@ const Layout = ({ children }) => {
? 'text-primary-700 dark:text-white'
: 'text-secondary-700 dark:text-secondary-200'
}`}>
{user?.username}
{user?.first_name || user?.username}
</span>
{user?.role === 'admin' && (
<span className="inline-flex items-center rounded-full bg-primary-100 px-1.5 py-0.5 text-xs font-medium text-primary-800">
@@ -436,17 +581,23 @@ const Layout = ({ children }) => {
</div>
{/* Updated info */}
{stats && (
<div className="px-3 py-1 border-t border-secondary-200 dark:border-secondary-700">
<div className="flex items-center gap-x-2 text-xs text-secondary-500 dark:text-secondary-400">
<Clock className="h-3 w-3" />
<span>Updated: {formatRelativeTimeShort(stats.lastUpdated)}</span>
<div className="px-2 py-1 border-t border-secondary-200 dark:border-secondary-700">
<div className="flex items-center gap-x-1 text-xs text-secondary-500 dark:text-secondary-400">
<Clock className="h-3 w-3 flex-shrink-0" />
<span className="truncate">Updated: {formatRelativeTimeShort(stats.lastUpdated)}</span>
<button
onClick={() => refetch()}
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
disabled={isFetching}
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded flex-shrink-0 disabled:opacity-50"
title="Refresh data"
>
<RefreshCw className="h-3 w-3" />
<RefreshCw className={`h-3 w-3 ${isFetching ? 'animate-spin' : ''}`} />
</button>
{versionInfo && (
<span className="text-xs text-secondary-400 dark:text-secondary-500 flex-shrink-0">
v{versionInfo.version}
</span>
)}
</div>
</div>
)}
@@ -473,14 +624,20 @@ const Layout = ({ children }) => {
</button>
{/* Updated info for collapsed sidebar */}
{stats && (
<div className="flex justify-center py-1 border-t border-secondary-200 dark:border-secondary-700">
<div className="flex flex-col items-center py-1 border-t border-secondary-200 dark:border-secondary-700">
<button
onClick={() => refetch()}
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
disabled={isFetching}
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded disabled:opacity-50"
title={`Refresh data - Updated: ${formatRelativeTimeShort(stats.lastUpdated)}`}
>
<RefreshCw className="h-3 w-3" />
<RefreshCw className={`h-3 w-3 ${isFetching ? 'animate-spin' : ''}`} />
</button>
{versionInfo && (
<span className="text-xs text-secondary-400 dark:text-secondary-500 mt-1">
v{versionInfo.version}
</span>
)}
</div>
)}
</div>
@@ -513,20 +670,48 @@ const Layout = ({ children }) => {
</h2>
</div>
<div className="flex items-center gap-x-4 lg:gap-x-6">
{/* Customize Dashboard Button - Only show on Dashboard page */}
{location.pathname === '/' && (
<button
onClick={() => {
// This will be handled by the Dashboard component
const event = new CustomEvent('openDashboardSettings');
window.dispatchEvent(event);
}}
className="btn-outline flex items-center gap-2"
{/* External Links */}
<div className="flex items-center gap-2">
<a
href="https://github.com/9technologygroup/patchmon.net"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 py-2 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm group relative"
>
<Settings className="h-4 w-4" />
Customize Dashboard
</button>
)}
<Github className="h-5 w-5 flex-shrink-0" />
{githubStars !== null && (
<div className="flex items-center gap-0.5">
<Star className="h-3 w-3 fill-current text-yellow-500" />
<span className="text-sm font-medium">{githubStars}</span>
</div>
)}
</a>
<a
href="https://discord.gg/DDKQeW6mnq"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
title="Discord"
>
<MessageCircle className="h-5 w-5" />
</a>
<a
href="mailto:support@patchmon.net"
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
title="Email support@patchmon.net"
>
<Mail className="h-5 w-5" />
</a>
<a
href="https://patchmon.net"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center w-10 h-10 bg-gray-50 dark:bg-gray-800 text-secondary-600 dark:text-secondary-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors shadow-sm"
title="Visit patchmon.net"
>
<Globe className="h-5 w-5" />
</a>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,15 @@
import React from 'react'
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

@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, useEffect } from 'react'
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react'
const AuthContext = createContext()
@@ -15,6 +15,10 @@ export const AuthProvider = ({ children }) => {
const [token, setToken] = useState(null)
const [permissions, setPermissions] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [permissionsLoading, setPermissionsLoading] = useState(false)
const [needsFirstTimeSetup, setNeedsFirstTimeSetup] = useState(false)
const [checkingSetup, setCheckingSetup] = useState(true)
// Initialize auth state from localStorage
useEffect(() => {
@@ -42,20 +46,17 @@ export const AuthProvider = ({ children }) => {
setIsLoading(false)
}, [])
// Periodically refresh permissions when user is logged in
// Refresh permissions when user logs in (no automatic refresh)
useEffect(() => {
if (token && user) {
// Refresh permissions every 30 seconds
const interval = setInterval(() => {
refreshPermissions()
}, 30000)
return () => clearInterval(interval)
// Only refresh permissions once when user logs in
refreshPermissions()
}
}, [token, user])
const fetchPermissions = async (authToken) => {
try {
setPermissionsLoading(true)
const response = await fetch('/api/v1/permissions/user-permissions', {
headers: {
'Authorization': `Bearer ${authToken}`,
@@ -74,6 +75,8 @@ export const AuthProvider = ({ children }) => {
} catch (error) {
console.error('Error fetching permissions:', error)
return null
} finally {
setPermissionsLoading(false)
}
}
@@ -199,30 +202,79 @@ export const AuthProvider = ({ children }) => {
// 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 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')
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')
// 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)
} else {
// If endpoint doesn't exist or fails, assume setup is needed
setNeedsFirstTimeSetup(true)
}
} catch (error) {
console.error('Error checking admin users:', error)
// If there's an error, assume setup is needed
setNeedsFirstTimeSetup(true)
} finally {
setCheckingSetup(false)
}
}, [])
// Check for admin users on initial load
useEffect(() => {
if (!token && !user) {
checkAdminUsersExist()
} else {
setCheckingSetup(false)
}
}, [token, user, checkAdminUsersExist])
const setAuthState = (authToken, authUser) => {
setToken(authToken)
setUser(authUser)
localStorage.setItem('token', authToken)
localStorage.setItem('user', JSON.stringify(authUser))
}
const value = {
user,
token,
permissions,
isLoading,
isLoading: isLoading || permissionsLoading || checkingSetup,
needsFirstTimeSetup,
checkingSetup,
login,
logout,
updateProfile,
changePassword,
refreshPermissions,
setAuthState,
isAuthenticated,
isAdmin,
hasPermission,

View File

@@ -0,0 +1,58 @@
import React, { createContext, useContext, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { versionAPI, settingsAPI } 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 { user, token } = useAuth()
// Ensure settings are loaded
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ['settings'],
queryFn: () => settingsAPI.get().then(res => res.data),
enabled: !!(user && token),
retry: 1
})
// 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: !!(user && token && settings && !settingsLoading) // Only run when authenticated and settings are loaded
})
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

@@ -8,13 +8,19 @@ import {
Shield,
TrendingUp,
RefreshCw,
Clock
Clock,
WifiOff,
Settings,
Users,
Folder,
GitBranch
} from 'lucide-react'
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title } from 'chart.js'
import { Pie, Bar } from 'react-chartjs-2'
import { dashboardAPI, dashboardPreferencesAPI, settingsAPI, formatRelativeTime } from '../utils/api'
import DashboardSettingsModal from '../components/DashboardSettingsModal'
import { useTheme } from '../contexts/ThemeContext'
import { useAuth } from '../contexts/AuthContext'
// Register Chart.js components
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title)
@@ -24,10 +30,11 @@ const Dashboard = () => {
const [cardPreferences, setCardPreferences] = useState([])
const navigate = useNavigate()
const { isDark } = useTheme()
const { user } = useAuth()
// Navigation handlers
const handleTotalHostsClick = () => {
navigate('/hosts')
navigate('/hosts', { replace: true })
}
const handleHostsNeedingUpdatesClick = () => {
@@ -46,18 +53,79 @@ const Dashboard = () => {
navigate('/hosts?filter=inactive')
}
const handleOfflineHostsClick = () => {
navigate('/hosts?filter=offline')
}
// New navigation handlers for top cards
const handleUsersClick = () => {
navigate('/users')
}
const handleHostGroupsClick = () => {
navigate('/options')
}
const handleRepositoriesClick = () => {
navigate('/repositories')
}
const handleOSDistributionClick = () => {
navigate('/hosts')
navigate('/hosts?showFilters=true', { replace: true })
}
const handleUpdateStatusClick = () => {
navigate('/hosts')
navigate('/hosts?filter=needsUpdates', { replace: true })
}
const handlePackagePriorityClick = () => {
navigate('/packages?filter=security')
}
// Chart click handlers
const handleOSChartClick = (event, elements) => {
if (elements.length > 0) {
const elementIndex = elements[0].index
const osName = stats.charts.osDistribution[elementIndex].name.toLowerCase()
navigate(`/hosts?osFilter=${osName}&showFilters=true`, { replace: true })
}
}
const handleUpdateStatusChartClick = (event, elements) => {
if (elements.length > 0) {
const elementIndex = elements[0].index
const statusName = stats.charts.updateStatusDistribution[elementIndex].name
// Map status names to filter parameters
let filter = ''
if (statusName.toLowerCase().includes('needs updates')) {
filter = 'needsUpdates'
} else if (statusName.toLowerCase().includes('up to date')) {
filter = 'upToDate'
} else if (statusName.toLowerCase().includes('stale')) {
filter = 'stale'
}
if (filter) {
navigate(`/hosts?filter=${filter}`, { replace: true })
}
}
}
const handlePackagePriorityChartClick = (event, elements) => {
if (elements.length > 0) {
const elementIndex = elements[0].index
const priorityName = stats.charts.packageUpdateDistribution[elementIndex].name
// Map priority names to filter parameters
if (priorityName.toLowerCase().includes('security')) {
navigate('/packages?filter=security', { replace: true })
} else if (priorityName.toLowerCase().includes('outdated')) {
navigate('/packages?filter=outdated', { replace: true })
}
}
}
// Helper function to format the update interval threshold
const formatUpdateIntervalThreshold = () => {
if (!settings?.updateInterval) return '24 hours'
@@ -84,11 +152,25 @@ const Dashboard = () => {
}
}
const { data: stats, isLoading, error, refetch } = useQuery({
const { data: stats, isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['dashboardStats'],
queryFn: () => dashboardAPI.getStats().then(res => res.data),
refetchInterval: 60000, // Refresh every minute
staleTime: 30000, // Consider data stale after 30 seconds
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
})
// Fetch recent users (permission protected server-side)
const { data: recentUsers } = useQuery({
queryKey: ['dashboardRecentUsers'],
queryFn: () => dashboardAPI.getRecentUsers().then(res => res.data),
staleTime: 60 * 1000,
})
// Fetch recent collection (permission protected server-side)
const { data: recentCollection } = useQuery({
queryKey: ['dashboardRecentCollection'],
queryFn: () => dashboardAPI.getRecentCollection().then(res => res.data),
staleTime: 60 * 1000,
})
// Fetch settings to get the agent update interval
@@ -110,22 +192,32 @@ const Dashboard = () => {
queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data),
})
// Merge preferences with default cards
// Merge preferences with default cards (normalize snake_case from API)
useEffect(() => {
if (preferences && defaultCards) {
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);
setCardPreferences(mergedCards);
const normalizedPreferences = preferences.map((p) => ({
cardId: p.cardId ?? p.card_id,
enabled: p.enabled,
order: p.order,
}))
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,
}
})
.sort((a, b) => a.order - b.order)
setCardPreferences(mergedCards)
} else if (defaultCards) {
// If no preferences exist, use defaults
setCardPreferences(defaultCards.sort((a, b) => a.order - b.order));
setCardPreferences(defaultCards.sort((a, b) => a.order - b.order))
}
}, [preferences, defaultCards])
@@ -149,9 +241,9 @@ const Dashboard = () => {
// Helper function to get card type for layout grouping
const getCardType = (cardId) => {
if (['totalHosts', 'hostsNeedingUpdates', 'totalOutdatedPackages', 'securityUpdates'].includes(cardId)) {
if (['totalHosts', 'hostsNeedingUpdates', 'totalOutdatedPackages', 'securityUpdates', 'upToDateHosts', 'totalHostGroups', 'totalUsers', 'totalRepos'].includes(cardId)) {
return 'stats';
} else if (['osDistribution', 'updateStatus', 'packagePriority'].includes(cardId)) {
} else if (['osDistribution', 'osDistributionBar', 'updateStatus', 'packagePriority', 'recentUsers', 'recentCollection'].includes(cardId)) {
return 'charts';
} else if (['erroredHosts', 'quickStats'].includes(cardId)) {
return 'fullwidth';
@@ -176,6 +268,24 @@ const Dashboard = () => {
// Helper function to render a card by ID
const renderCard = (cardId) => {
switch (cardId) {
case 'upToDateHosts':
return (
<div
className="card p-4"
>
<div className="flex items-center">
<div className="flex-shrink-0">
<TrendingUp className="h-5 w-5 text-success-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">Up to date</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.upToDateHosts}/{stats.cards.totalHosts}
</p>
</div>
</div>
</div>
);
case 'totalHosts':
return (
<div
@@ -255,6 +365,57 @@ const Dashboard = () => {
</div>
</div>
);
case 'totalHostGroups':
return (
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200" onClick={handleHostGroupsClick}>
<div className="flex items-center">
<div className="flex-shrink-0">
<Folder className="h-5 w-5 text-primary-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">Host Groups</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.totalHostGroups}
</p>
</div>
</div>
</div>
);
case 'totalUsers':
return (
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200" onClick={handleUsersClick}>
<div className="flex items-center">
<div className="flex-shrink-0">
<Users className="h-5 w-5 text-success-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">Users</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.totalUsers}
</p>
</div>
</div>
</div>
);
case 'totalRepos':
return (
<div className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200" onClick={handleRepositoriesClick}>
<div className="flex items-center">
<div className="flex-shrink-0">
<GitBranch className="h-5 w-5 text-warning-600 mr-2" />
</div>
<div className="w-0 flex-1">
<p className="text-sm text-secondary-500 dark:text-white">Repositories</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
{stats.cards.totalRepos}
</p>
</div>
</div>
</div>
);
case 'erroredHosts':
return (
@@ -295,6 +456,45 @@ const Dashboard = () => {
</div>
);
case 'offlineHosts':
return (
<div
className={`border rounded-lg p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 ${
stats.cards.offlineHosts > 0
? 'bg-warning-50 border-warning-200'
: 'bg-success-50 border-success-200'
}`}
onClick={handleOfflineHostsClick}
>
<div className="flex">
<WifiOff className={`h-5 w-5 ${
stats.cards.offlineHosts > 0 ? 'text-warning-400' : 'text-success-400'
}`} />
<div className="ml-3">
{stats.cards.offlineHosts > 0 ? (
<>
<h3 className="text-sm font-medium text-warning-800">
{stats.cards.offlineHosts} host{stats.cards.offlineHosts > 1 ? 's' : ''} offline/stale
</h3>
<p className="text-sm text-warning-700 mt-1">
These hosts haven't reported in {formatUpdateIntervalThreshold() * 3}+ minutes.
</p>
</>
) : (
<>
<h3 className="text-sm font-medium text-success-800">
All hosts are online
</h3>
<p className="text-sm text-success-700 mt-1">
No hosts are offline or stale.
</p>
</>
)}
</div>
</div>
</div>
);
case 'osDistribution':
return (
<div
@@ -308,6 +508,19 @@ const Dashboard = () => {
</div>
);
case 'osDistributionBar':
return (
<div
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
onClick={handleOSDistributionClick}
>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">OS Distribution</h3>
<div className="h-64">
<Bar data={osBarChartData} options={barChartOptions} />
</div>
</div>
);
case 'updateStatus':
return (
<div
@@ -316,7 +529,7 @@ const Dashboard = () => {
>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Update Status</h3>
<div className="h-64">
<Pie data={updateStatusChartData} options={chartOptions} />
<Pie data={updateStatusChartData} options={updateStatusChartOptions} />
</div>
</div>
);
@@ -329,36 +542,112 @@ const Dashboard = () => {
>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Package Priority</h3>
<div className="h-64">
<Pie data={packagePriorityChartData} options={chartOptions} />
<Pie data={packagePriorityChartData} options={packagePriorityChartOptions} />
</div>
</div>
);
case 'quickStats':
// Calculate dynamic stats
const updatePercentage = stats.cards.totalHosts > 0 ? ((stats.cards.hostsNeedingUpdates / stats.cards.totalHosts) * 100).toFixed(1) : 0;
const onlineHosts = stats.cards.totalHosts - stats.cards.erroredHosts;
const onlinePercentage = stats.cards.totalHosts > 0 ? ((onlineHosts / stats.cards.totalHosts) * 100).toFixed(0) : 0;
const securityPercentage = stats.cards.totalOutdatedPackages > 0 ? ((stats.cards.securityUpdates / stats.cards.totalOutdatedPackages) * 100).toFixed(0) : 0;
const avgPackagesPerHost = stats.cards.totalHosts > 0 ? Math.round(stats.cards.totalOutdatedPackages / stats.cards.totalHosts) : 0;
return (
<div className="card p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Quick Stats</h3>
<TrendingUp className="h-5 w-5 text-success-500" />
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">System Overview</h3>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-primary-600">
{((stats.cards.hostsNeedingUpdates / stats.cards.totalHosts) * 100).toFixed(1)}%
{updatePercentage}%
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Need Updates</div>
<div className="text-xs text-secondary-400 dark:text-secondary-500">
{stats.cards.hostsNeedingUpdates}/{stats.cards.totalHosts} hosts
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Hosts need updates</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-danger-600">
{stats.cards.securityUpdates}
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Security updates pending</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Security Issues</div>
<div className="text-xs text-secondary-400 dark:text-secondary-500">
{securityPercentage}% of updates
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-success-600">
{stats.cards.totalHosts - stats.cards.erroredHosts}
{onlinePercentage}%
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Hosts online</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Online</div>
<div className="text-xs text-secondary-400 dark:text-secondary-500">
{onlineHosts}/{stats.cards.totalHosts} hosts
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-secondary-600">
{avgPackagesPerHost}
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-300">Avg per Host</div>
<div className="text-xs text-secondary-400 dark:text-secondary-500">
outdated packages
</div>
</div>
</div>
</div>
);
case 'recentUsers':
return (
<div className="card p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Recent Users Logged in</h3>
<div className="h-64 overflow-y-auto">
<div className="space-y-3">
{(recentUsers || []).slice(0, 5).map(u => (
<div key={u.id} className="flex items-center justify-between py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-b-0">
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{u.username}
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-400">
{u.last_login ? formatRelativeTime(u.last_login) : 'Never'}
</div>
</div>
))}
{(!recentUsers || recentUsers.length === 0) && (
<div className="text-center text-secondary-500 dark:text-secondary-400 py-4">
No users found
</div>
)}
</div>
</div>
</div>
);
case 'recentCollection':
return (
<div className="card p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Recent Collection</h3>
<div className="h-64 overflow-y-auto">
<div className="space-y-3">
{(recentCollection || []).slice(0, 5).map(host => (
<div key={host.id} className="flex items-center justify-between py-2 border-b border-secondary-100 dark:border-secondary-700 last:border-b-0">
<div className="text-sm font-medium text-secondary-900 dark:text-white">
{host.friendly_name || host.hostname}
</div>
<div className="text-sm text-secondary-500 dark:text-secondary-400">
{host.last_update ? formatRelativeTime(host.last_update) : 'Never'}
</div>
</div>
))}
{(!recentCollection || recentCollection.length === 0) && (
<div className="text-center text-secondary-500 dark:text-secondary-400 py-4">
No hosts found
</div>
)}
</div>
</div>
</div>
@@ -412,6 +701,73 @@ const Dashboard = () => {
}
},
},
onClick: handleOSChartClick,
}
const updateStatusChartOptions = {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: isDark ? '#ffffff' : '#374151',
font: {
size: 12
}
}
},
},
onClick: handleUpdateStatusChartClick,
}
const packagePriorityChartOptions = {
responsive: true,
plugins: {
legend: {
position: 'bottom',
labels: {
color: isDark ? '#ffffff' : '#374151',
font: {
size: 12
}
}
},
},
onClick: handlePackagePriorityChartClick,
}
const barChartOptions = {
responsive: true,
indexAxis: 'y', // Make the chart horizontal
plugins: {
legend: {
display: false
},
},
scales: {
x: {
ticks: {
color: isDark ? '#ffffff' : '#374151',
font: {
size: 12
}
},
grid: {
color: isDark ? '#374151' : '#e5e7eb'
}
},
y: {
ticks: {
color: isDark ? '#ffffff' : '#374151',
font: {
size: 12
}
},
grid: {
color: isDark ? '#374151' : '#e5e7eb'
}
}
}
}
const osChartData = {
@@ -433,6 +789,28 @@ const Dashboard = () => {
],
}
const osBarChartData = {
labels: stats.charts.osDistribution.map(item => item.name),
datasets: [
{
label: 'Hosts',
data: stats.charts.osDistribution.map(item => item.count),
backgroundColor: [
'#3B82F6', // Blue
'#10B981', // Green
'#F59E0B', // Yellow
'#EF4444', // Red
'#8B5CF6', // Purple
'#06B6D4', // Cyan
],
borderWidth: 1,
borderColor: isDark ? '#374151' : '#ffffff',
borderRadius: 4,
borderSkipped: false,
},
],
}
const updateStatusChartData = {
labels: stats.charts.updateStatusDistribution.map(item => item.name),
datasets: [
@@ -466,6 +844,36 @@ const Dashboard = () => {
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">
Welcome back, {user?.first_name || user?.username || 'User'} 👋
</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Overview of your PatchMon infrastructure
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowSettingsModal(true)}
className="btn-outline flex items-center gap-2"
title="Customize dashboard layout"
>
<Settings className="h-4 w-4" />
Customize Dashboard
</button>
<button
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center gap-2"
title="Refresh dashboard data"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Refreshing...' : 'Refresh'}
</button>
</div>
</div>
{/* Dynamically Rendered Cards - Unified Order */}
{(() => {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,48 @@
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Eye, EyeOff, Lock, User, AlertCircle } from 'lucide-react'
import { Eye, EyeOff, Lock, User, AlertCircle, Smartphone, ArrowLeft, Mail } from 'lucide-react'
import { useAuth } from '../contexts/AuthContext'
import { authAPI } from '../utils/api'
const Login = () => {
const [isSignupMode, setIsSignupMode] = useState(false)
const [formData, setFormData] = useState({
username: '',
password: ''
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 { login, setAuthState } = useAuth()
// 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()
}, [])
const handleSubmit = async (e) => {
e.preventDefault()
@@ -21,16 +50,80 @@ const Login = () => {
setError('')
try {
const result = await login(formData.username, formData.password)
if (result.success) {
const response = await authAPI.login(formData.username, formData.password)
if (response.data.requiresTfa) {
setRequiresTfa(true)
setTfaUsername(formData.username)
setError('')
} else {
// Regular login successful
const result = await login(formData.username, formData.password)
if (result.success) {
navigate('/')
} else {
setError(result.error || 'Login failed')
}
}
} catch (err) {
setError(err.response?.data?.error || 'Login failed')
} finally {
setIsLoading(false)
}
}
const handleSignupSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
try {
const response = await authAPI.signup(formData.username, formData.email, formData.password, formData.firstName, formData.lastName)
if (response.data && response.data.token) {
// Update AuthContext state and localStorage
setAuthState(response.data.token, response.data.user)
// Redirect to dashboard
navigate('/')
} else {
setError(result.error || 'Login failed')
setError('Signup failed - invalid response')
}
} catch (err) {
setError('Network error occurred')
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)
}
}
const handleTfaSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
setError('')
try {
const response = await authAPI.verifyTfa(tfaUsername, tfaData.token)
if (response.data && response.data.token) {
// Store token and user data
localStorage.setItem('token', response.data.token)
localStorage.setItem('user', JSON.stringify(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)
}
@@ -43,31 +136,62 @@ const Login = () => {
})
}
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 className="h-6 w-6 text-primary-600" />
<Lock size={24} color="#2563eb" strokeWidth={2} />
</div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900">
Sign in to PatchMon
{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>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{!requiresTfa ? (
<form className="mt-8 space-y-6" onSubmit={isSignupMode ? handleSignupSubmit : handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="username" className="block text-sm font-medium text-secondary-700">
Username or Email
{isSignupMode ? 'Username' : '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"
@@ -76,19 +200,94 @@ const Login = () => {
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"
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="firstName" 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="firstName"
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="lastName" 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="lastName"
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="email" className="block text-sm font-medium text-secondary-700">
Email
</label>
<div className="mt-1 relative">
<input
id="email"
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="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"
@@ -99,16 +298,23 @@ const Login = () => {
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">
<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="text-secondary-400 hover:text-secondary-500"
className="bg-transparent border-none cursor-pointer p-1 flex items-center justify-center"
>
{showPassword ? (
<EyeOff className="h-5 w-5" />
<EyeOff size={20} color="#64748b" strokeWidth={2} />
) : (
<Eye className="h-5 w-5" />
<Eye size={20} color="#64748b" strokeWidth={2} />
)}
</button>
</div>
@@ -119,7 +325,7 @@ const Login = () => {
{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" />
<AlertCircle size={20} color="#dc2626" strokeWidth={2} />
<div className="ml-3">
<p className="text-sm text-danger-700">{error}</p>
</div>
@@ -136,20 +342,106 @@ const Login = () => {
{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...
{isSignupMode ? 'Creating account...' : 'Signing in...'}
</div>
) : (
'Sign in'
isSignupMode ? 'Create Account' : 'Sign in'
)}
</button>
</div>
<div className="text-center">
<p className="text-sm text-secondary-600">
Need help? Contact your system administrator.
</p>
</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="token" className="block text-sm font-medium text-secondary-700">
Verification Code
</label>
<div className="mt-1">
<input
id="token"
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>
)

View File

@@ -0,0 +1,571 @@
import React, { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Plus,
Edit,
Trash2,
Server,
Users,
AlertTriangle,
CheckCircle,
Settings
} from 'lucide-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
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>
)}
</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
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 [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 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 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 [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 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 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
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>
)
}
export default Options

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import React, { useState, useEffect, useMemo } from 'react'
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import {
Package,
@@ -9,7 +9,17 @@ import {
Search,
AlertTriangle,
Filter,
ExternalLink
ExternalLink,
ArrowUpDown,
ArrowUp,
ArrowDown,
ChevronDown,
Settings,
Columns,
GripVertical,
X,
Eye as EyeIcon,
EyeOff as EyeOffIcon
} from 'lucide-react'
import { dashboardAPI } from '../utils/api'
@@ -17,7 +27,62 @@ const Packages = () => {
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 host filter from URL parameter
useEffect(() => {
const hostParam = searchParams.get('host')
if (hostParam) {
setHostFilter(hostParam)
}
}, [searchParams])
// 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 }
]
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
})
// Update column configuration
const updateColumnConfig = (newConfig) => {
setColumnConfig(newConfig)
localStorage.setItem('packages-column-config', JSON.stringify(newConfig))
}
// Handle affected hosts click
const handleAffectedHostsClick = (pkg) => {
const affectedHosts = pkg.affectedHosts || []
const hostIds = affectedHosts.map(host => host.hostId)
const hostNames = affectedHosts.map(host => host.friendlyName)
// Create URL with selected hosts and filter
const params = new URLSearchParams()
params.set('selected', hostIds.join(','))
params.set('filter', 'selected')
// Navigate to hosts page with selected hosts
navigate(`/hosts?${params.toString()}`)
}
// Handle URL filter parameters
useEffect(() => {
@@ -34,13 +99,206 @@ const Packages = () => {
}
}, [searchParams])
const { data: packages, isLoading, error, refetch } = useQuery({
const { data: packages, isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['packages'],
queryFn: () => dashboardAPI.getPackages().then(res => res.data),
refetchInterval: 60000,
staleTime: 30000,
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
})
// 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
})
// Filter and sort packages
const filteredAndSortedPackages = useMemo(() => {
if (!packages) return []
// Filter packages
const filtered = 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)
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])
// 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
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">
@@ -74,64 +332,58 @@ const Packages = () => {
)
}
// 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
}) || []
// 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 => {
pkg.affectedHosts.forEach(host => {
uniqueAffectedHosts.add(host.hostId)
})
})
const uniqueAffectedHostsCount = uniqueAffectedHosts.size
return (
<div className="space-y-6">
<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
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">
<div className="card p-4">
<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">{packages?.length || 0}</p>
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{totalPackagesCount}</p>
</div>
</div>
</div>
<div className="card p-4">
<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" />
<Package className="h-5 w-5 text-warning-600 mr-2" />
<div>
<p className="text-sm text-secondary-500 dark:text-white">Security Updates</p>
<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">
{packages?.filter(pkg => pkg.isSecurityUpdate).length || 0}
{outdatedPackagesCount}
</p>
</div>
</div>
</div>
<div className="card p-4">
<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">Affected Hosts</p>
<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>
@@ -139,152 +391,235 @@ const Packages = () => {
</div>
</div>
<div className="card p-4">
<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">
<Filter className="h-5 w-5 text-secondary-600 mr-2" />
<Shield className="h-5 w-5 text-danger-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>
<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>
{/* 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>
{/* 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>
<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>
{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>
)}
{/* 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
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 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">
</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>
<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>
{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
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">
{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>
{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>
</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 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) => (
<div
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 ${
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
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>
</div>
)}
))}
</div>
<div className="flex justify-between mt-6">
<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
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>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Shield,
@@ -145,17 +145,22 @@ const Permissions = () => {
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: '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' }
{ 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) => {
@@ -169,7 +174,7 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
onSave(role.role, permissions)
}
const isAdminRole = role.role === 'admin'
const isBuiltInRole = role.role === 'admin' || role.role === 'user'
return (
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
@@ -178,9 +183,9 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
<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 && (
{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">
System Role
Built-in Role
</span>
)}
</div>
@@ -196,7 +201,7 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
</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"
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
@@ -206,13 +211,13 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
<>
<button
onClick={onEdit}
disabled={isAdminRole}
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>
{!isAdminRole && (
{!isBuiltInRole && (
<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"
@@ -240,7 +245,7 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
type="checkbox"
checked={isChecked}
onChange={(e) => handlePermissionChange(field.key, e.target.checked)}
disabled={!isEditing || (isAdminRole && field.key === 'canManageUsers')}
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>
@@ -268,16 +273,16 @@ const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDele
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
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('')
@@ -309,12 +314,12 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
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>
<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 className="block text-sm font-medium text-secondary-700 mb-1">
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Role Name
</label>
<input
@@ -323,25 +328,25 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
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"
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">Use lowercase with underscores (e.g., host_manager)</p>
<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: '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' }
{ 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
@@ -351,7 +356,7 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
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">
<label className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200">
{permission.label}
</label>
</div>
@@ -359,8 +364,8 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
</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 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>
)}
@@ -368,7 +373,7 @@ const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
<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"
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>

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { useAuth } from '../contexts/AuthContext'
import { useTheme } from '../contexts/ThemeContext'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
User,
Mail,
@@ -13,8 +14,15 @@ import {
AlertCircle,
Sun,
Moon,
Settings
Settings,
Smartphone,
QrCode,
Copy,
Download,
Trash2,
RefreshCw
} from 'lucide-react'
import { tfaAPI } from '../utils/api'
const Profile = () => {
const { user, updateProfile, changePassword } = useAuth()
@@ -25,7 +33,9 @@ const Profile = () => {
const [profileData, setProfileData] = useState({
username: user?.username || '',
email: user?.email || ''
email: user?.email || '',
first_name: user?.first_name || '',
last_name: user?.last_name || ''
})
const [passwordData, setPasswordData] = useState({
@@ -111,6 +121,7 @@ const Profile = () => {
const tabs = [
{ id: 'profile', name: 'Profile Information', icon: User },
{ id: 'password', name: 'Change Password', icon: Key },
{ id: 'tfa', name: 'Multi-Factor Authentication', icon: Smartphone },
{ id: 'preferences', name: 'Preferences', icon: Settings }
]
@@ -132,7 +143,11 @@ const Profile = () => {
</div>
</div>
<div className="flex-1">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">{user?.username}</h3>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
{user?.first_name && user?.last_name
? `${user.first_name} ${user.last_name}`
: user?.first_name || user?.username}
</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300">{user?.email}</p>
<div className="mt-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
@@ -242,6 +257,38 @@ const Profile = () => {
<Mail className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
</div>
</div>
<div>
<label htmlFor="first_name" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200">
First Name
</label>
<div className="mt-1">
<input
type="text"
name="first_name"
id="first_name"
value={profileData.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>
<div>
<label htmlFor="last_name" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200">
Last Name
</label>
<div className="mt-1">
<input
type="text"
name="last_name"
id="last_name"
value={profileData.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>
</div>
@@ -357,6 +404,11 @@ const Profile = () => {
</form>
)}
{/* Multi-Factor Authentication Tab */}
{activeTab === 'tfa' && (
<TfaTab />
)}
{/* Preferences Tab */}
{activeTab === 'preferences' && (
<div className="space-y-6">
@@ -411,4 +463,435 @@ const Profile = () => {
)
}
// TFA Tab Component
const TfaTab = () => {
const [setupStep, setSetupStep] = useState('status') // 'status', 'setup', 'verify', 'backup-codes'
const [verificationToken, setVerificationToken] = useState('')
const [password, setPassword] = useState('')
const [backupCodes, setBackupCodes] = useState([])
const [isLoading, setIsLoading] = useState(false)
const [message, setMessage] = useState({ type: '', text: '' })
const queryClient = useQueryClient()
// Fetch TFA status
const { data: tfaStatus, isLoading: statusLoading } = useQuery({
queryKey: ['tfaStatus'],
queryFn: () => tfaAPI.status().then(res => res.data),
})
// Setup TFA mutation
const setupMutation = useMutation({
mutationFn: () => tfaAPI.setup().then(res => res.data),
onSuccess: (data) => {
setSetupStep('setup')
setMessage({ type: 'info', text: 'Scan the QR code with your authenticator app and enter the verification code below.' })
},
onError: (error) => {
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to setup TFA' })
}
})
// Verify setup mutation
const verifyMutation = useMutation({
mutationFn: (data) => tfaAPI.verifySetup(data).then(res => res.data),
onSuccess: (data) => {
setBackupCodes(data.backupCodes)
setSetupStep('backup-codes')
setMessage({ type: 'success', text: 'Two-factor authentication has been enabled successfully!' })
},
onError: (error) => {
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to verify TFA setup' })
}
})
// Disable TFA mutation
const disableMutation = useMutation({
mutationFn: (data) => tfaAPI.disable(data).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries(['tfaStatus'])
setSetupStep('status')
setMessage({ type: 'success', text: 'Two-factor authentication has been disabled successfully!' })
},
onError: (error) => {
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to disable TFA' })
}
})
// Regenerate backup codes mutation
const regenerateBackupCodesMutation = useMutation({
mutationFn: () => tfaAPI.regenerateBackupCodes().then(res => res.data),
onSuccess: (data) => {
setBackupCodes(data.backupCodes)
setMessage({ type: 'success', text: 'Backup codes have been regenerated successfully!' })
},
onError: (error) => {
setMessage({ type: 'error', text: error.response?.data?.error || 'Failed to regenerate backup codes' })
}
})
const handleSetup = () => {
setupMutation.mutate()
}
const handleVerify = (e) => {
e.preventDefault()
if (verificationToken.length !== 6) {
setMessage({ type: 'error', text: 'Please enter a 6-digit verification code' })
return
}
verifyMutation.mutate({ token: verificationToken })
}
const handleDisable = (e) => {
e.preventDefault()
if (!password) {
setMessage({ type: 'error', text: 'Please enter your password to disable TFA' })
return
}
disableMutation.mutate({ password })
}
const handleRegenerateBackupCodes = () => {
regenerateBackupCodesMutation.mutate()
}
const copyToClipboard = async (text) => {
try {
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
setMessage({ type: 'success', text: 'Copied to clipboard!' })
return
}
// Fallback for older browsers or non-secure contexts
const textArea = document.createElement('textarea')
textArea.value = text
textArea.style.position = 'fixed'
textArea.style.left = '-999999px'
textArea.style.top = '-999999px'
document.body.appendChild(textArea)
textArea.focus()
textArea.select()
try {
const successful = document.execCommand('copy')
if (successful) {
setMessage({ type: 'success', text: 'Copied to clipboard!' })
} else {
throw new Error('Copy command failed')
}
} catch (err) {
// If all else fails, show the text in a prompt
prompt('Copy this text:', text)
setMessage({ type: 'info', text: 'Text shown in prompt for manual copying' })
} finally {
document.body.removeChild(textArea)
}
} catch (err) {
console.error('Failed to copy to clipboard:', err)
// Show the text in a prompt as last resort
prompt('Copy this text:', text)
setMessage({ type: 'info', text: 'Text shown in prompt for manual copying' })
}
}
const downloadBackupCodes = () => {
const content = `PatchMon Backup Codes\n\n${backupCodes.map((code, index) => `${index + 1}. ${code}`).join('\n')}\n\nKeep these codes safe! Each code can only be used once.`
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'patchmon-backup-codes.txt'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
if (statusLoading) {
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>
)
}
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Multi-Factor Authentication</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
Add an extra layer of security to your account by enabling two-factor authentication.
</p>
</div>
{/* Status Message */}
{message.text && (
<div className={`rounded-md p-4 ${
message.type === 'success'
? 'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700'
: message.type === 'error'
? 'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700'
: 'bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700'
}`}>
<div className="flex">
{message.type === 'success' ? (
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
) : message.type === 'error' ? (
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
) : (
<AlertCircle className="h-5 w-5 text-blue-400 dark:text-blue-300" />
)}
<div className="ml-3">
<p className={`text-sm font-medium ${
message.type === 'success' ? 'text-green-800 dark:text-green-200' :
message.type === 'error' ? 'text-red-800 dark:text-red-200' :
'text-blue-800 dark:text-blue-200'
}`}>
{message.text}
</p>
</div>
</div>
</div>
)}
{/* TFA Status */}
{setupStep === 'status' && (
<div className="space-y-6">
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className={`p-2 rounded-full ${tfaStatus?.enabled ? 'bg-green-100 dark:bg-green-900' : 'bg-secondary-100 dark:bg-secondary-700'}`}>
<Smartphone className={`h-6 w-6 ${tfaStatus?.enabled ? 'text-green-600 dark:text-green-400' : 'text-secondary-600 dark:text-secondary-400'}`} />
</div>
<div>
<h4 className="text-lg font-medium text-secondary-900 dark:text-white">
{tfaStatus?.enabled ? 'Two-Factor Authentication Enabled' : 'Two-Factor Authentication Disabled'}
</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300">
{tfaStatus?.enabled
? 'Your account is protected with two-factor authentication.'
: 'Add an extra layer of security to your account.'
}
</p>
</div>
</div>
<div>
{tfaStatus?.enabled ? (
<button
onClick={() => setSetupStep('disable')}
className="btn-outline text-danger-600 border-danger-300 hover:bg-danger-50"
>
<Trash2 className="h-4 w-4 mr-2" />
Disable TFA
</button>
) : (
<button
onClick={handleSetup}
disabled={setupMutation.isPending}
className="btn-primary"
>
<Smartphone className="h-4 w-4 mr-2" />
{setupMutation.isPending ? 'Setting up...' : 'Enable TFA'}
</button>
)}
</div>
</div>
</div>
{tfaStatus?.enabled && (
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Backup Codes</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
Use these backup codes to access your account if you lose your authenticator device.
</p>
<button
onClick={handleRegenerateBackupCodes}
disabled={regenerateBackupCodesMutation.isPending}
className="btn-outline"
>
<RefreshCw className={`h-4 w-4 mr-2 ${regenerateBackupCodesMutation.isPending ? 'animate-spin' : ''}`} />
{regenerateBackupCodesMutation.isPending ? 'Regenerating...' : 'Regenerate Codes'}
</button>
</div>
)}
</div>
)}
{/* TFA Setup */}
{setupStep === 'setup' && setupMutation.data && (
<div className="space-y-6">
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Setup Two-Factor Authentication</h4>
<div className="space-y-4">
<div className="text-center">
<img
src={setupMutation.data.qrCode}
alt="QR Code"
className="mx-auto h-48 w-48 border border-secondary-200 dark:border-secondary-600 rounded-lg"
/>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-2">
Scan this QR code with your authenticator app
</p>
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<p className="text-sm font-medium text-secondary-900 dark:text-white mb-2">Manual Entry Key:</p>
<div className="flex items-center space-x-2">
<code className="flex-1 bg-white dark:bg-secondary-800 px-3 py-2 rounded border text-sm font-mono">
{setupMutation.data.manualEntryKey}
</code>
<button
onClick={() => copyToClipboard(setupMutation.data.manualEntryKey)}
className="p-2 text-secondary-400 hover:text-secondary-600 dark:hover:text-secondary-300"
title="Copy to clipboard"
>
<Copy className="h-4 w-4" />
</button>
</div>
</div>
<div className="text-center">
<button
onClick={() => setSetupStep('verify')}
className="btn-primary"
>
Continue to Verification
</button>
</div>
</div>
</div>
</div>
)}
{/* TFA Verification */}
{setupStep === 'verify' && (
<div className="space-y-6">
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Verify Setup</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
Enter the 6-digit code from your authenticator app to complete the setup.
</p>
<form onSubmit={handleVerify} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Verification Code
</label>
<input
type="text"
value={verificationToken}
onChange={(e) => setVerificationToken(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
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 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white text-center text-lg font-mono tracking-widest"
maxLength="6"
required
/>
</div>
<div className="flex space-x-3">
<button
type="submit"
disabled={verifyMutation.isPending || verificationToken.length !== 6}
className="btn-primary"
>
{verifyMutation.isPending ? 'Verifying...' : 'Verify & Enable'}
</button>
<button
type="button"
onClick={() => setSetupStep('status')}
className="btn-outline"
>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
{/* Backup Codes */}
{setupStep === 'backup-codes' && backupCodes.length > 0 && (
<div className="space-y-6">
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Backup Codes</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
Save these backup codes in a safe place. Each code can only be used once.
</p>
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg mb-4">
<div className="grid grid-cols-2 gap-2 font-mono text-sm">
{backupCodes.map((code, index) => (
<div key={index} className="flex items-center justify-between py-1">
<span className="text-secondary-600 dark:text-secondary-400">{index + 1}.</span>
<span className="text-secondary-900 dark:text-white">{code}</span>
</div>
))}
</div>
</div>
<div className="flex space-x-3">
<button
onClick={downloadBackupCodes}
className="btn-outline"
>
<Download className="h-4 w-4 mr-2" />
Download Codes
</button>
<button
onClick={() => {
setSetupStep('status')
queryClient.invalidateQueries(['tfaStatus'])
}}
className="btn-primary"
>
Done
</button>
</div>
</div>
</div>
)}
{/* Disable TFA */}
{setupStep === 'disable' && (
<div className="space-y-6">
<div className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6">
<h4 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Disable Two-Factor Authentication</h4>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-4">
Enter your password to disable two-factor authentication.
</p>
<form onSubmit={handleDisable} className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(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 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
required
/>
</div>
<div className="flex space-x-3">
<button
type="submit"
disabled={disableMutation.isPending || !password}
className="btn-danger"
>
{disableMutation.isPending ? 'Disabling...' : 'Disable TFA'}
</button>
<button
type="button"
onClick={() => setSetupStep('status')}
className="btn-outline"
>
Cancel
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
export default Profile

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import {
@@ -11,7 +11,16 @@ import {
Lock,
Unlock,
Database,
Eye
Eye,
Search,
Columns,
ArrowUpDown,
ArrowUp,
ArrowDown,
X,
GripVertical,
Check,
RefreshCw
} from 'lucide-react';
import { repositoryAPI } from '../utils/api';
@@ -19,9 +28,40 @@ const Repositories = () => {
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);
// 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 }
];
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;
});
const updateColumnConfig = (newConfig) => {
setColumnConfig(newConfig);
localStorage.setItem('repositories-column-config', JSON.stringify(newConfig));
};
// Fetch repositories
const { data: repositories = [], isLoading, error } = useQuery({
const { data: repositories = [], isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['repositories'],
queryFn: () => repositoryAPI.list().then(res => res.data)
});
@@ -32,22 +72,107 @@ const Repositories = () => {
queryFn: () => repositoryAPI.getStats().then(res => res.data)
});
// 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());
// 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)
const matchesType = filterType === 'all' ||
(filterType === 'secure' && repo.isSecure) ||
(filterType === 'insecure' && !repo.isSecure);
// Update order values
const updatedConfig = newConfig.map((col, index) => ({ ...col, order: index }))
updateColumnConfig(updatedConfig)
};
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)
};
// Filter and sort repositories
const filteredAndSortedRepositories = useMemo(() => {
if (!repositories) return []
const matchesStatus = filterStatus === 'all' ||
(filterStatus === 'active' && repo.isActive) ||
(filterStatus === 'inactive' && !repo.isActive);
return matchesSearch && matchesType && matchesStatus;
});
// 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());
// Check security based on URL if isSecure property doesn't exist
const isSecure = repo.isSecure !== undefined ? repo.isSecure : repo.url.startsWith('https://');
const matchesType = filterType === 'all' ||
(filterType === 'secure' && isSecure) ||
(filterType === 'insecure' && !isSecure);
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 (
@@ -71,202 +196,351 @@ const Repositories = () => {
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<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-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
<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
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>
{/* 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>
<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>
<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>
<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>
{/* 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>
)}
{/* 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 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>
{/* 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>
<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>
{/* 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 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="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>
{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 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>
) : (
<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>
<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>
{/* 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>
{/* 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
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
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 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) => (
<div
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"
>
<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
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>
</div>
))}
</div>
<div className="flex justify-between mt-6">
<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
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

@@ -45,7 +45,7 @@ const RepositoryDetail = () => {
setFormData({
name: repository.name,
description: repository.description || '',
isActive: repository.isActive,
is_active: repository.is_active,
priority: repository.priority || ''
});
setEditMode(true);
@@ -139,11 +139,11 @@ const RepositoryDetail = () => {
{repository.name}
</h1>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
repository.isActive
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.isActive ? 'Active' : 'Inactive'}
{repository.is_active ? 'Active' : 'Inactive'}
</span>
</div>
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
@@ -228,12 +228,12 @@ const RepositoryDetail = () => {
<div className="flex items-center">
<input
type="checkbox"
id="isActive"
checked={formData.isActive}
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
id="is_active"
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="isActive" className="ml-2 block text-sm text-secondary-900 dark:text-white">
<label htmlFor="is_active" className="ml-2 block text-sm text-secondary-900 dark:text-white">
Repository is active
</label>
</div>
@@ -295,7 +295,7 @@ const RepositoryDetail = () => {
<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()}
{new Date(repository.created_at).toLocaleDateString()}
</span>
</div>
</div>
@@ -310,10 +310,10 @@ const RepositoryDetail = () => {
<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})
Hosts Using This Repository ({repository.host_repositories?.length || 0})
</h2>
</div>
{!repository.hostRepositories || repository.hostRepositories.length === 0 ? (
{!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>
@@ -323,28 +323,28 @@ const RepositoryDetail = () => {
</div>
) : (
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
{repository.hostRepositories.map((hostRepo) => (
{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.host.status === 'active'
hostRepo.hosts.status === 'active'
? 'bg-green-500'
: hostRepo.host.status === 'pending'
: hostRepo.hosts.status === 'pending'
? 'bg-yellow-500'
: 'bg-red-500'
}`} />
<div>
<Link
to={`/hosts/${hostRepo.host.id}`}
to={`/hosts/${hostRepo.hosts.id}`}
className="text-primary-600 hover:text-primary-700 font-medium"
>
{hostRepo.host.hostname}
{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.host.ip}</span>
<span>OS: {hostRepo.host.osType} {hostRepo.host.osVersion}</span>
<span>Last Update: {new Date(hostRepo.host.lastUpdate).toLocaleDateString()}</span>
<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>
@@ -352,7 +352,7 @@ const RepositoryDetail = () => {
<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()}
{new Date(hostRepo.last_checked).toLocaleDateString()}
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -140,7 +140,7 @@ const Users = () => {
<Shield className="h-3 w-3 mr-1" />
{user.role.charAt(0).toUpperCase() + user.role.slice(1).replace('_', ' ')}
</span>
{user.isActive ? (
{user.is_active ? (
<CheckCircle className="ml-2 h-4 w-4 text-green-500" />
) : (
<XCircle className="ml-2 h-4 w-4 text-red-500" />
@@ -152,11 +152,11 @@ const Users = () => {
</div>
<div className="flex items-center mt-1 text-sm text-secondary-500 dark:text-secondary-300">
<Calendar className="h-4 w-4 mr-1" />
Created: {new Date(user.createdAt).toLocaleDateString()}
{user.lastLogin && (
Created: {new Date(user.created_at).toLocaleDateString()}
{user.last_login && (
<>
<span className="mx-2"></span>
Last login: {new Date(user.lastLogin).toLocaleDateString()}
Last login: {new Date(user.last_login).toLocaleDateString()}
</>
)}
</div>
@@ -174,11 +174,11 @@ const Users = () => {
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.isActive
!user.is_active
? "Cannot reset password for inactive user"
: "Reset password"
}
disabled={!user.isActive}
disabled={!user.is_active}
>
<Key className="h-4 w-4" />
</button>
@@ -256,6 +256,8 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
username: '',
email: '',
password: '',
first_name: '',
last_name: '',
role: 'user'
})
const [isLoading, setIsLoading] = useState(false)
@@ -267,7 +269,12 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
setError('')
try {
const response = await adminUsersAPI.create(formData)
// 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
}
const response = await adminUsersAPI.create(payload)
onUserCreated()
} catch (err) {
setError(err.response?.data?.error || 'Failed to create user')
@@ -319,6 +326,33 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
First Name
</label>
<input
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 className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Last Name
</label>
<input
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 className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Password
@@ -345,7 +379,7 @@ const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
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 && 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('_', ' ')}
@@ -393,8 +427,10 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
const [formData, setFormData] = useState({
username: user?.username || '',
email: user?.email || '',
first_name: user?.first_name || '',
last_name: user?.last_name || '',
role: user?.role || 'user',
isActive: user?.isActive ?? true
is_active: user?.is_active ?? true
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
@@ -458,6 +494,33 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
First Name
</label>
<input
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 className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Last Name
</label>
<input
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 className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
Role
@@ -486,8 +549,8 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
<div className="flex items-center">
<input
type="checkbox"
name="isActive"
checked={formData.isActive}
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"
/>

View File

@@ -31,9 +31,17 @@ api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Handle unauthorized
localStorage.removeItem('token')
window.location.href = '/login'
// Don't redirect if we're on the login page or if it's a TFA verification error
const currentPath = window.location.pathname
const isTfaError = error.config?.url?.includes('/verify-tfa')
if (currentPath !== '/login' && !isTfaError) {
// Handle unauthorized
localStorage.removeItem('token')
localStorage.removeItem('user')
localStorage.removeItem('permissions')
window.location.href = '/login'
}
}
return Promise.reject(error)
}
@@ -45,6 +53,8 @@ export const dashboardAPI = {
getHosts: () => api.get('/dashboard/hosts'),
getPackages: () => api.get('/dashboard/packages'),
getHostDetail: (hostId) => api.get(`/dashboard/hosts/${hostId}`),
getRecentUsers: () => api.get('/dashboard/recent-users'),
getRecentCollection: () => api.get('/dashboard/recent-collection')
}
// Admin Hosts API (for management interface)
@@ -52,10 +62,12 @@ export const adminHostsAPI = {
create: (data) => api.post('/hosts/create', data),
list: () => api.get('/hosts/admin/list'),
delete: (hostId) => api.delete(`/hosts/${hostId}`),
deleteBulk: (hostIds) => api.delete('/hosts/bulk', { data: { hostIds } }),
regenerateCredentials: (hostId) => api.post(`/hosts/${hostId}/regenerate-credentials`),
updateGroup: (hostId, hostGroupId) => api.put(`/hosts/${hostId}/group`, { hostGroupId }),
bulkUpdateGroup: (hostIds, hostGroupId) => api.put('/hosts/bulk/group', { hostIds, hostGroupId }),
toggleAutoUpdate: (hostId, autoUpdate) => api.patch(`/hosts/${hostId}/auto-update`, { autoUpdate })
toggleAutoUpdate: (hostId, autoUpdate) => api.patch(`/hosts/${hostId}/auto-update`, { auto_update: autoUpdate }),
updateFriendlyName: (hostId, friendlyName) => api.patch(`/hosts/${hostId}/friendly-name`, { friendly_name: friendlyName })
}
// Host Groups API
@@ -147,7 +159,7 @@ export const hostsAPI = {
'X-API-KEY': apiKey
}
}),
toggleAutoUpdate: (id, autoUpdate) => api.patch(`/hosts/${id}/auto-update`, { autoUpdate })
toggleAutoUpdate: (id, autoUpdate) => api.patch(`/hosts/${id}/auto-update`, { auto_update: autoUpdate })
}
// Packages API
@@ -178,6 +190,30 @@ export const formatDate = (date) => {
return new Date(date).toLocaleString()
}
// Version API
export const versionAPI = {
getCurrent: () => api.get('/version/current'),
checkUpdates: () => api.get('/version/check-updates'),
testSshKey: (data) => api.post('/version/test-ssh-key', data),
}
// Auth API
export const authAPI = {
login: (username, password) => api.post('/auth/login', { username, password }),
verifyTfa: (username, token) => api.post('/auth/verify-tfa', { username, token }),
signup: (username, email, password, firstName, lastName) => api.post('/auth/signup', { username, email, password, firstName, lastName }),
}
// TFA API
export const tfaAPI = {
setup: () => api.get('/tfa/setup'),
verifySetup: (data) => api.post('/tfa/verify-setup', data),
disable: (data) => api.post('/tfa/disable', data),
status: () => api.get('/tfa/status'),
regenerateBackupCodes: () => api.post('/tfa/regenerate-backup-codes'),
verify: (data) => api.post('/tfa/verify', data),
}
export const formatRelativeTime = (date) => {
const now = new Date()
const diff = now - new Date(date)

View File

@@ -0,0 +1,130 @@
import {
Monitor,
Server,
HardDrive,
Cpu,
Zap,
Shield,
Globe,
Terminal
} from 'lucide-react';
// Import OS icons from react-icons
import {
SiUbuntu,
SiDebian,
SiCentos,
SiFedora,
SiArchlinux,
SiAlpinelinux,
SiLinux,
SiMacos
} from 'react-icons/si';
import {
DiUbuntu,
DiDebian,
DiLinux,
DiWindows
} from 'react-icons/di';
/**
* OS Icon mapping utility
* Maps operating system types to appropriate react-icons components
*/
export const getOSIcon = (osType) => {
if (!osType) return Monitor;
const os = osType.toLowerCase();
// Linux distributions with authentic react-icons
if (os.includes('ubuntu')) return SiUbuntu;
if (os.includes('debian')) return SiDebian;
if (os.includes('centos') || os.includes('rhel') || os.includes('red hat')) return SiCentos;
if (os.includes('fedora')) return SiFedora;
if (os.includes('arch')) return SiArchlinux;
if (os.includes('alpine')) return SiAlpinelinux;
if (os.includes('suse') || os.includes('opensuse')) return SiLinux; // SUSE uses generic Linux icon
// Generic Linux
if (os.includes('linux')) return SiLinux;
// Windows
if (os.includes('windows')) return DiWindows;
// macOS
if (os.includes('mac') || os.includes('darwin')) return SiMacos;
// FreeBSD
if (os.includes('freebsd')) return Server;
// Default fallback
return Monitor;
};
/**
* OS Color mapping utility
* Maps operating system types to appropriate colors (react-icons have built-in brand colors)
*/
export const getOSColor = (osType) => {
if (!osType) return 'text-gray-500';
// react-icons already have the proper brand colors built-in
// This function is kept for compatibility but returns neutral colors
return 'text-gray-600';
};
/**
* OS Display name utility
* Provides clean, formatted OS names for display
*/
export const getOSDisplayName = (osType) => {
if (!osType) return 'Unknown';
const os = osType.toLowerCase();
// Linux distributions
if (os.includes('ubuntu')) return 'Ubuntu';
if (os.includes('debian')) return 'Debian';
if (os.includes('centos')) return 'CentOS';
if (os.includes('rhel') || os.includes('red hat')) return 'Red Hat Enterprise Linux';
if (os.includes('fedora')) return 'Fedora';
if (os.includes('arch')) return 'Arch Linux';
if (os.includes('suse')) return 'SUSE Linux';
if (os.includes('opensuse')) return 'openSUSE';
if (os.includes('alpine')) return 'Alpine Linux';
// Generic Linux
if (os.includes('linux')) return 'Linux';
// Windows
if (os.includes('windows')) return 'Windows';
// macOS
if (os.includes('mac') || os.includes('darwin')) return 'macOS';
// FreeBSD
if (os.includes('freebsd')) return 'FreeBSD';
// Return original if no match
return osType;
};
/**
* OS Icon component with proper styling
*/
export const OSIcon = ({ osType, className = "h-4 w-4", showText = false }) => {
const IconComponent = getOSIcon(osType);
const displayName = getOSDisplayName(osType);
if (showText) {
return (
<div className="flex items-center gap-2">
<IconComponent className={className} title={displayName} />
<span className="text-sm">{displayName}</span>
</div>
);
}
return <IconComponent className={className} title={displayName} />;
};

340
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "patchmon",
"version": "1.0.0",
"version": "1.2.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "patchmon",
"version": "1.0.0",
"version": "1.2.6",
"workspaces": [
"backend",
"frontend"
@@ -20,7 +20,7 @@
},
"backend": {
"name": "patchmon-backend",
"version": "1.0.0",
"version": "1.2.6",
"dependencies": {
"@prisma/client": "^5.7.0",
"bcryptjs": "^2.4.3",
@@ -32,6 +32,8 @@
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"moment": "^2.30.1",
"qrcode": "^1.5.4",
"speakeasy": "^2.0.0",
"uuid": "^9.0.1",
"winston": "^3.11.0"
},
@@ -45,7 +47,7 @@
},
"frontend": {
"name": "patchmon-frontend",
"version": "1.0.0",
"version": "1.2.6",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -54,11 +56,15 @@
"axios": "^1.6.2",
"chart.js": "^4.4.0",
"clsx": "^2.0.0",
"cors": "^2.8.5",
"date-fns": "^2.30.0",
"express": "^4.18.2",
"http-proxy-middleware": "^2.0.6",
"lucide-react": "^0.294.0",
"react": "^18.2.0",
"react-chartjs-2": "^5.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.5.0",
"react-router-dom": "^6.20.1"
},
"devDependencies": {
@@ -1768,6 +1774,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/http-proxy": {
"version": "1.17.16",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.16.tgz",
"integrity": "sha512-sdWoUajOB1cd0A8cRRQ1cfyWNbmFKLAqBB89Y8x5iYyG/mkJHc0YUH8pdWBy2omi9qtCpiIgGjuwO0dQST2l5w==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "24.5.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.2.tgz",
"integrity": "sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.12.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -1887,7 +1911,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -1897,7 +1920,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -2182,6 +2204,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/base32.js": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/base32.js/-/base32.js-0.0.1.tgz",
"integrity": "sha512-EGHIRiegFa62/SsA1J+Xs2tIzludPdzM064N9wjbiEgHnGnJ1V0WEpA4pEwCYT5nDvZk3ubf0shqaCS7k6xeUQ==",
"license": "MIT"
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
@@ -2240,7 +2268,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -2355,6 +2382,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/camelcase-css": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
@@ -2491,7 +2527,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -2768,6 +2803,15 @@
"ms": "2.0.0"
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2846,6 +2890,12 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dlv": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
@@ -2925,7 +2975,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/enabled": {
@@ -3464,6 +3513,12 @@
"node": ">= 0.6"
}
},
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/express": {
"version": "4.21.2",
"resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz",
@@ -3609,7 +3664,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -3858,7 +3912,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -4122,6 +4175,44 @@
"node": ">= 0.8"
}
},
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-proxy-middleware": {
"version": "2.0.9",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz",
"integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==",
"license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.8",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.1",
"is-plain-obj": "^3.0.0",
"micromatch": "^4.0.2"
},
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"@types/express": "^4.17.13"
},
"peerDependenciesMeta": {
"@types/express": {
"optional": true
}
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -4378,7 +4469,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -4404,7 +4494,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -4433,7 +4522,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -4472,7 +4560,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -4505,6 +4592,18 @@
"node": ">=8"
}
},
"node_modules/is-plain-obj": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -5076,7 +5175,6 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -5523,6 +5621,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -5564,7 +5671,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5638,7 +5744,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -5667,6 +5772,15 @@
"node": ">= 6"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -5926,6 +6040,141 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/qrcode/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.13.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz",
@@ -6021,6 +6270,15 @@
"react": "^18.3.1"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
@@ -6155,12 +6413,23 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -6456,6 +6725,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -6670,6 +6945,18 @@
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
"dev": true
},
"node_modules/speakeasy": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/speakeasy/-/speakeasy-2.0.0.tgz",
"integrity": "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw==",
"license": "MIT",
"dependencies": {
"base32.js": "0.0.1"
},
"engines": {
"node": ">= 0.10.0"
}
},
"node_modules/stack-trace": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
@@ -6715,7 +7002,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -6844,7 +7130,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -7139,7 +7424,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -7342,6 +7626,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.12.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz",
"integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==",
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -7627,6 +7917,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.19",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon",
"version": "1.0.0",
"version": "1.2.6",
"description": "Linux Patch Monitoring System",
"private": true,
"workspaces": [

View File

@@ -17,7 +17,7 @@ async function setupAdminUser() {
console.log('=====================================\n');
// Check if any users exist
const existingUsers = await prisma.user.count();
const existingUsers = await prisma.users.count();
if (existingUsers > 0) {
console.log('⚠️ Users already exist in the database.');
const overwrite = await question('Do you want to create another admin user? (y/N): ');
@@ -47,7 +47,7 @@ async function setupAdminUser() {
}
// Check if username or email already exists
const existingUser = await prisma.user.findFirst({
const existingUser = await prisma.users.findFirst({
where: {
OR: [
{ username: username.trim() },
@@ -66,19 +66,23 @@ async function setupAdminUser() {
const passwordHash = await bcrypt.hash(password, 12);
// Create admin user
const user = await prisma.user.create({
const user = await prisma.users.create({
data: {
id: require('crypto').randomUUID(),
username: username.trim(),
email: email.trim(),
passwordHash: passwordHash,
role: 'admin'
password_hash: passwordHash,
role: 'admin',
is_active: true,
created_at: new Date(),
updated_at: new Date()
},
select: {
id: true,
username: true,
email: true,
role: true,
createdAt: true
created_at: true
}
});
@@ -87,7 +91,7 @@ async function setupAdminUser() {
console.log(` Username: ${user.username}`);
console.log(` Email: ${user.email}`);
console.log(` Role: ${user.role}`);
console.log(` Created: ${user.createdAt.toISOString()}`);
console.log(` Created: ${user.created_at.toISOString()}`);
console.log('\n🎉 Setup complete!');
console.log('\nNext steps:');

1583
setup.sh Normal file

File diff suppressed because it is too large Load Diff