Compare commits

...

77 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
83 changed files with 7410 additions and 5706 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 }}

2
.gitignore vendored
View File

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

367
README.md
View File

@@ -1,3 +1,366 @@
Join my discord for Instructions, support and feedback :
## Purpose
https://discord.gg/S7RXUHwg
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 v1.2.5
# 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.5"
AGENT_VERSION="1.2.6"
CONFIG_FILE="/etc/patchmon/agent.conf"
CREDENTIALS_FILE="/etc/patchmon/credentials"
LOG_FILE="/var/log/patchmon-agent.log"
@@ -1013,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"
@@ -1219,4 +1220,4 @@ main() {
}
# Run main function
main "$@"
main "$@"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

@@ -1 +0,0 @@

View File

@@ -1,67 +0,0 @@
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();
async function checkAgentVersion() {
try {
// Check current agent version in database
const agentVersion = await prisma.agentVersion.findFirst({
where: { version: '1.2.5' }
});
if (agentVersion) {
console.log('✅ Agent version 1.2.5 found in database');
console.log('Version:', agentVersion.version);
console.log('Is Default:', agentVersion.isDefault);
console.log('Script Content Length:', agentVersion.scriptContent?.length || 0);
console.log('Created At:', agentVersion.createdAt);
console.log('Updated At:', agentVersion.updatedAt);
// Check if script content contains the current version
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('AGENT_VERSION="1.2.5"')) {
console.log('✅ Script content contains correct version 1.2.5');
} else {
console.log('❌ Script content does not contain version 1.2.5');
}
// Check if script content contains system info functions
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('get_hardware_info()')) {
console.log('✅ Script content contains hardware info function');
} else {
console.log('❌ Script content missing hardware info function');
}
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('get_network_info()')) {
console.log('✅ Script content contains network info function');
} else {
console.log('❌ Script content missing network info function');
}
if (agentVersion.scriptContent && agentVersion.scriptContent.includes('get_system_info()')) {
console.log('✅ Script content contains system info function');
} else {
console.log('❌ Script content missing system info function');
}
} else {
console.log('❌ Agent version 1.2.5 not found in database');
}
// List all agent versions
console.log('\n=== All Agent Versions ===');
const allVersions = await prisma.agentVersion.findMany({
orderBy: { createdAt: 'desc' }
});
allVersions.forEach(version => {
console.log(`Version: ${version.version}, Default: ${version.isDefault}, Length: ${version.scriptContent?.length || 0}`);
});
} catch (error) {
console.error('❌ Error checking agent version:', error);
} finally {
await prisma.$disconnect();
}
}
checkAgentVersion();

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

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.2.5",
"version": "1.2.6",
"description": "Backend API for Linux Patch Monitoring System",
"main": "src/server.js",
"scripts": {

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,239 +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
// Two-Factor Authentication
tfaEnabled Boolean @default(false) @map("tfa_enabled")
tfaSecret String? @map("tfa_secret")
tfaBackupCodes String? @map("tfa_backup_codes") // JSON array of backup codes
// 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())
friendlyName String @unique @map("friendly_name")
hostname String? // Actual system hostname from agent
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
// Hardware Information
cpuModel String? @map("cpu_model") // CPU model name
cpuCores Int? @map("cpu_cores") // Number of CPU cores
ramInstalled Int? @map("ram_installed") // RAM in GB
swapSize Int? @map("swap_size") // Swap size in GB
diskDetails Json? @map("disk_details") // Array of disk objects
// Network Information
gatewayIp String? @map("gateway_ip") // Gateway IP address
dnsServers Json? @map("dns_servers") // Array of DNS servers
networkInterfaces Json? @map("network_interfaces") // Array of network interface objects
// System Information
kernelVersion String? @map("kernel_version") // Kernel version
selinuxStatus String? @map("selinux_status") // SELinux status (enabled/disabled/permissive)
systemUptime String? @map("system_uptime") // System uptime
loadAverage Json? @map("load_average") // Load average (1min, 5min, 15min)
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
repositoryType String @map("repository_type") @default("public") // "public" or "private"
sshKeyPath String? @map("ssh_key_path") // Optional SSH key path for deploy key authentication
lastUpdateCheck DateTime? @map("last_update_check") // When the system last checked for updates
updateAvailable Boolean @map("update_available") @default(false) // Whether an update is available
latestVersion String? @map("latest_version") // Latest available version
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

@@ -8,36 +8,36 @@ 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']
log: process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['warn', 'error'],
errorFormat: 'pretty'
});
@@ -54,6 +54,33 @@ async function checkDatabaseConnection(prisma) {
}
}
// 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++) {
@@ -75,6 +102,7 @@ async function disconnectPrisma(prisma, maxRetries = 3) {
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,21 +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,
passwordHash: true,
first_name: true,
last_name: true,
password_hash: true,
role: true,
tfaEnabled: true
is_active: true,
last_login: true,
created_at: true,
updated_at: true,
tfa_enabled: true
}
});
@@ -360,13 +598,13 @@ 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.tfaEnabled) {
if (user.tfa_enabled) {
return res.status(200).json({
message: 'TFA verification required',
requiresTfa: true,
@@ -375,9 +613,12 @@ router.post('/login', [
}
// 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
@@ -390,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) {
@@ -414,22 +659,22 @@ router.post('/verify-tfa', [
const { username, token } = req.body;
// Find user
const user = await prisma.user.findFirst({
const user = await prisma.users.findFirst({
where: {
OR: [
{ username },
{ email: username }
],
isActive: true,
tfaEnabled: true
is_active: true,
tfa_enabled: true
},
select: {
id: true,
username: true,
email: true,
role: true,
tfaSecret: true,
tfaBackupCodes: true
tfa_secret: true,
tfa_backup_codes: true
}
});
@@ -441,7 +686,7 @@ router.post('/verify-tfa', [
const speakeasy = require('speakeasy');
// Check if it's a backup code
const backupCodes = user.tfaBackupCodes ? JSON.parse(user.tfaBackupCodes) : [];
const backupCodes = user.tfa_backup_codes ? JSON.parse(user.tfa_backup_codes) : [];
const isBackupCode = backupCodes.includes(token);
let verified = false;
@@ -449,17 +694,17 @@ router.post('/verify-tfa', [
if (isBackupCode) {
// Remove the used backup code
const updatedBackupCodes = backupCodes.filter(code => code !== token);
await prisma.user.update({
await prisma.users.update({
where: { id: user.id },
data: {
tfaBackupCodes: JSON.stringify(updatedBackupCodes)
tfa_backup_codes: JSON.stringify(updatedBackupCodes)
}
});
verified = true;
} else {
// Verify TOTP token
verified = speakeasy.totp.verify({
secret: user.tfaSecret,
secret: user.tfa_secret,
encoding: 'base32',
token: token,
window: 2
@@ -471,9 +716,9 @@ router.post('/verify-tfa', [
}
// Update last login
await prisma.user.update({
await prisma.users.update({
where: { id: user.id },
data: { lastLogin: new Date() }
data: { last_login: new Date() }
});
// Generate token
@@ -486,6 +731,8 @@ router.post('/verify-tfa', [
id: user.id,
username: user.username,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
role: user.role
}
});
@@ -510,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);
@@ -518,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 } },
@@ -545,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
}
});
@@ -583,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' });
}
@@ -597,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,18 +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: 'offlineHosts', title: 'Offline/Stale Hosts', icon: 'WifiOff', enabled: false, order: 5 },
{ cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 6 },
{ cardId: 'osDistributionBar', title: 'OS Distribution (Bar)', icon: 'BarChart3', enabled: false, order: 7 },
{ cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 8 },
{ cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 9 },
{ cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 10 }
{ 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);
@@ -88,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
@@ -33,70 +34,79 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
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.host.count({
prisma.hosts.count({
where: {
status: 'active',
lastUpdate: {
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: {
@@ -107,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
@@ -136,10 +146,14 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
cards: {
totalHosts,
hostsNeedingUpdates,
upToDateHosts: Math.max(totalHosts - hostsNeedingUpdates, 0),
totalOutdatedPackages,
erroredHosts,
securityUpdates,
offlineHosts
offlineHosts,
totalHostGroups,
totalUsers,
totalRepos
},
charts: {
osDistribution: osDistributionFormatted,
@@ -158,20 +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,
friendlyName: 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,
@@ -180,41 +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.hostPackage.count({
const totalPackagesCount = await prisma.host_packages.count({
where: {
hostId: host.id
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
@@ -242,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
}
}
},
@@ -255,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,
friendlyName: true,
osType: true
friendly_name: true,
os_type: true
}
}
}
@@ -282,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,
friendlyName: hp.host.friendlyName,
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
}))
}));
@@ -307,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'
},
@@ -341,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
}
};
@@ -354,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,19 +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,
friendlyName: 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
}
}
}
@@ -79,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 }
});
@@ -87,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()
}
});
@@ -118,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 }
});
@@ -127,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 }
@@ -138,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()
}
});
@@ -160,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: {
@@ -182,7 +186,7 @@ router.delete('/:id', authenticateToken, requireManageHosts, async (req, res) =>
});
}
await prisma.hostGroup.delete({
await prisma.host_groups.delete({
where: { id }
});
@@ -198,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,
friendlyName: 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: {
friendlyName: '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,38 +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,
friendlyName: true,
friendly_name: true,
hostname: true,
osType: true
os_type: true
}
}
},
@@ -112,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
}
};
})
@@ -142,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: {
@@ -171,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,
friendlyName: 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,
friendlyName: hr.host.friendlyName,
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,
friendlyName: true
friendly_name: true
}
}
},
orderBy: {
repository: {
repositories: {
name: 'asc'
}
}
@@ -92,27 +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,
friendlyName: 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: {
friendlyName: 'asc'
hosts: {
friendly_name: 'asc'
}
}
}
@@ -146,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
}
}
}
@@ -183,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: {
friendlyName: true
friendly_name: true
}
}
}
});
res.json({
message: `Repository ${isEnabled ? 'enabled' : 'disabled'} for host ${hostRepository.host.friendlyName}`,
message: `Repository ${isEnabled ? 'enabled' : 'disabled'} for host ${hostRepository.hosts.friendly_name}`,
hostRepository
});
} catch (error) {
@@ -217,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({
@@ -257,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: {}
}
}
@@ -274,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,
friendlyName: 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.friendlyName}`);
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.friendlyName}`);
console.log(`Successfully triggered crontab update for ${host.friendly_name}`);
} else {
console.error(`Failed to trigger crontab update for ${host.friendlyName}: ${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.friendlyName}:`, 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.friendlyName}:`, 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,9 +108,10 @@ 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('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) => {
@@ -136,81 +125,43 @@ router.put('/', authenticateToken, requireManageSettings, [
})
], 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, repositoryType, sshKeyPath } = req.body;
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl, repositoryType, sshKeyPath });
console.log('GitHub repo URL received:', githubRepoUrl, 'Type:', typeof 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 !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: repositoryType || 'public'
});
console.log('Final githubRepoUrl value being saved:', githubRepoUrl !== undefined ? 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 !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: repositoryType || 'public',
sshKeyPath: sshKeyPath || null
}
});
console.log('Settings updated successfully:', settings);
// If update interval changed, trigger crontab updates on all hosts with auto-update enabled
if (oldUpdateInterval !== (updateInterval || 60)) {
console.log(`Update interval changed from ${oldUpdateInterval} to ${updateInterval || 60} minutes. Triggering crontab updates...`);
await triggerCrontabUpdates();
}
} else {
// Create new settings
settings = await prisma.settings.create({
data: {
serverUrl,
serverProtocol,
serverHost,
serverPort,
frontendUrl,
updateInterval: updateInterval || 60,
autoUpdate: autoUpdate || false,
githubRepoUrl: githubRepoUrl !== undefined ? githubRepoUrl : 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: repositoryType || 'public',
sshKeyPath: sshKeyPath || null
}
});
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);
@@ -221,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);
@@ -256,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

@@ -14,12 +14,12 @@ router.get('/setup', authenticateToken, async (req, res) => {
const userId = req.user.id;
// Check if user already has TFA enabled
const user = await prisma.user.findUnique({
const user = await prisma.users.findUnique({
where: { id: userId },
select: { tfaEnabled: true, tfaSecret: true }
select: { tfa_enabled: true, tfa_secret: true }
});
if (user.tfaEnabled) {
if (user.tfa_enabled) {
return res.status(400).json({
error: 'Two-factor authentication is already enabled for this account'
});
@@ -36,9 +36,9 @@ router.get('/setup', authenticateToken, async (req, res) => {
const qrCodeUrl = await QRCode.toDataURL(secret.otpauth_url);
// Store the secret temporarily (not enabled yet)
await prisma.user.update({
await prisma.users.update({
where: { id: userId },
data: { tfaSecret: secret.base32 }
data: { tfa_secret: secret.base32 }
});
res.json({
@@ -67,18 +67,18 @@ router.post('/verify-setup', authenticateToken, [
const userId = req.user.id;
// Get user's TFA secret
const user = await prisma.user.findUnique({
const user = await prisma.users.findUnique({
where: { id: userId },
select: { tfaSecret: true, tfaEnabled: true }
select: { tfa_secret: true, tfa_enabled: true }
});
if (!user.tfaSecret) {
if (!user.tfa_secret) {
return res.status(400).json({
error: 'No TFA secret found. Please start the setup process first.'
});
}
if (user.tfaEnabled) {
if (user.tfa_enabled) {
return res.status(400).json({
error: 'Two-factor authentication is already enabled for this account'
});
@@ -86,7 +86,7 @@ router.post('/verify-setup', authenticateToken, [
// Verify the token
const verified = speakeasy.totp.verify({
secret: user.tfaSecret,
secret: user.tfa_secret,
encoding: 'base32',
token: token,
window: 2 // Allow 2 time windows (60 seconds) for clock drift
@@ -104,11 +104,11 @@ router.post('/verify-setup', authenticateToken, [
);
// Enable TFA and store backup codes
await prisma.user.update({
await prisma.users.update({
where: { id: userId },
data: {
tfaEnabled: true,
tfaBackupCodes: JSON.stringify(backupCodes)
tfa_enabled: true,
tfa_backup_codes: JSON.stringify(backupCodes)
}
});
@@ -136,12 +136,12 @@ router.post('/disable', authenticateToken, [
const userId = req.user.id;
// Verify password
const user = await prisma.user.findUnique({
const user = await prisma.users.findUnique({
where: { id: userId },
select: { passwordHash: true, tfaEnabled: true }
select: { password_hash: true, tfa_enabled: true }
});
if (!user.tfaEnabled) {
if (!user.tfa_enabled) {
return res.status(400).json({
error: 'Two-factor authentication is not enabled for this account'
});
@@ -151,12 +151,12 @@ router.post('/disable', authenticateToken, [
// For now, we'll skip password verification for simplicity
// Disable TFA
await prisma.user.update({
where: { id: userId },
await prisma.users.update({
where: { id: id },
data: {
tfaEnabled: false,
tfaSecret: null,
tfaBackupCodes: null
tfa_enabled: false,
tfa_secret: null,
tfa_backup_codes: null
}
});
@@ -174,18 +174,18 @@ router.get('/status', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
const user = await prisma.user.findUnique({
const user = await prisma.users.findUnique({
where: { id: userId },
select: {
tfaEnabled: true,
tfaSecret: true,
tfaBackupCodes: true
tfa_enabled: true,
tfa_secret: true,
tfa_backup_codes: true
}
});
res.json({
enabled: user.tfaEnabled,
hasBackupCodes: !!user.tfaBackupCodes
enabled: user.tfa_enabled,
hasBackupCodes: !!user.tfa_backup_codes
});
} catch (error) {
console.error('TFA status error:', error);
@@ -199,12 +199,12 @@ router.post('/regenerate-backup-codes', authenticateToken, async (req, res) => {
const userId = req.user.id;
// Check if TFA is enabled
const user = await prisma.user.findUnique({
const user = await prisma.users.findUnique({
where: { id: userId },
select: { tfaEnabled: true }
select: { tfa_enabled: true }
});
if (!user.tfaEnabled) {
if (!user.tfa_enabled) {
return res.status(400).json({
error: 'Two-factor authentication is not enabled for this account'
});
@@ -216,10 +216,10 @@ router.post('/regenerate-backup-codes', authenticateToken, async (req, res) => {
);
// Update backup codes
await prisma.user.update({
await prisma.users.update({
where: { id: userId },
data: {
tfaBackupCodes: JSON.stringify(backupCodes)
tfa_backup_codes: JSON.stringify(backupCodes)
}
});
@@ -248,24 +248,24 @@ router.post('/verify', [
const { username, token } = req.body;
// Get user's TFA secret
const user = await prisma.user.findUnique({
const user = await prisma.users.findUnique({
where: { username },
select: {
id: true,
tfaEnabled: true,
tfaSecret: true,
tfaBackupCodes: true
tfa_enabled: true,
tfa_secret: true,
tfa_backup_codes: true
}
});
if (!user || !user.tfaEnabled || !user.tfaSecret) {
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.tfaBackupCodes ? JSON.parse(user.tfaBackupCodes) : [];
const backupCodes = user.tfa_backup_codes ? JSON.parse(user.tfa_backup_codes) : [];
const isBackupCode = backupCodes.includes(token);
let verified = false;
@@ -273,17 +273,17 @@ router.post('/verify', [
if (isBackupCode) {
// Remove the used backup code
const updatedBackupCodes = backupCodes.filter(code => code !== token);
await prisma.user.update({
await prisma.users.update({
where: { id: user.id },
data: {
tfaBackupCodes: JSON.stringify(updatedBackupCodes)
tfa_backup_codes: JSON.stringify(updatedBackupCodes)
}
});
verified = true;
} else {
// Verify TOTP token
verified = speakeasy.totp.verify({
secret: user.tfaSecret,
secret: user.tfa_secret,
encoding: 'base32',
token: token,
window: 2

View File

@@ -13,8 +13,17 @@ const router = express.Router();
// Get current version info
router.get('/current', authenticateToken, async (req, res) => {
try {
// For now, return hardcoded version - this should match your agent version
const currentVersion = '1.2.5';
// 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,
@@ -149,22 +158,22 @@ router.get('/check-updates', authenticateToken, requireManageSettings, async (re
return res.status(400).json({ error: 'Settings not found' });
}
const currentVersion = '1.2.5';
const latestVersion = settings.latestVersion || currentVersion;
const isUpdateAvailable = settings.updateAvailable || false;
const lastUpdateCheck = settings.lastUpdateCheck;
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.repositoryType || 'public',
repositoryType: settings.repository_type || 'public',
latestRelease: {
tagName: latestVersion ? `v${latestVersion}` : null,
version: latestVersion,
repository: settings.githubRepoUrl ? settings.githubRepoUrl.split('/').slice(-2).join('/') : null,
accessMethod: settings.repositoryType === 'private' ? 'ssh' : 'api'
repository: settings.github_repo_url ? settings.github_repo_url.split('/').slice(-2).join('/') : null,
accessMethod: settings.repository_type === 'private' ? 'ssh' : 'api'
}
});

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 { createPrismaClient, checkDatabaseConnection, disconnectPrisma } = require('./config/database');
const { createPrismaClient, waitForDatabase, disconnectPrisma } = require('./config/database');
const winston = require('winston');
// Import routes
@@ -14,15 +14,285 @@ 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 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({
level: process.env.LOG_LEVEL || 'info',
@@ -31,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: () => {},
@@ -42,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();
@@ -53,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
@@ -89,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);
@@ -104,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();
});
}
@@ -118,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);
@@ -145,9 +480,9 @@ 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
});
});
@@ -175,26 +510,240 @@ process.on('SIGTERM', async () => {
process.exit(0);
});
// 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 {
// Check database connection before starting server
const isConnected = await checkDatabaseConnection(prisma);
if (!isConnected) {
console.error('❌ Database connection failed. Server not started.');
process.exit(1);
}
// 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();
});
@@ -206,4 +755,4 @@ async function startServer() {
startServer();
module.exports = app;
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

@@ -100,7 +100,16 @@ class UpdateScheduler {
return;
}
const currentVersion = '1.2.5';
// 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
@@ -193,11 +202,22 @@ class UpdateScheduler {
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/1.2.4'
'User-Agent': `PatchMon-Server/${currentVersion}`
}
});

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

View File

@@ -1 +0,0 @@

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.2.5",
"version": "1.2.6",
"type": "module",
"scripts": {
"dev": "vite",
@@ -20,6 +20,7 @@
"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",
@@ -41,6 +42,6 @@
"vite": "^7.1.5"
},
"overrides": {
"esbuild": "^0.24.4"
"esbuild": "^0.25.10"
}
}

View File

@@ -2,12 +2,14 @@ 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({
@@ -15,6 +17,20 @@ app.use(cors({
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')));

View File

@@ -1,6 +1,6 @@
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'
@@ -18,79 +18,97 @@ 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>
<UpdateNotificationProvider>
<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="/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="canManageHosts">
<ProtectedRoute requirePermission="can_manage_hosts">
<Layout>
<Options />
</Layout>
@@ -104,13 +122,22 @@ 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

@@ -1,4 +1,4 @@
import React, { useState, useRef, useEffect } from 'react';
import React, { useState, useRef, useEffect, useMemo } from 'react';
import { Edit2, Check, X, ChevronDown } from 'lucide-react';
const InlineGroupEdit = ({
@@ -88,11 +88,8 @@ const InlineGroupEdit = ({
const handleSave = async () => {
if (disabled || isLoading) return;
console.log('handleSave called:', { selectedValue, originalValue: value, changed: selectedValue !== value });
// Check if value actually changed
if (selectedValue === value) {
console.log('No change detected, closing edit mode');
setIsEditing(false);
setIsOpen(false);
return;
@@ -102,15 +99,12 @@ const InlineGroupEdit = ({
setError('');
try {
console.log('Calling onSave with:', selectedValue);
await onSave(selectedValue);
console.log('Save successful');
// Update the local value to match the saved value
setSelectedValue(selectedValue);
setIsEditing(false);
setIsOpen(false);
} catch (err) {
console.error('Save failed:', err);
setError(err.message || 'Failed to save');
} finally {
setIsLoading(false);
@@ -127,22 +121,23 @@ const InlineGroupEdit = ({
}
};
const getDisplayValue = () => {
console.log('getDisplayValue called with:', { value, options });
const displayValue = useMemo(() => {
if (!value) {
console.log('No value, returning Ungrouped');
return 'Ungrouped';
}
const option = options.find(opt => opt.id === value);
console.log('Found option:', option);
return option ? option.name : 'Unknown Group';
};
}, [value, options]);
const getDisplayColor = () => {
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 (
@@ -241,10 +236,10 @@ const InlineGroupEdit = ({
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 ${getDisplayColor()}`}
style={value ? { backgroundColor: options.find(opt => opt.id === value)?.color } : {}}
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${displayColor}`}
style={value ? { backgroundColor: selectedOption?.color } : {}}
>
{getDisplayValue()}
{displayValue}
</span>
{!disabled && (
<button

View File

@@ -23,7 +23,12 @@ import {
Plus,
Activity,
Cog,
FileText
FileText,
Github,
MessageCircle,
Mail,
Star,
Globe
} from 'lucide-react'
import { useState, useEffect, useRef } from 'react'
import { useQuery } from '@tanstack/react-query'
@@ -40,17 +45,18 @@ 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
})
// Fetch version info
@@ -60,44 +66,103 @@ const Layout = ({ children }) => {
staleTime: 300000, // Consider data stale after 5 minutes
})
const navigation = [
{ name: 'Dashboard', href: '/', icon: Home },
{
section: 'Inventory',
items: [
...(canViewHosts() ? [{ name: 'Hosts', href: '/hosts', icon: Server }] : []),
...(canViewPackages() ? [{ name: 'Packages', href: '/packages', icon: Package }] : []),
...(canViewHosts() ? [{ name: 'Repos', href: '/repositories', icon: GitBranch }] : []),
{ name: 'Services', href: '/services', icon: Activity, comingSoon: true },
{ name: 'Docker', href: '/docker', icon: Container, 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: [
...(canManageHosts() ? [{
// 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
}] : []),
{ name: 'Audit Log', href: '/audit-log', icon: FileText, comingSoon: true },
...(canManageSettings() ? [{
})
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
@@ -133,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)
@@ -167,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 */}
@@ -189,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)
@@ -314,6 +414,15 @@ const Layout = ({ children }) => {
</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)
@@ -425,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 ? (
@@ -451,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">
@@ -477,10 +587,11 @@ const Layout = ({ children }) => {
<span className="truncate">Updated: {formatRelativeTimeShort(stats.lastUpdated)}</span>
<button
onClick={() => refetch()}
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded flex-shrink-0"
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">
@@ -516,10 +627,11 @@ const Layout = ({ children }) => {
<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">
@@ -558,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

@@ -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

@@ -1,6 +1,7 @@
import React, { createContext, useContext, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { versionAPI } from '../utils/api'
import { versionAPI, settingsAPI } from '../utils/api'
import { useAuth } from './AuthContext'
const UpdateNotificationContext = createContext()
@@ -14,13 +15,24 @@ export const useUpdateNotification = () => {
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),
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
retry: 1
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

View File

@@ -9,13 +9,18 @@ import {
TrendingUp,
RefreshCw,
Clock,
WifiOff
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)
@@ -25,6 +30,7 @@ const Dashboard = () => {
const [cardPreferences, setCardPreferences] = useState([])
const navigate = useNavigate()
const { isDark } = useTheme()
const { user } = useAuth()
// Navigation handlers
const handleTotalHostsClick = () => {
@@ -51,18 +57,75 @@ const Dashboard = () => {
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', { replace: true })
navigate('/hosts?showFilters=true', { replace: true })
}
const handleUpdateStatusClick = () => {
navigate('/hosts', { replace: true })
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'
@@ -89,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
@@ -115,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])
@@ -154,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', 'osDistributionBar', 'updateStatus', 'packagePriority'].includes(cardId)) {
} else if (['osDistribution', 'osDistributionBar', 'updateStatus', 'packagePriority', 'recentUsers', 'recentCollection'].includes(cardId)) {
return 'charts';
} else if (['erroredHosts', 'quickStats'].includes(cardId)) {
return 'fullwidth';
@@ -181,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
@@ -260,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 (
@@ -373,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>
);
@@ -386,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>
@@ -469,6 +701,39 @@ 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 = {
@@ -579,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 */}
{(() => {

View File

@@ -46,15 +46,25 @@ const HostDetail = () => {
const [isEditingFriendlyName, setIsEditingFriendlyName] = useState(false)
const [editedFriendlyName, setEditedFriendlyName] = useState('')
const [showAllUpdates, setShowAllUpdates] = useState(false)
const [activeTab, setActiveTab] = useState('host')
const [activeTab, setActiveTab] = useState(() => {
// Restore tab state from localStorage
const savedTab = localStorage.getItem(`host-detail-tab-${hostId}`)
return savedTab || 'host'
})
const { data: host, isLoading, error, refetch } = useQuery({
const { data: host, isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['host', hostId],
queryFn: () => dashboardAPI.getHostDetail(hostId).then(res => res.data),
refetchInterval: 60000,
staleTime: 30000,
staleTime: 5 * 60 * 1000, // 5 minutes - data stays fresh longer
refetchOnWindowFocus: false, // Don't refetch when window regains focus
})
// Save tab state to localStorage when it changes
const handleTabChange = (tabName) => {
setActiveTab(tabName)
localStorage.setItem(`host-detail-tab-${hostId}`, tabName)
}
// Auto-show credentials modal for new/pending hosts
React.useEffect(() => {
if (host && host.status === 'pending') {
@@ -72,7 +82,7 @@ const HostDetail = () => {
// Toggle auto-update mutation
const toggleAutoUpdateMutation = useMutation({
mutationFn: (autoUpdate) => adminHostsAPI.toggleAutoUpdate(hostId, autoUpdate).then(res => res.data),
mutationFn: (auto_update) => adminHostsAPI.toggleAutoUpdate(hostId, auto_update).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries(['host', hostId])
queryClient.invalidateQueries(['hosts'])
@@ -88,7 +98,7 @@ const HostDetail = () => {
})
const handleDeleteHost = async () => {
if (window.confirm(`Are you sure you want to delete host "${host.friendlyName}"? This action cannot be undone.`)) {
if (window.confirm(`Are you sure you want to delete host "${host.friendly_name}"? This action cannot be undone.`)) {
try {
await deleteHostMutation.mutateAsync(hostId)
} catch (error) {
@@ -178,7 +188,7 @@ const HostDetail = () => {
return 'Up to Date'
}
const isStale = new Date() - new Date(host.lastUpdate) > 24 * 60 * 60 * 1000
const isStale = new Date() - new Date(host.last_update) > 24 * 60 * 60 * 1000
return (
<div className="h-screen flex flex-col">
@@ -188,25 +198,34 @@ const HostDetail = () => {
<Link to="/hosts" className="text-secondary-500 hover:text-secondary-700 dark:text-secondary-400 dark:hover:text-secondary-200">
<ArrowLeft className="h-5 w-5" />
</Link>
<h1 className="text-xl font-semibold text-secondary-900 dark:text-white">{host.friendlyName}</h1>
{host.systemUptime && (
<h1 className="text-xl font-semibold text-secondary-900 dark:text-white">{host.friendly_name}</h1>
{host.system_uptime && (
<div className="flex items-center gap-1 text-sm text-secondary-600 dark:text-secondary-400">
<Clock className="h-4 w-4" />
<span className="text-xs font-medium">Uptime:</span>
<span>{host.systemUptime}</span>
<span>{host.system_uptime}</span>
</div>
)}
<div className="flex items-center gap-1 text-sm text-secondary-600 dark:text-secondary-400">
<Clock className="h-4 w-4" />
<span className="text-xs font-medium">Last updated:</span>
<span>{formatRelativeTime(host.lastUpdate)}</span>
<span>{formatRelativeTime(host.last_update)}</span>
</div>
<div className={`flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(isStale, host.stats.outdatedPackages > 0)}`}>
{getStatusIcon(isStale, host.stats.outdatedPackages > 0)}
{getStatusText(isStale, host.stats.outdatedPackages > 0)}
<div className={`flex items-center gap-2 px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(isStale, host.stats.outdated_packages > 0)}`}>
{getStatusIcon(isStale, host.stats.outdated_packages > 0)}
{getStatusText(isStale, host.stats.outdated_packages > 0)}
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center gap-2 text-sm"
title="Refresh host data"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Refreshing...' : 'Refresh'}
</button>
<button
onClick={() => setShowCredentialsModal(true)}
className="btn-outline flex items-center gap-2 text-sm"
@@ -232,7 +251,7 @@ const HostDetail = () => {
<div className="card">
<div className="flex border-b border-secondary-200 dark:border-secondary-600">
<button
onClick={() => setActiveTab('host')}
onClick={() => handleTabChange('host')}
className={`px-4 py-2 text-sm font-medium ${
activeTab === 'host'
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
@@ -242,17 +261,7 @@ const HostDetail = () => {
Host Info
</button>
<button
onClick={() => setActiveTab('hardware')}
className={`px-4 py-2 text-sm font-medium ${
activeTab === 'hardware'
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
: 'text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300'
}`}
>
Hardware
</button>
<button
onClick={() => setActiveTab('network')}
onClick={() => handleTabChange('network')}
className={`px-4 py-2 text-sm font-medium ${
activeTab === 'network'
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
@@ -262,7 +271,7 @@ const HostDetail = () => {
Network
</button>
<button
onClick={() => setActiveTab('system')}
onClick={() => handleTabChange('system')}
className={`px-4 py-2 text-sm font-medium ${
activeTab === 'system'
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
@@ -272,17 +281,17 @@ const HostDetail = () => {
System
</button>
<button
onClick={() => setActiveTab('monitoring')}
onClick={() => handleTabChange('monitoring')}
className={`px-4 py-2 text-sm font-medium ${
activeTab === 'monitoring'
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
: 'text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300'
}`}
>
Resource Monitor
Resource
</button>
<button
onClick={() => setActiveTab('history')}
onClick={() => handleTabChange('history')}
className={`px-4 py-2 text-sm font-medium ${
activeTab === 'history'
? 'text-primary-600 dark:text-primary-400 border-b-2 border-primary-500'
@@ -301,7 +310,7 @@ const HostDetail = () => {
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300 mb-1">Friendly Name</p>
<InlineEdit
value={host.friendlyName}
value={host.friendly_name}
onSave={(newName) => updateFriendlyNameMutation.mutate(newName)}
placeholder="Enter friendly name..."
maxLength={100}
@@ -324,12 +333,12 @@ const HostDetail = () => {
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Host Group</p>
{host.hostGroup ? (
{host.host_groups ? (
<span
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium text-white"
style={{ backgroundColor: host.hostGroup.color }}
style={{ backgroundColor: host.host_groups.color }}
>
{host.hostGroup.name}
{host.host_groups.name}
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-200">
@@ -341,44 +350,32 @@ const HostDetail = () => {
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Operating System</p>
<div className="flex items-center gap-2">
<OSIcon osType={host.osType} className="h-4 w-4" />
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.osType} {host.osVersion}</p>
<OSIcon osType={host.os_type} className="h-4 w-4" />
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.os_type} {host.os_version}</p>
</div>
</div>
{host.ip && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">IP Address</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.ip}</p>
</div>
)}
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Last Update</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{formatRelativeTime(host.lastUpdate)}</p>
</div>
{host.agentVersion && (
{host.agent_version && (
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Agent Version</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.agentVersion}</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.agent_version}</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-secondary-500 dark:text-secondary-300">Auto-update</span>
<button
onClick={() => toggleAutoUpdateMutation.mutate(!host.autoUpdate)}
onClick={() => toggleAutoUpdateMutation.mutate(!host.auto_update)}
disabled={toggleAutoUpdateMutation.isPending}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
host.autoUpdate
host.auto_update
? 'bg-primary-600 dark:bg-primary-500'
: 'bg-secondary-200 dark:bg-secondary-600'
}`}
>
<span
className={`inline-block h-3 w-3 transform rounded-full bg-white transition-transform ${
host.autoUpdate ? 'translate-x-5' : 'translate-x-1'
host.auto_update ? 'translate-x-5' : 'translate-x-1'
}`}
/>
</button>
@@ -389,88 +386,34 @@ const HostDetail = () => {
</div>
)}
{/* Hardware Information */}
{activeTab === 'hardware' && (host.cpuModel || host.ramInstalled || host.diskDetails) && (
<div className="space-y-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{host.cpuModel && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">CPU Model</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.cpuModel}</p>
</div>
)}
{host.cpuCores && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">CPU Cores</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.cpuCores}</p>
</div>
)}
{host.ramInstalled && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">RAM Installed</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.ramInstalled} GB</p>
</div>
)}
{host.swapSize !== undefined && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Swap Size</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.swapSize} GB</p>
</div>
)}
</div>
{host.diskDetails && Array.isArray(host.diskDetails) && host.diskDetails.length > 0 && (
<div className="pt-4 border-t border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">Disk Details</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{host.diskDetails.map((disk, index) => (
<div key={index} className="bg-secondary-50 dark:bg-secondary-700 p-3 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<HardDrive className="h-4 w-4 text-secondary-500" />
<span className="font-medium text-secondary-900 dark:text-white text-sm">{disk.name}</span>
</div>
<p className="text-xs text-secondary-600 dark:text-secondary-300">Size: {disk.size}</p>
{disk.mountpoint && (
<p className="text-xs text-secondary-600 dark:text-secondary-300">Mount: {disk.mountpoint}</p>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
{/* Network Information */}
{activeTab === 'network' && (host.gatewayIp || host.dnsServers || host.networkInterfaces) && (
{activeTab === 'network' && (host.gateway_ip || host.dns_servers || host.network_interfaces) && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{host.gatewayIp && (
{host.gateway_ip && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Gateway IP</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">{host.gatewayIp}</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">{host.gateway_ip}</p>
</div>
)}
{host.dnsServers && Array.isArray(host.dnsServers) && host.dnsServers.length > 0 && (
{host.dns_servers && Array.isArray(host.dns_servers) && host.dns_servers.length > 0 && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">DNS Servers</p>
<div className="space-y-1">
{host.dnsServers.map((dns, index) => (
{host.dns_servers.map((dns, index) => (
<p key={index} className="font-medium text-secondary-900 dark:text-white font-mono text-sm">{dns}</p>
))}
</div>
</div>
)}
{host.networkInterfaces && Array.isArray(host.networkInterfaces) && host.networkInterfaces.length > 0 && (
{host.network_interfaces && Array.isArray(host.network_interfaces) && host.network_interfaces.length > 0 && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Network Interfaces</p>
<div className="space-y-1">
{host.networkInterfaces.map((iface, index) => (
{host.network_interfaces.map((iface, index) => (
<p key={index} className="font-medium text-secondary-900 dark:text-white text-sm">{iface.name}</p>
))}
</div>
@@ -481,7 +424,7 @@ const HostDetail = () => {
)}
{/* System Information */}
{activeTab === 'system' && (host.kernelVersion || host.selinuxStatus || host.architecture) && (
{activeTab === 'system' && (host.kernel_version || host.selinux_status || host.architecture) && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{host.architecture && (
@@ -491,24 +434,24 @@ const HostDetail = () => {
</div>
)}
{host.kernelVersion && (
{host.kernel_version && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Kernel Version</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">{host.kernelVersion}</p>
<p className="font-medium text-secondary-900 dark:text-white font-mono text-sm">{host.kernel_version}</p>
</div>
)}
{host.selinuxStatus && (
{host.selinux_status && (
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">SELinux Status</p>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
host.selinuxStatus === 'enabled'
host.selinux_status === 'enabled'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: host.selinuxStatus === 'permissive'
: host.selinux_status === 'permissive'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
}`}>
{host.selinuxStatus}
{host.selinux_status}
</span>
</div>
)}
@@ -518,22 +461,15 @@ const HostDetail = () => {
</div>
)}
{/* Empty state for tabs with no data */}
{activeTab === 'hardware' && !(host.cpuModel || host.ramInstalled || host.diskDetails) && (
<div className="text-center py-8">
<Cpu className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">No hardware information available</p>
</div>
)}
{activeTab === 'network' && !(host.gatewayIp || host.dnsServers || host.networkInterfaces) && (
{activeTab === 'network' && !(host.gateway_ip || host.dns_servers || host.network_interfaces) && (
<div className="text-center py-8">
<Wifi className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">No network information available</p>
</div>
)}
{activeTab === 'system' && !(host.kernelVersion || host.selinuxStatus || host.architecture) && (
{activeTab === 'system' && !(host.kernel_version || host.selinux_status || host.architecture) && (
<div className="text-center py-8">
<Terminal className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">No system information available</p>
@@ -541,35 +477,143 @@ const HostDetail = () => {
)}
{/* System Monitoring */}
{activeTab === 'monitoring' && host.loadAverage && Array.isArray(host.loadAverage) && host.loadAverage.length > 0 && (
<div className="space-y-4">
{activeTab === 'monitoring' && (
<div className="space-y-6">
{/* System Overview */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<p className="text-xs text-secondary-500 dark:text-secondary-300">Load Average</p>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.loadAverage.map((load, index) => (
<span key={index}>
{load.toFixed(2)}
{index < host.loadAverage.length - 1 && ', '}
</span>
{/* System Uptime */}
{host.system_uptime && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">System Uptime</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.system_uptime}</p>
</div>
)}
{/* CPU Model */}
{host.cpu_model && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Cpu className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">CPU Model</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.cpu_model}</p>
</div>
)}
{/* CPU Cores */}
{host.cpu_cores && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Cpu className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">CPU Cores</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.cpu_cores}</p>
</div>
)}
{/* RAM Installed */}
{host.ram_installed && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<MemoryStick className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">RAM Installed</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.ram_installed} GB</p>
</div>
)}
{/* Swap Size */}
{host.swap_size !== undefined && host.swap_size !== null && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<MemoryStick className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">Swap Size</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">{host.swap_size} GB</p>
</div>
)}
{/* Load Average */}
{host.load_average && Array.isArray(host.load_average) && host.load_average.length > 0 && host.load_average.some(load => load != null) && (
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<Activity className="h-4 w-4 text-primary-600 dark:text-primary-400" />
<p className="text-xs text-secondary-500 dark:text-secondary-300">Load Average</p>
</div>
<p className="font-medium text-secondary-900 dark:text-white text-sm">
{host.load_average.filter(load => load != null).map((load, index) => (
<span key={index}>
{typeof load === 'number' ? load.toFixed(2) : String(load)}
{index < host.load_average.filter(load => load != null).length - 1 && ', '}
</span>
))}
</p>
</div>
)}
</div>
{/* Disk Information */}
{host.disk_details && Array.isArray(host.disk_details) && host.disk_details.length > 0 && (
<div className="pt-4 border-t border-secondary-200 dark:border-secondary-600">
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3 flex items-center gap-2">
<HardDrive className="h-4 w-4 text-primary-600 dark:text-primary-400" />
Disk Usage
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{host.disk_details.map((disk, index) => (
<div key={index} className="bg-secondary-50 dark:bg-secondary-700 p-3 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<HardDrive className="h-4 w-4 text-secondary-500" />
<span className="font-medium text-secondary-900 dark:text-white text-sm">{disk.name || `Disk ${index + 1}`}</span>
</div>
{disk.size && (
<p className="text-xs text-secondary-600 dark:text-secondary-300 mb-1">Size: {disk.size}</p>
)}
{disk.mountpoint && (
<p className="text-xs text-secondary-600 dark:text-secondary-300 mb-1">Mount: {disk.mountpoint}</p>
)}
{disk.usage && typeof disk.usage === 'number' && (
<div className="mt-2">
<div className="flex justify-between text-xs text-secondary-600 dark:text-secondary-300 mb-1">
<span>Usage</span>
<span>{disk.usage}%</span>
</div>
<div className="w-full bg-secondary-200 dark:bg-secondary-600 rounded-full h-2">
<div
className="bg-primary-600 dark:bg-primary-400 h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.min(Math.max(disk.usage, 0), 100)}%` }}
></div>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* No Data State */}
{!host.system_uptime && !host.cpu_model && !host.cpu_cores && !host.ram_installed && host.swap_size === undefined &&
(!host.load_average || !Array.isArray(host.load_average) || host.load_average.length === 0 || !host.load_average.some(load => load != null)) &&
(!host.disk_details || !Array.isArray(host.disk_details) || host.disk_details.length === 0) && (
<div className="text-center py-8">
<Monitor className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">No monitoring data available</p>
<p className="text-xs text-secondary-400 dark:text-secondary-400 mt-1">
Monitoring data will appear once the agent collects system information
</p>
</div>
</div>
</div>
)}
{activeTab === 'monitoring' && (!host.loadAverage || !Array.isArray(host.loadAverage) || host.loadAverage.length === 0) && (
<div className="text-center py-8">
<Monitor className="h-8 w-8 text-secondary-400 mx-auto mb-2" />
<p className="text-sm text-secondary-500 dark:text-secondary-300">No monitoring data available</p>
)}
</div>
)}
{/* Update History */}
{activeTab === 'history' && (
<div className="overflow-x-auto">
{host.updateHistory?.length > 0 ? (
{host.update_history?.length > 0 ? (
<>
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
<thead className="bg-secondary-50 dark:bg-secondary-700">
@@ -589,7 +633,7 @@ const HostDetail = () => {
</tr>
</thead>
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
{(showAllUpdates ? host.updateHistory : host.updateHistory.slice(0, 5)).map((update, index) => (
{(showAllUpdates ? host.update_history : host.update_history.slice(0, 5)).map((update, index) => (
<tr key={update.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700">
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex items-center gap-1.5">
@@ -607,14 +651,14 @@ const HostDetail = () => {
{formatDate(update.timestamp)}
</td>
<td className="px-4 py-2 whitespace-nowrap text-xs text-secondary-900 dark:text-white">
{update.packagesCount}
{update.packages_count}
</td>
<td className="px-4 py-2 whitespace-nowrap">
{update.securityCount > 0 ? (
{update.security_count > 0 ? (
<div className="flex items-center gap-1">
<Shield className="h-3 w-3 text-danger-600" />
<span className="text-xs text-danger-600 font-medium">
{update.securityCount}
{update.security_count}
</span>
</div>
) : (
@@ -626,7 +670,7 @@ const HostDetail = () => {
</tbody>
</table>
{host.updateHistory.length > 5 && (
{host.update_history.length > 5 && (
<div className="px-4 py-2 border-t border-secondary-200 dark:border-secondary-600 bg-secondary-50 dark:bg-secondary-700">
<button
onClick={() => setShowAllUpdates(!showAllUpdates)}
@@ -640,7 +684,7 @@ const HostDetail = () => {
) : (
<>
<ChevronDown className="h-3 w-3" />
Show All ({host.updateHistory.length} total)
Show All ({host.update_history.length} total)
</>
)}
</button>
@@ -664,17 +708,21 @@ const HostDetail = () => {
{/* Package Statistics */}
<div className="card">
<div className="px-4 py-2.5 border-b border-secondary-200 dark:border-secondary-600">
<h3 className="text-sm font-medium text-secondary-900 dark:text-white">Package Statistics</h3>
<h3 className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Package Statistics</h3>
</div>
<div className="p-4">
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-primary-50 dark:bg-primary-900/20 rounded-lg">
<div className="flex items-center justify-center w-12 h-12 bg-primary-100 dark:bg-primary-800 rounded-lg mx-auto mb-2">
<button
onClick={() => navigate(`/packages?host=${hostId}`)}
className="text-center p-4 bg-primary-50 dark:bg-primary-900/20 rounded-lg hover:bg-primary-100 dark:hover:bg-primary-900/30 transition-colors group"
title="View all packages for this host"
>
<div className="flex items-center justify-center w-12 h-12 bg-primary-100 dark:bg-primary-800 rounded-lg mx-auto mb-2 group-hover:bg-primary-200 dark:group-hover:bg-primary-700 transition-colors">
<Package className="h-6 w-6 text-primary-600 dark:text-primary-400" />
</div>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.totalPackages}</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.total_packages}</p>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Total Packages</p>
</div>
</button>
<button
onClick={() => navigate(`/packages?host=${hostId}`)}
@@ -684,7 +732,7 @@ const HostDetail = () => {
<div className="flex items-center justify-center w-12 h-12 bg-warning-100 dark:bg-warning-800 rounded-lg mx-auto mb-2 group-hover:bg-warning-200 dark:group-hover:bg-warning-700 transition-colors">
<Clock className="h-6 w-6 text-warning-600 dark:text-warning-400" />
</div>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.outdatedPackages}</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.outdated_packages}</p>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Outdated Packages</p>
</button>
@@ -696,7 +744,7 @@ const HostDetail = () => {
<div className="flex items-center justify-center w-12 h-12 bg-danger-100 dark:bg-danger-800 rounded-lg mx-auto mb-2 group-hover:bg-danger-200 dark:group-hover:bg-danger-700 transition-colors">
<Shield className="h-6 w-6 text-danger-600 dark:text-danger-400" />
</div>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.securityUpdates}</p>
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.security_updates}</p>
<p className="text-sm text-secondary-500 dark:text-secondary-300">Security Updates</p>
</button>
</div>
@@ -738,15 +786,52 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
queryKey: ['serverUrl'],
queryFn: () => settingsAPI.getServerUrl().then(res => res.data),
})
const serverUrl = serverUrlData?.serverUrl || 'http://localhost:3001'
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text)
const serverUrl = serverUrlData?.server_url || 'http://localhost:3001'
const copyToClipboard = async (text) => {
try {
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
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) {
throw new Error('Copy command failed')
}
} catch (err) {
// If all else fails, show the text in a prompt
prompt('Copy this command:', text)
} 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 command:', text)
}
}
const getSetupCommands = () => {
return `# Run this on the target host: ${host?.friendlyName}
// Get current time for crontab scheduling
const now = new Date()
const currentMinute = now.getMinutes()
const currentHour = now.getHours()
return `# Run this on the target host: ${host?.friendly_name}
echo "🔄 Setting up PatchMon agent..."
@@ -769,14 +854,14 @@ sudo /usr/local/bin/patchmon-agent.sh test
echo "📊 Sending initial package data..."
sudo /usr/local/bin/patchmon-agent.sh update
# Setup crontab
echo "⏰ Setting up hourly crontab..."
echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -
# Setup crontab starting at current time
echo "⏰ Setting up hourly crontab starting at ${currentHour.toString().padStart(2, '0')}:${currentMinute.toString().padStart(2, '0')}..."
echo "${currentMinute} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -
echo "✅ PatchMon agent setup complete!"
echo " - Agent installed: /usr/local/bin/patchmon-agent.sh"
echo " - Config directory: /etc/patchmon/"
echo " - Updates: Every hour via crontab"
echo " - Updates: Every hour via crontab (starting at ${currentHour.toString().padStart(2, '0')}:${currentMinute.toString().padStart(2, '0')})"
echo " - View logs: tail -f /var/log/patchmon-agent.log"`
}
@@ -788,7 +873,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<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-4xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Host Setup - {host.friendlyName}</h3>
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Host Setup - {host.friendly_name}</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>
@@ -831,12 +916,12 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="flex items-center gap-2">
<input
type="text"
value={`curl -s ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host.apiId}" "${host.apiKey}"`}
value={`curl -s ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host.api_id}" "${host.api_key}"`}
readOnly
className="flex-1 px-3 py-2 border border-primary-300 dark:border-primary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => copyToClipboard(`curl -s ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host.apiId}" "${host.apiKey}"`)}
onClick={() => copyToClipboard(`curl -s ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host.api_id}" "${host.api_key}"`)}
className="btn-primary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
@@ -894,12 +979,12 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="flex items-center gap-2">
<input
type="text"
value={`sudo /usr/local/bin/patchmon-agent.sh configure "${host.apiId}" "${host.apiKey}"`}
value={`sudo /usr/local/bin/patchmon-agent.sh configure "${host.api_id}" "${host.api_key}"`}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => copyToClipboard(`sudo /usr/local/bin/patchmon-agent.sh configure "${host.apiId}" "${host.apiKey}"`)}
onClick={() => copyToClipboard(`sudo /usr/local/bin/patchmon-agent.sh configure "${host.api_id}" "${host.api_key}"`)}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
@@ -951,12 +1036,12 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="flex items-center gap-2">
<input
type="text"
value='echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -'
value={`echo "${new Date().getMinutes()} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -`}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-white dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => copyToClipboard('echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -')}
onClick={() => copyToClipboard(`echo "${new Date().getMinutes()} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -`)}
className="btn-secondary flex items-center gap-1"
>
<Copy className="h-4 w-4" />
@@ -979,12 +1064,12 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="flex items-center gap-2">
<input
type="text"
value={host.apiId}
value={host.api_id}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
<button
onClick={() => copyToClipboard(host.apiId)}
onClick={() => copyToClipboard(host.api_id)}
className="btn-outline flex items-center gap-1"
>
<Copy className="h-4 w-4" />
@@ -998,7 +1083,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="flex items-center gap-2">
<input
type={showApiKey ? 'text' : 'password'}
value={host.apiKey}
value={host.api_key}
readOnly
className="flex-1 px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md bg-secondary-50 dark:bg-secondary-800 text-sm font-mono text-secondary-900 dark:text-white"
/>
@@ -1009,7 +1094,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
<button
onClick={() => copyToClipboard(host.apiKey)}
onClick={() => copyToClipboard(host.api_key)}
className="btn-outline flex items-center gap-1"
>
<Copy className="h-4 w-4" />
@@ -1069,7 +1154,7 @@ const DeleteConfirmationModal = ({ host, isOpen, onClose, onConfirm, isLoading }
<div className="mb-6">
<p className="text-secondary-700 dark:text-secondary-300">
Are you sure you want to delete the host{' '}
<span className="font-semibold">"{host.friendlyName}"</span>?
<span className="font-semibold">"{host.friendly_name}"</span>?
</p>
<div className="mt-3 p-3 bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md">
<p className="text-sm text-danger-800 dark:text-danger-200">

View File

@@ -38,7 +38,7 @@ import InlineGroupEdit from '../components/InlineGroupEdit'
// Add Host Modal Component
const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
const [formData, setFormData] = useState({
friendlyName: '',
friendly_name: '',
hostGroupId: ''
})
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -62,7 +62,7 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
const response = await adminHostsAPI.create(formData)
console.log('Host created successfully:', response.data)
onSuccess(response.data)
setFormData({ friendlyName: '', hostGroupId: '' })
setFormData({ friendly_name: '', hostGroupId: '' })
onClose()
} catch (err) {
console.error('Full error object:', err)
@@ -105,8 +105,8 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
<input
type="text"
required
value={formData.friendlyName}
onChange={(e) => setFormData({ ...formData, friendlyName: e.target.value })}
value={formData.friendly_name}
onChange={(e) => setFormData({ ...formData, friendly_name: e.target.value })}
className="block w-full px-3 py-2.5 text-base border-2 border-secondary-300 dark:border-secondary-600 rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white transition-all duration-200"
placeholder="server.example.com"
/>
@@ -121,16 +121,16 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
{/* No Group Option */}
<button
type="button"
onClick={() => setFormData({ ...formData, hostGroupId: '' })}
onClick={() => setFormData({ ...formData, host_group_id: '' })}
className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
formData.hostGroupId === ''
formData.host_group_id === ''
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
: 'border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500'
}`}
>
<div className="text-xs font-medium">No Group</div>
<div className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">Ungrouped</div>
{formData.hostGroupId === '' && (
{formData.host_group_id === '' && (
<div className="absolute top-2 right-2 w-3 h-3 rounded-full bg-primary-500 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-white"></div>
</div>
@@ -142,9 +142,9 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
<button
key={group.id}
type="button"
onClick={() => setFormData({ ...formData, hostGroupId: group.id })}
onClick={() => setFormData({ ...formData, host_group_id: group.id })}
className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
formData.hostGroupId === group.id
formData.host_group_id === group.id
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
: 'border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500'
}`}
@@ -159,7 +159,7 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
<div className="text-xs font-medium truncate max-w-full">{group.name}</div>
</div>
<div className="text-xs text-secondary-500 dark:text-secondary-400">Group</div>
{formData.hostGroupId === group.id && (
{formData.host_group_id === group.id && (
<div className="absolute top-2 right-2 w-3 h-3 rounded-full bg-primary-500 flex items-center justify-center">
<div className="w-1.5 h-1.5 rounded-full bg-white"></div>
</div>
@@ -214,9 +214,43 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
}
}, [host?.isNewHost])
const copyToClipboard = (text, label) => {
navigator.clipboard.writeText(text)
alert(`${label} copied to clipboard!`)
const copyToClipboard = async (text, label) => {
try {
// Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text)
alert(`${label} 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) {
alert(`${label} 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 ${label.toLowerCase()}:`, text)
} 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 ${label.toLowerCase()}:`, text)
}
}
// Fetch server URL from settings
@@ -226,11 +260,16 @@ const CredentialsModal = ({ host, isOpen, onClose }) => {
enabled: isOpen // Only fetch when modal is open
})
const serverUrl = settings?.serverUrl || window.location.origin.replace(':3000', ':3001')
const serverUrl = settings?.server_url || window.location.origin.replace(':3000', ':3001')
const getSetupCommands = () => {
// Get current time for crontab scheduling
const now = new Date()
const currentMinute = now.getMinutes()
const currentHour = now.getHours()
return {
oneLine: `curl -sSL ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host?.apiId}" "${host?.apiKey}"`,
oneLine: `curl -sSL ${serverUrl}/api/v1/hosts/install | bash -s -- ${serverUrl} "${host?.api_id}" "${host?.api_key}"`,
download: `# Download and setup PatchMon agent
curl -o /tmp/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download
@@ -239,7 +278,7 @@ sudo mv /tmp/patchmon-agent.sh /usr/local/bin/patchmon-agent.sh
sudo chmod +x /usr/local/bin/patchmon-agent.sh`,
configure: `# Configure API credentials
sudo /usr/local/bin/patchmon-agent.sh configure "${host?.apiId}" "${host?.apiKey}"`,
sudo /usr/local/bin/patchmon-agent.sh configure "${host?.api_id}" "${host?.api_key}"`,
test: `# Test the configuration
sudo /usr/local/bin/patchmon-agent.sh test`,
@@ -247,12 +286,12 @@ sudo /usr/local/bin/patchmon-agent.sh test`,
initialUpdate: `# Send initial package data
sudo /usr/local/bin/patchmon-agent.sh update`,
crontab: `# Add to crontab for hourly updates (run as root)
echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -`,
crontab: `# Add to crontab for hourly updates starting at current time (run as root)
echo "${currentMinute} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -`,
fullSetup: `#!/bin/bash
# Complete PatchMon Agent Setup Script
# Run this on the target host: ${host?.friendlyName}
# Run this on the target host: ${host?.friendly_name}
echo "🔄 Setting up PatchMon agent..."
@@ -265,7 +304,7 @@ sudo chmod +x /usr/local/bin/patchmon-agent.sh
# Configure credentials
echo "🔑 Configuring API credentials..."
sudo /usr/local/bin/patchmon-agent.sh configure "${host?.apiId}" "${host?.apiKey}"
sudo /usr/local/bin/patchmon-agent.sh configure "${host?.api_id}" "${host?.api_key}"
# Test configuration
echo "🧪 Testing configuration..."
@@ -275,14 +314,14 @@ sudo /usr/local/bin/patchmon-agent.sh test
echo "📊 Sending initial package data..."
sudo /usr/local/bin/patchmon-agent.sh update
# Setup crontab
echo "⏰ Setting up hourly crontab..."
echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -
# Setup crontab starting at current time
echo "⏰ Setting up hourly crontab starting at ${currentHour.toString().padStart(2, '0')}:${currentMinute.toString().padStart(2, '0')}..."
echo "${currentMinute} * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | sudo crontab -
echo "✅ PatchMon agent setup complete!"
echo " - Agent installed: /usr/local/bin/patchmon-agent.sh"
echo " - Config directory: /etc/patchmon/"
echo " - Updates: Every hour via crontab"
echo " - Updates: Every hour via crontab (starting at ${currentHour.toString().padStart(2, '0')}:${currentMinute.toString().padStart(2, '0')})"
echo " - View logs: tail -f /var/log/patchmon-agent.log"`
}
}
@@ -295,7 +334,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<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-4xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-secondary-900">Host Setup - {host.friendlyName}</h3>
<h3 className="text-lg font-medium text-secondary-900">Host Setup - {host.friendly_name}</h3>
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600">
<X className="h-5 w-5" />
</button>
@@ -351,7 +390,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<h4 className="text-sm font-medium text-green-800 mb-2">🚀 One-Line Installation</h4>
<p className="text-sm text-green-700">
Copy and paste this single command on <strong>{host.friendlyName}</strong> to install and configure the PatchMon agent automatically.
Copy and paste this single command on <strong>{host.friendly_name}</strong> to install and configure the PatchMon agent automatically.
</p>
</div>
@@ -378,7 +417,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<ul className="text-sm text-blue-700 space-y-1">
<li> Downloads the PatchMon installation script</li>
<li> Installs the agent to <code>/usr/local/bin/patchmon-agent.sh</code></li>
<li> Configures API credentials for <strong>{host.friendlyName}</strong></li>
<li> Configures API credentials for <strong>{host.friendly_name}</strong></li>
<li> Tests the connection to PatchMon server</li>
<li> Sends initial package data</li>
<li> Sets up hourly automatic updates via crontab</li>
@@ -444,7 +483,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="bg-amber-50 border border-amber-200 rounded-md p-4">
<h4 className="text-sm font-medium text-amber-800 mb-2"> Security Note</h4>
<p className="text-sm text-amber-700">
Keep these credentials secure. They provide access to update package information for <strong>{host.friendlyName}</strong> only.
Keep these credentials secure. They provide access to update package information for <strong>{host.friendly_name}</strong> only.
</p>
</div>
</div>
@@ -455,7 +494,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<h4 className="text-sm font-medium text-blue-800 mb-2">📋 Step-by-Step Setup</h4>
<p className="text-sm text-blue-700">
Follow these commands on <strong>{host.friendlyName}</strong> to install and configure the PatchMon agent.
Follow these commands on <strong>{host.friendly_name}</strong> to install and configure the PatchMon agent.
</p>
</div>
@@ -552,7 +591,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="bg-green-50 border border-green-200 rounded-md p-4">
<h4 className="text-sm font-medium text-green-800 mb-2">🚀 Automated Setup</h4>
<p className="text-sm text-green-700">
Copy this complete setup script to <strong>{host.friendlyName}</strong> and run it to automatically install and configure everything.
Copy this complete setup script to <strong>{host.friendly_name}</strong> and run it to automatically install and configure everything.
</p>
</div>
@@ -573,7 +612,7 @@ echo " - View logs: tail -f /var/log/patchmon-agent.log"`
<div className="mt-3 text-sm text-secondary-600">
<p><strong>Usage:</strong></p>
<p>1. Copy the script above</p>
<p>2. Save it to a file on {host.friendlyName} (e.g., <code>setup-patchmon.sh</code>)</p>
<p>2. Save it to a file on {host.friendly_name} (e.g., <code>setup-patchmon.sh</code>)</p>
<p>3. Run: <code>chmod +x setup-patchmon.sh && sudo ./setup-patchmon.sh</code></p>
</div>
</div>
@@ -604,6 +643,7 @@ const Hosts = () => {
const [showAddModal, setShowAddModal] = useState(false)
const [selectedHosts, setSelectedHosts] = useState([])
const [showBulkAssignModal, setShowBulkAssignModal] = useState(false)
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
const [searchParams] = useSearchParams()
const navigate = useNavigate()
@@ -622,6 +662,9 @@ const Hosts = () => {
// Handle URL filter parameters
useEffect(() => {
const filter = searchParams.get('filter')
const showFiltersParam = searchParams.get('showFilters')
const osFilterParam = searchParams.get('osFilter')
if (filter === 'needsUpdates') {
setShowFilters(true)
setStatusFilter('all')
@@ -634,6 +677,18 @@ const Hosts = () => {
setShowFilters(true)
setStatusFilter('active')
// We'll filter hosts that are up to date in the filtering logic
} else if (filter === 'stale') {
setShowFilters(true)
setStatusFilter('all')
// We'll filter hosts that are stale in the filtering logic
} else if (showFiltersParam === 'true') {
setShowFilters(true)
}
// Handle OS filter parameter
if (osFilterParam) {
setShowFilters(true)
setOsFilter(osFilterParam)
}
// Handle add host action from navigation
@@ -666,65 +721,38 @@ const Hosts = () => {
{ id: 'ip', label: 'IP Address', visible: false, order: 2 },
{ id: 'group', label: 'Group', visible: true, order: 3 },
{ id: 'os', label: 'OS', visible: true, order: 4 },
{ id: 'osVersion', label: 'OS Version', visible: false, order: 5 },
{ id: 'agentVersion', label: 'Agent Version', visible: true, order: 6 },
{ id: 'autoUpdate', label: 'Auto-update', visible: true, order: 7 },
{ id: 'os_version', label: 'OS Version', visible: false, order: 5 },
{ id: 'agent_version', label: 'Agent Version', visible: true, order: 6 },
{ id: 'auto_update', label: 'Auto-update', visible: true, order: 7 },
{ id: 'status', label: 'Status', visible: true, order: 8 },
{ id: 'updates', label: 'Updates', visible: true, order: 9 },
{ id: 'lastUpdate', label: 'Last Update', visible: true, order: 10 },
{ id: 'last_update', label: 'Last Update', visible: true, order: 10 },
{ id: 'actions', label: 'Actions', visible: true, order: 11 }
]
const saved = localStorage.getItem('hosts-column-config')
if (saved) {
const savedConfig = JSON.parse(saved)
// Check if agentVersion column exists in saved config
const hasAgentVersion = savedConfig.some(col => col.id === 'agentVersion')
const hasAutoUpdate = savedConfig.some(col => col.id === 'autoUpdate')
let needsUpdate = false
let updatedConfig = [...savedConfig]
if (!hasAgentVersion) {
// Add agentVersion column to saved config
const agentVersionColumn = { id: 'agentVersion', label: 'Agent Version', visible: true, order: 6 }
try {
const savedConfig = JSON.parse(saved)
// Insert agentVersion column at the correct position
updatedConfig = updatedConfig.map(col => {
if (col.order >= 6) {
return { ...col, order: col.order + 1 }
}
return col
})
// Check if we have old camelCase column IDs that need to be migrated
const hasOldColumns = savedConfig.some(col =>
col.id === 'agentVersion' || col.id === 'autoUpdate' || col.id === 'osVersion' || col.id === 'lastUpdate'
)
updatedConfig.push(agentVersionColumn)
needsUpdate = true
if (hasOldColumns) {
// Clear the old configuration and use the default snake_case configuration
localStorage.removeItem('hosts-column-config')
return defaultConfig
} else {
// Use the existing configuration
return savedConfig
}
} catch (error) {
// If there's an error parsing the config, clear it and use default
localStorage.removeItem('hosts-column-config')
return defaultConfig
}
if (!hasAutoUpdate) {
// Add autoUpdate column to saved config
const autoUpdateColumn = { id: 'autoUpdate', label: 'Auto-update', visible: true, order: 7 }
// Insert autoUpdate column at the correct position
updatedConfig = updatedConfig.map(col => {
if (col.order >= 7) {
return { ...col, order: col.order + 1 }
}
return col
})
updatedConfig.push(autoUpdateColumn)
needsUpdate = true
}
if (needsUpdate) {
updatedConfig.sort((a, b) => a.order - b.order)
localStorage.setItem('hosts-column-config', JSON.stringify(updatedConfig))
return updatedConfig
}
return savedConfig
}
return defaultConfig
@@ -732,11 +760,11 @@ const Hosts = () => {
const queryClient = useQueryClient()
const { data: hosts, isLoading, error, refetch } = useQuery({
const { data: hosts, isLoading, error, refetch, isFetching } = useQuery({
queryKey: ['hosts'],
queryFn: () => dashboardAPI.getHosts().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
})
const { data: hostGroups } = useQuery({
@@ -759,7 +787,7 @@ const Hosts = () => {
// Ensure hostGroupId is set correctly
return {
...updatedHost,
hostGroupId: updatedHost.hostGroup?.id || null
hostGroupId: updatedHost.host_groups?.id || null
};
}
return host;
@@ -776,7 +804,7 @@ const Hosts = () => {
// Toggle auto-update mutation
const toggleAutoUpdateMutation = useMutation({
mutationFn: ({ hostId, autoUpdate }) => adminHostsAPI.toggleAutoUpdate(hostId, autoUpdate).then(res => res.data),
mutationFn: ({ hostId, auto_update }) => adminHostsAPI.toggleAutoUpdate(hostId, auto_update).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries(['hosts'])
}
@@ -798,9 +826,6 @@ const Hosts = () => {
});
},
onSuccess: (data) => {
console.log('updateHostGroupMutation success:', data);
console.log('Updated host data:', data.host);
console.log('Host group in response:', data.host.hostGroup);
// Update the cache with the new host data
queryClient.setQueryData(['hosts'], (oldData) => {
@@ -812,7 +837,7 @@ const Hosts = () => {
// Ensure hostGroupId is set correctly
const updatedHost = {
...data.host,
hostGroupId: data.host.hostGroup?.id || null
hostGroupId: data.host.host_groups?.id || null
};
console.log('Updated host with hostGroupId:', updatedHost);
return updatedHost;
@@ -831,6 +856,19 @@ const Hosts = () => {
}
})
const bulkDeleteMutation = useMutation({
mutationFn: (hostIds) => adminHostsAPI.deleteBulk(hostIds),
onSuccess: (data) => {
console.log('Bulk delete success:', data);
queryClient.invalidateQueries(['hosts']);
setSelectedHosts([]);
setShowBulkDeleteModal(false);
},
onError: (error) => {
console.error('Bulk delete error:', error);
}
});
// Helper functions for bulk selection
const handleSelectHost = (hostId) => {
setSelectedHosts(prev =>
@@ -852,6 +890,10 @@ const Hosts = () => {
bulkUpdateGroupMutation.mutate({ hostIds: selectedHosts, hostGroupId })
}
const handleBulkDelete = () => {
bulkDeleteMutation.mutate(selectedHosts)
}
// Table filtering and sorting logic
const filteredAndSortedHosts = React.useMemo(() => {
if (!hosts) return []
@@ -859,27 +901,28 @@ const Hosts = () => {
let filtered = hosts.filter(host => {
// Search filter
const matchesSearch = searchTerm === '' ||
host.friendlyName.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.friendly_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.ip?.toLowerCase().includes(searchTerm.toLowerCase()) ||
host.osType?.toLowerCase().includes(searchTerm.toLowerCase())
host.os_type?.toLowerCase().includes(searchTerm.toLowerCase())
// Group filter
const matchesGroup = groupFilter === 'all' ||
(groupFilter === 'ungrouped' && !host.hostGroup) ||
(groupFilter !== 'ungrouped' && host.hostGroup?.id === groupFilter)
(groupFilter === 'ungrouped' && !host.host_groups) ||
(groupFilter !== 'ungrouped' && host.host_groups?.id === groupFilter)
// Status filter
const matchesStatus = statusFilter === 'all' || (host.effectiveStatus || host.status) === statusFilter
// OS filter
const matchesOs = osFilter === 'all' || host.osType?.toLowerCase() === osFilter.toLowerCase()
const matchesOs = osFilter === 'all' || host.os_type?.toLowerCase() === osFilter.toLowerCase()
// URL filter for hosts needing updates, inactive hosts, or up-to-date hosts
// URL filter for hosts needing updates, inactive hosts, up-to-date hosts, or stale hosts
const filter = searchParams.get('filter')
const matchesUrlFilter =
(filter !== 'needsUpdates' || (host.updatesCount && host.updatesCount > 0)) &&
(filter !== 'inactive' || (host.effectiveStatus || host.status) === 'inactive') &&
(filter !== 'upToDate' || (!host.isStale && host.updatesCount === 0))
(filter !== 'upToDate' || (!host.isStale && host.updatesCount === 0)) &&
(filter !== 'stale' || host.isStale)
// Hide stale filter
const matchesHideStale = !hideStale || !host.isStale
@@ -893,8 +936,8 @@ const Hosts = () => {
switch (sortField) {
case 'friendlyName':
aValue = a.friendlyName.toLowerCase()
bValue = b.friendlyName.toLowerCase()
aValue = a.friendly_name.toLowerCase()
bValue = b.friendly_name.toLowerCase()
break
case 'hostname':
aValue = a.hostname?.toLowerCase() || 'zzz_no_hostname'
@@ -905,20 +948,20 @@ const Hosts = () => {
bValue = b.ip?.toLowerCase() || 'zzz_no_ip'
break
case 'group':
aValue = a.hostGroup?.name || 'zzz_ungrouped'
bValue = b.hostGroup?.name || 'zzz_ungrouped'
aValue = a.host_groups?.name || 'zzz_ungrouped'
bValue = b.host_groups?.name || 'zzz_ungrouped'
break
case 'os':
aValue = a.osType?.toLowerCase() || 'zzz_unknown'
bValue = b.osType?.toLowerCase() || 'zzz_unknown'
aValue = a.os_type?.toLowerCase() || 'zzz_unknown'
bValue = b.os_type?.toLowerCase() || 'zzz_unknown'
break
case 'osVersion':
aValue = a.osVersion?.toLowerCase() || 'zzz_unknown'
bValue = b.osVersion?.toLowerCase() || 'zzz_unknown'
case 'os_version':
aValue = a.os_version?.toLowerCase() || 'zzz_unknown'
bValue = b.os_version?.toLowerCase() || 'zzz_unknown'
break
case 'agentVersion':
aValue = a.agentVersion?.toLowerCase() || 'zzz_no_version'
bValue = b.agentVersion?.toLowerCase() || 'zzz_no_version'
case 'agent_version':
aValue = a.agent_version?.toLowerCase() || 'zzz_no_version'
bValue = b.agent_version?.toLowerCase() || 'zzz_no_version'
break
case 'status':
aValue = a.effectiveStatus || a.status
@@ -928,9 +971,9 @@ const Hosts = () => {
aValue = a.updatesCount || 0
bValue = b.updatesCount || 0
break
case 'lastUpdate':
aValue = new Date(a.lastUpdate)
bValue = new Date(b.lastUpdate)
case 'last_update':
aValue = new Date(a.last_update)
bValue = new Date(b.last_update)
break
default:
aValue = a[sortField]
@@ -956,13 +999,13 @@ const Hosts = () => {
let groupKey
switch (groupBy) {
case 'group':
groupKey = host.hostGroup?.name || 'Ungrouped'
groupKey = host.host_groups?.name || 'Ungrouped'
break
case 'status':
groupKey = (host.effectiveStatus || host.status).charAt(0).toUpperCase() + (host.effectiveStatus || host.status).slice(1)
break
case 'os':
groupKey = host.osType || 'Unknown'
groupKey = host.os_type || 'Unknown'
break
default:
groupKey = 'All Hosts'
@@ -1022,10 +1065,10 @@ const Hosts = () => {
{ id: 'ip', label: 'IP Address', visible: false, order: 3 },
{ id: 'group', label: 'Group', visible: true, order: 4 },
{ id: 'os', label: 'OS', visible: true, order: 5 },
{ id: 'osVersion', label: 'OS Version', visible: false, order: 6 },
{ id: 'os_version', label: 'OS Version', visible: false, order: 6 },
{ id: 'status', label: 'Status', visible: true, order: 7 },
{ id: 'updates', label: 'Updates', visible: true, order: 8 },
{ id: 'lastUpdate', label: 'Last Update', visible: true, order: 9 },
{ id: 'last_update', label: 'Last Update', visible: true, order: 9 },
{ id: 'actions', label: 'Actions', visible: true, order: 10 }
]
updateColumnConfig(defaultConfig)
@@ -1055,7 +1098,7 @@ const Hosts = () => {
case 'host':
return (
<InlineEdit
value={host.friendlyName}
value={host.friendly_name}
onSave={(newName) => updateFriendlyNameMutation.mutate({ hostId: host.id, friendlyName: newName })}
placeholder="Enter friendly name..."
maxLength={100}
@@ -1082,16 +1125,10 @@ const Hosts = () => {
</div>
)
case 'group':
console.log('Rendering group for host:', {
hostId: host.id,
hostGroupId: host.hostGroupId,
hostGroup: host.hostGroup,
availableGroups: hostGroups
});
return (
<InlineGroupEdit
key={`${host.id}-${host.hostGroup?.id || 'ungrouped'}-${host.hostGroup?.name || 'ungrouped'}`}
value={host.hostGroup?.id}
key={`${host.id}-${host.host_groups?.id || 'ungrouped'}-${host.host_groups?.name || 'ungrouped'}`}
value={host.host_groups?.id}
onSave={(newGroupId) => updateHostGroupMutation.mutate({ hostId: host.id, hostGroupId: newGroupId })}
options={hostGroups || []}
placeholder="Select group..."
@@ -1101,30 +1138,30 @@ const Hosts = () => {
case 'os':
return (
<div className="flex items-center gap-2 text-sm text-secondary-900 dark:text-white">
<OSIcon osType={host.osType} className="h-4 w-4" />
<span>{host.osType}</span>
<OSIcon osType={host.os_type} className="h-4 w-4" />
<span>{host.os_type}</span>
</div>
)
case 'osVersion':
case 'os_version':
return (
<div className="text-sm text-secondary-900 dark:text-white">
{host.osVersion || 'N/A'}
{host.os_version || 'N/A'}
</div>
)
case 'agentVersion':
case 'agent_version':
return (
<div className="text-sm text-secondary-900 dark:text-white">
{host.agentVersion || 'N/A'}
{host.agent_version || 'N/A'}
</div>
)
case 'autoUpdate':
case 'auto_update':
return (
<span className={`text-sm font-medium ${
host.autoUpdate
host.auto_update
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}>
{host.autoUpdate ? 'Yes' : 'No'}
{host.auto_update ? 'Yes' : 'No'}
</span>
)
case 'status':
@@ -1135,14 +1172,18 @@ const Hosts = () => {
)
case 'updates':
return (
<div className="text-sm text-secondary-900 dark:text-white">
<button
onClick={() => navigate(`/packages?host=${host.id}`)}
className="text-sm text-primary-600 hover:text-primary-900 dark:text-primary-400 dark:hover:text-primary-300 font-medium hover:underline"
title="View packages for this host"
>
{host.updatesCount || 0}
</div>
</button>
)
case 'lastUpdate':
case 'last_update':
return (
<div className="text-sm text-secondary-500 dark:text-secondary-300">
{formatRelativeTime(host.lastUpdate)}
{formatRelativeTime(host.last_update)}
</div>
)
case 'actions':
@@ -1259,10 +1300,37 @@ const Hosts = () => {
}
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">Hosts</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Manage and monitor your connected hosts
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center gap-2"
title="Refresh hosts data"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Refreshing...' : 'Refresh'}
</button>
<button
onClick={() => setShowAddModal(true)}
className="btn-primary flex items-center gap-2"
>
<Plus className="h-4 w-4" />
Add Host
</button>
</div>
</div>
{/* Stats Summary */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6">
<div
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
onClick={handleTotalHostsClick}
@@ -1320,8 +1388,8 @@ const Hosts = () => {
</div>
{/* Hosts List */}
<div className="card">
<div className="px-4 py-4 sm:p-4">
<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">
{selectedHosts.length > 0 && (
<div className="flex items-center gap-3">
@@ -1335,6 +1403,13 @@ const Hosts = () => {
<Users className="h-4 w-4" />
Assign to Group
</button>
<button
onClick={() => setShowBulkDeleteModal(true)}
className="btn-danger flex items-center gap-2"
>
<Trash2 className="h-4 w-4" />
Delete
</button>
<button
onClick={() => setSelectedHosts([])}
className="text-sm text-secondary-500 hover:text-secondary-700"
@@ -1471,17 +1546,27 @@ const Hosts = () => {
)}
</div>
{(!hosts || hosts.length === 0) ? (
<div className="text-center py-8">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500">No hosts registered yet</p>
<p className="text-sm text-secondary-400 mt-2">
Click "Add Host" to manually register a new host and get API credentials
</p>
</div>
) : (
<div className="space-y-6">
{Object.entries(groupedHosts).map(([groupName, groupHosts]) => (
<div className="flex-1 overflow-hidden">
{(!hosts || hosts.length === 0) ? (
<div className="text-center py-8">
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500">No hosts registered yet</p>
<p className="text-sm text-secondary-400 mt-2">
Click "Add Host" to manually register a new host and get API credentials
</p>
</div>
) : filteredAndSortedHosts.length === 0 ? (
<div className="text-center py-8">
<Search className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<p className="text-secondary-500">No hosts match your current filters</p>
<p className="text-sm text-secondary-400 mt-2">
Try adjusting your search terms or filters to see more results
</p>
</div>
) : (
<div className="h-full overflow-auto">
<div className="space-y-6">
{Object.entries(groupedHosts).map(([groupName, groupHosts]) => (
<div key={groupName} className="space-y-3">
{/* Group Header */}
{groupBy !== 'none' && (
@@ -1550,23 +1635,23 @@ const Hosts = () => {
{column.label}
{getSortIcon('os')}
</button>
) : column.id === 'osVersion' ? (
) : column.id === 'os_version' ? (
<button
onClick={() => handleSort('osVersion')}
onClick={() => handleSort('os_version')}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon('osVersion')}
{getSortIcon('os_version')}
</button>
) : column.id === 'agentVersion' ? (
) : column.id === 'agent_version' ? (
<button
onClick={() => handleSort('agentVersion')}
onClick={() => handleSort('agent_version')}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon('agentVersion')}
{getSortIcon('agent_version')}
</button>
) : column.id === 'autoUpdate' ? (
) : column.id === 'auto_update' ? (
<div className="flex items-center gap-2 font-normal text-xs text-secondary-500 dark:text-secondary-300 normal-case tracking-wider">
{column.label}
</div>
@@ -1586,13 +1671,13 @@ const Hosts = () => {
{column.label}
{getSortIcon('updates')}
</button>
) : column.id === 'lastUpdate' ? (
) : column.id === 'last_update' ? (
<button
onClick={() => handleSort('lastUpdate')}
onClick={() => handleSort('last_update')}
className="flex items-center gap-2 hover:text-secondary-700"
>
{column.label}
{getSortIcon('lastUpdate')}
{getSortIcon('last_update')}
</button>
) : (
column.label
@@ -1628,11 +1713,13 @@ const Hosts = () => {
</table>
</div>
</div>
))}
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
{/* Modals */}
<AddHostModal
@@ -1653,6 +1740,17 @@ const Hosts = () => {
/>
)}
{/* Bulk Delete Modal */}
{showBulkDeleteModal && (
<BulkDeleteModal
selectedHosts={selectedHosts}
hosts={hosts}
onClose={() => setShowBulkDeleteModal(false)}
onDelete={handleBulkDelete}
isLoading={bulkDeleteMutation.isPending}
/>
)}
{/* Column Settings Modal */}
{showColumnSettings && (
<ColumnSettingsModal
@@ -1679,7 +1777,7 @@ const BulkAssignModal = ({ selectedHosts, hosts, onClose, onAssign, isLoading })
const selectedHostNames = hosts
.filter(host => selectedHosts.includes(host.id))
.map(host => host.friendlyName)
.map(host => host.friendly_name)
const handleSubmit = (e) => {
e.preventDefault()
@@ -1756,6 +1854,85 @@ const BulkAssignModal = ({ selectedHosts, hosts, onClose, onAssign, isLoading })
)
}
// Bulk Delete Modal Component
const BulkDeleteModal = ({ selectedHosts, hosts, onClose, onDelete, isLoading }) => {
const selectedHostNames = hosts
.filter(host => selectedHosts.includes(host.id))
.map(host => host.friendly_name || host.hostname || host.id)
const handleSubmit = (e) => {
e.preventDefault()
onDelete()
}
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-md w-full mx-4">
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Delete Hosts</h3>
<button
onClick={onClose}
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
disabled={isLoading}
>
<X className="h-5 w-5" />
</button>
</div>
</div>
<div className="px-6 py-4">
<div className="mb-4">
<div className="flex items-center gap-2 mb-3">
<AlertTriangle className="h-5 w-5 text-danger-600" />
<h4 className="text-sm font-medium text-danger-800 dark:text-danger-200">
Warning: This action cannot be undone
</h4>
</div>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-4">
You are about to permanently delete {selectedHosts.length} host{selectedHosts.length !== 1 ? 's' : ''}.
This will remove all host data, including package information, update history, and API credentials.
</p>
</div>
<div className="mb-4">
<p className="text-sm text-secondary-600 dark:text-secondary-400 mb-2">
Hosts to be deleted:
</p>
<div className="max-h-32 overflow-y-auto bg-secondary-50 dark:bg-secondary-700 rounded-md p-3">
{selectedHostNames.map((friendlyName, index) => (
<div key={index} className="text-sm text-secondary-700 dark:text-secondary-300">
{friendlyName}
</div>
))}
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<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-danger"
disabled={isLoading}
>
{isLoading ? 'Deleting...' : `Delete ${selectedHosts.length} Host${selectedHosts.length !== 1 ? 's' : ''}`}
</button>
</div>
</form>
</div>
</div>
</div>
)
}
// Column Settings Modal Component
const ColumnSettingsModal = ({ columnConfig, onClose, onToggleVisibility, onReorder, onReset }) => {
const [draggedIndex, setDraggedIndex] = useState(null)

View File

@@ -1,13 +1,17 @@
import React, { useState } from 'react'
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { Eye, EyeOff, Lock, User, AlertCircle, Smartphone, ArrowLeft } 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: ''
@@ -17,9 +21,28 @@ const Login = () => {
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()
@@ -28,7 +51,7 @@ const Login = () => {
try {
const response = await authAPI.login(formData.username, formData.password)
if (response.data.requiresTfa) {
setRequiresTfa(true)
setTfaUsername(formData.username)
@@ -49,6 +72,34 @@ const Login = () => {
}
}
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('Signup failed - invalid response')
}
} catch (err) {
console.error('Signup error:', err)
const errorMessage = err.response?.data?.error ||
(err.response?.data?.errors && err.response.data.errors.length > 0
? err.response.data.errors.map(e => e.msg).join(', ')
: err.message || 'Signup failed')
setError(errorMessage)
} finally {
setIsLoading(false)
}
}
const handleTfaSubmit = async (e) => {
e.preventDefault()
setIsLoading(true)
@@ -56,12 +107,12 @@ const Login = () => {
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 {
@@ -102,15 +153,31 @@ const Login = () => {
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
@@ -118,16 +185,13 @@ const Login = () => {
</div>
{!requiresTfa ? (
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<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"
@@ -136,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"
@@ -159,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>
@@ -179,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>
@@ -196,25 +342,34 @@ 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 className="h-6 w-6 text-blue-600" />
<Smartphone size={24} color="#2563eb" strokeWidth={2} />
</div>
<h3 className="mt-4 text-lg font-medium text-secondary-900">
Two-Factor Authentication
@@ -246,7 +401,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>
@@ -273,9 +428,9 @@ const Login = () => {
<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"
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 className="h-4 w-4 mr-2" />
<ArrowLeft size={16} color="#475569" strokeWidth={2} />
Back to Login
</button>
</div>

View File

@@ -7,7 +7,8 @@ import {
Server,
Users,
AlertTriangle,
CheckCircle
CheckCircle,
Settings
} from 'lucide-react'
import { hostGroupsAPI } from '../utils/api'
@@ -214,7 +215,7 @@ const Options = () => {
const renderComingSoonTab = (tabName) => (
<div className="text-center py-12">
<SettingsIcon className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
<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>
@@ -381,7 +382,7 @@ const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
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>
@@ -483,7 +484,7 @@ const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
type="text"
value={formData.color}
onChange={handleChange}
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
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>

View File

@@ -71,8 +71,9 @@ const Packages = () => {
// Handle affected hosts click
const handleAffectedHostsClick = (pkg) => {
const hostIds = pkg.affectedHosts.map(host => host.hostId)
const hostNames = pkg.affectedHosts.map(host => host.friendlyName)
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()
@@ -98,19 +99,19 @@ 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),
refetchInterval: 60000,
staleTime: 30000,
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Don't refetch when window regains focus
})
// Filter and sort packages
@@ -128,8 +129,9 @@ const Packages = () => {
(securityFilter === 'security' && pkg.isSecurityUpdate) ||
(securityFilter === 'regular' && !pkg.isSecurityUpdate)
const affectedHosts = pkg.affectedHosts || []
const matchesHost = hostFilter === 'all' ||
pkg.affectedHosts.some(host => host.hostId === hostFilter)
affectedHosts.some(host => host.hostId === hostFilter)
return matchesSearch && matchesCategory && matchesSecurity && matchesHost
})
@@ -148,8 +150,8 @@ const Packages = () => {
bValue = b.latestVersion?.toLowerCase() || ''
break
case 'affectedHosts':
aValue = a.affectedHostsCount || 0
bValue = b.affectedHostsCount || 0
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
@@ -241,14 +243,15 @@ const Packages = () => {
</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 ${pkg.affectedHostsCount} affected hosts`}
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">
{pkg.affectedHostsCount} host{pkg.affectedHostsCount !== 1 ? 's' : ''}
{affectedHostsCount} host{affectedHostsCount !== 1 ? 's' : ''}
</div>
</button>
)
@@ -278,7 +281,8 @@ const Packages = () => {
// Calculate unique affected hosts
const uniqueAffectedHosts = new Set()
packages?.forEach(pkg => {
pkg.affectedHosts.forEach(host => {
const affectedHosts = pkg.affectedHosts || []
affectedHosts.forEach(host => {
uniqueAffectedHosts.add(host.hostId)
})
})
@@ -330,6 +334,26 @@ const Packages = () => {
return (
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
{/* Page Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">Packages</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Manage package updates and security patches
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center gap-2"
title="Refresh packages data"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Refreshing...' : 'Refresh'}
</button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6 flex-shrink-0">
@@ -438,7 +462,7 @@ const Packages = () => {
>
<option value="all">All Hosts</option>
{hosts?.map(host => (
<option key={host.id} value={host.id}>{host.friendlyName}</option>
<option key={host.id} value={host.id}>{host.friendly_name}</option>
))}
</select>
</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

@@ -33,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({
@@ -141,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 ${
@@ -251,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>
@@ -517,9 +555,45 @@ const TfaTab = () => {
regenerateBackupCodesMutation.mutate()
}
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text)
setMessage({ type: 'success', text: 'Copied to clipboard!' })
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 = () => {

View File

@@ -19,7 +19,8 @@ import {
ArrowDown,
X,
GripVertical,
Check
Check,
RefreshCw
} from 'lucide-react';
import { repositoryAPI } from '../utils/api';
@@ -60,7 +61,7 @@ const Repositories = () => {
};
// 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)
});
@@ -132,14 +133,6 @@ const Repositories = () => {
repo.url.toLowerCase().includes(searchTerm.toLowerCase()) ||
repo.distribution.toLowerCase().includes(searchTerm.toLowerCase());
// Debug logging
console.log('Filtering repo:', {
name: repo.name,
isSecure: repo.isSecure,
filterType,
url: repo.url
});
// Check security based on URL if isSecure property doesn't exist
const isSecure = repo.isSecure !== undefined ? repo.isSecure : repo.url.startsWith('https://');
@@ -148,15 +141,8 @@ const Repositories = () => {
(filterType === 'insecure' && !isSecure);
const matchesStatus = filterStatus === 'all' ||
(filterStatus === 'active' && repo.isActive === true) ||
(filterStatus === 'inactive' && repo.isActive === false);
console.log('Filter results:', {
matchesSearch,
matchesType,
matchesStatus,
final: matchesSearch && matchesType && matchesStatus
});
(filterStatus === 'active' && repo.is_active === true) ||
(filterStatus === 'inactive' && repo.is_active === false);
return matchesSearch && matchesType && matchesStatus;
});
@@ -171,8 +157,8 @@ const Repositories = () => {
aValue = a.isSecure ? 'Secure' : 'Insecure';
bValue = b.isSecure ? 'Secure' : 'Insecure';
} else if (sortField === 'status') {
aValue = a.isActive ? 'Active' : 'Inactive';
bValue = b.isActive ? 'Active' : 'Inactive';
aValue = a.is_active ? 'Active' : 'Inactive';
bValue = b.is_active ? 'Active' : 'Inactive';
}
if (typeof aValue === 'string') {
@@ -211,6 +197,26 @@ const Repositories = () => {
return (
<div className="h-[calc(100vh-7rem)] flex flex-col overflow-hidden">
{/* Page Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-semibold text-secondary-900 dark:text-white">Repositories</h1>
<p className="text-sm text-secondary-600 dark:text-secondary-400 mt-1">
Manage and monitor your package repositories
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => refetch()}
disabled={isFetching}
className="btn-outline flex items-center gap-2"
title="Refresh repositories data"
>
<RefreshCw className={`h-4 w-4 ${isFetching ? 'animate-spin' : ''}`} />
{isFetching ? 'Refreshing...' : 'Refresh'}
</button>
</div>
</div>
{/* Summary Stats */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-6 flex-shrink-0">
@@ -426,18 +432,18 @@ const Repositories = () => {
case 'status':
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
repo.isActive
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.isActive ? 'Active' : 'Inactive'}
{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.hostCount}</span>
<span>{repo.host_count}</span>
</div>
)
case 'actions':

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.friendlyName}
{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>

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Save, Server, Globe, Shield, AlertCircle, CheckCircle, Code, Plus, Trash2, Star, Download, X, Settings as SettingsIcon, Clock } from 'lucide-react';
import { settingsAPI, agentVersionAPI, versionAPI } from '../utils/api';
import { Save, Server, Shield, AlertCircle, CheckCircle, Code, Plus, Trash2, Star, Download, X, Settings as SettingsIcon, Clock } from 'lucide-react';
import { settingsAPI, agentVersionAPI, versionAPI, permissionsAPI } from '../utils/api';
import { useUpdateNotification } from '../contexts/UpdateNotificationContext';
import UpgradeNotificationIcon from '../components/UpgradeNotificationIcon';
@@ -10,9 +10,10 @@ const Settings = () => {
serverProtocol: 'http',
serverHost: 'localhost',
serverPort: 3001,
frontendUrl: 'http://localhost:3000',
updateInterval: 60,
autoUpdate: false,
signupEnabled: false,
defaultUserRole: 'user',
githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: 'public',
sshKeyPath: '',
@@ -20,21 +21,20 @@ const Settings = () => {
});
const [errors, setErrors] = useState({});
const [isDirty, setIsDirty] = useState(false);
// Tab management
const [activeTab, setActiveTab] = useState('server');
// Get update notification state
const { updateAvailable } = useUpdateNotification();
// Tab configuration
const tabs = [
{ id: 'server', name: 'Server Configuration', icon: Server },
{ id: 'frontend', name: 'Frontend Configuration', icon: Globe },
{ id: 'agent', name: 'Agent Management', icon: SettingsIcon },
{ id: 'version', name: 'Server Version', icon: Code, showUpgradeIcon: updateAvailable }
];
// Agent version management state
const [showAgentVersionModal, setShowAgentVersionModal] = useState(false);
const [editingAgentVersion, setEditingAgentVersion] = useState(null);
@@ -47,13 +47,13 @@ const Settings = () => {
// Version checking state
const [versionInfo, setVersionInfo] = useState({
currentVersion: '1.2.4',
currentVersion: null, // Will be loaded from API
latestVersion: null,
isUpdateAvailable: false,
checking: false,
error: null
});
const [sshTestResult, setSshTestResult] = useState({
testing: false,
success: null,
@@ -69,24 +69,28 @@ const Settings = () => {
queryFn: () => settingsAPI.get().then(res => res.data)
});
// Fetch available roles for default user role dropdown
const { data: roles, isLoading: rolesLoading } = useQuery({
queryKey: ['rolePermissions'],
queryFn: () => permissionsAPI.getRoles().then(res => res.data)
});
// Update form data when settings are loaded
useEffect(() => {
if (settings) {
console.log('Settings loaded:', settings);
console.log('updateInterval from settings:', settings.updateInterval);
const newFormData = {
serverProtocol: settings.serverProtocol || 'http',
serverHost: settings.serverHost || 'localhost',
serverPort: settings.serverPort || 3001,
frontendUrl: settings.frontendUrl || 'http://localhost:3000',
updateInterval: settings.updateInterval || 60,
autoUpdate: settings.autoUpdate || false,
githubRepoUrl: settings.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: settings.repositoryType || 'public',
sshKeyPath: settings.sshKeyPath || '',
useCustomSshKey: !!settings.sshKeyPath
serverProtocol: settings.server_protocol || 'http',
serverHost: settings.server_host || 'localhost',
serverPort: settings.server_port || 3001,
updateInterval: settings.update_interval || 60,
autoUpdate: settings.auto_update || false,
signupEnabled: settings.signup_enabled === true ? true : false, // Explicit boolean conversion
defaultUserRole: settings.default_user_role || 'user',
githubRepoUrl: settings.github_repo_url || 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: settings.repository_type || 'public',
sshKeyPath: settings.ssh_key_path || '',
useCustomSshKey: !!settings.ssh_key_path
};
console.log('Setting form data to:', newFormData);
setFormData(newFormData);
setIsDirty(false);
}
@@ -95,34 +99,14 @@ const Settings = () => {
// Update settings mutation
const updateSettingsMutation = useMutation({
mutationFn: (data) => {
console.log('Mutation called with data:', data);
return settingsAPI.update(data).then(res => {
console.log('API response:', res);
return res.data;
});
return settingsAPI.update(data).then(res => res.data);
},
onSuccess: (data) => {
console.log('Mutation success:', data);
console.log('Invalidating queries and updating form data');
queryClient.invalidateQueries(['settings']);
// Update form data with the returned data
setFormData({
serverProtocol: data.settings?.serverProtocol || data.serverProtocol || 'http',
serverHost: data.settings?.serverHost || data.serverHost || 'localhost',
serverPort: data.settings?.serverPort || data.serverPort || 3001,
frontendUrl: data.settings?.frontendUrl || data.frontendUrl || 'http://localhost:3000',
updateInterval: data.settings?.updateInterval || data.updateInterval || 60,
autoUpdate: data.settings?.autoUpdate || data.autoUpdate || false,
githubRepoUrl: data.settings?.githubRepoUrl || data.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git',
repositoryType: data.settings?.repositoryType || data.repositoryType || 'public',
sshKeyPath: data.settings?.sshKeyPath || data.sshKeyPath || '',
useCustomSshKey: !!(data.settings?.sshKeyPath || data.sshKeyPath)
});
setIsDirty(false);
setErrors({});
},
onError: (error) => {
console.log('Mutation error:', error);
if (error.response?.data?.errors) {
setErrors(error.response.data.errors.reduce((acc, err) => {
acc[err.path] = err.msg;
@@ -138,20 +122,30 @@ const Settings = () => {
const { data: agentVersions, isLoading: agentVersionsLoading, error: agentVersionsError } = useQuery({
queryKey: ['agentVersions'],
queryFn: () => {
console.log('Fetching agent versions...');
return agentVersionAPI.list().then(res => {
console.log('Agent versions API response:', res);
return res.data;
});
}
});
// Debug agent versions
// Load current version on component mount
useEffect(() => {
console.log('Agent versions data:', agentVersions);
console.log('Agent versions loading:', agentVersionsLoading);
console.log('Agent versions error:', agentVersionsError);
}, [agentVersions, agentVersionsLoading, agentVersionsError]);
const loadCurrentVersion = async () => {
try {
const response = await versionAPI.getCurrent();
const data = response.data;
setVersionInfo(prev => ({
...prev,
currentVersion: data.version
}));
} catch (error) {
console.error('Error loading current version:', error);
}
};
loadCurrentVersion();
}, []);
const createAgentVersionMutation = useMutation({
mutationFn: (data) => agentVersionAPI.create(data).then(res => res.data),
@@ -180,22 +174,36 @@ const Settings = () => {
mutationFn: (id) => agentVersionAPI.delete(id).then(res => res.data),
onSuccess: () => {
queryClient.invalidateQueries(['agentVersions']);
},
onError: (error) => {
console.error('Delete agent version error:', error);
// Show user-friendly error message
if (error.response?.data?.error === 'Agent version not found') {
alert('Agent version not found. Please refresh the page to get the latest data.');
// Force refresh the agent versions list
queryClient.invalidateQueries(['agentVersions']);
} else if (error.response?.data?.error === 'Cannot delete current agent version') {
alert('Cannot delete the current agent version. Please set another version as current first.');
} else {
alert(`Failed to delete agent version: ${error.response?.data?.error || error.message}`);
}
}
});
// Version checking functions
const checkForUpdates = async () => {
setVersionInfo(prev => ({ ...prev, checking: true, error: null }));
try {
const response = await versionAPI.checkUpdates();
const data = response.data;
setVersionInfo({
currentVersion: data.currentVersion,
latestVersion: data.latestVersion,
isUpdateAvailable: data.isUpdateAvailable,
lastUpdateCheck: data.lastUpdateCheck,
last_update_check: data.last_update_check,
checking: false,
error: null
});
@@ -221,13 +229,13 @@ const Settings = () => {
}
setSshTestResult({ testing: true, success: null, message: null, error: null });
try {
const response = await versionAPI.testSshKey({
sshKeyPath: formData.sshKeyPath,
githubRepoUrl: formData.githubRepoUrl
});
setSshTestResult({
testing: false,
success: true,
@@ -246,10 +254,8 @@ const Settings = () => {
};
const handleInputChange = (field, value) => {
console.log(`handleInputChange: ${field} = ${value}`);
setFormData(prev => {
const newData = { ...prev, [field]: value };
console.log('New form data:', newData);
return newData;
});
setIsDirty(true);
@@ -260,7 +266,7 @@ const Settings = () => {
const handleSubmit = (e) => {
e.preventDefault();
// Only include sshKeyPath if the toggle is enabled
const dataToSubmit = { ...formData };
if (!dataToSubmit.useCustomSshKey) {
@@ -268,40 +274,31 @@ const Settings = () => {
}
// Remove the frontend-only field
delete dataToSubmit.useCustomSshKey;
updateSettingsMutation.mutate(dataToSubmit);
};
const validateForm = () => {
const newErrors = {};
if (!formData.serverHost.trim()) {
newErrors.serverHost = 'Server host is required';
}
if (!formData.serverPort || formData.serverPort < 1 || formData.serverPort > 65535) {
newErrors.serverPort = 'Port must be between 1 and 65535';
}
try {
new URL(formData.frontendUrl);
} catch {
newErrors.frontendUrl = 'Frontend URL must be a valid URL';
}
if (!formData.updateInterval || formData.updateInterval < 5 || formData.updateInterval > 1440) {
newErrors.updateInterval = 'Update interval must be between 5 and 1440 minutes';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSave = () => {
console.log('Saving settings:', formData);
if (validateForm()) {
console.log('Validation passed, calling mutation');
// Prepare data for submission
const dataToSubmit = { ...formData };
if (!dataToSubmit.useCustomSshKey) {
@@ -309,11 +306,8 @@ const Settings = () => {
}
// Remove the frontend-only field
delete dataToSubmit.useCustomSshKey;
console.log('Submitting data with githubRepoUrl:', dataToSubmit.githubRepoUrl);
updateSettingsMutation.mutate(dataToSubmit);
} else {
console.log('Validation failed:', errors);
}
};
@@ -396,7 +390,7 @@ const Settings = () => {
<Server className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">Server Configuration</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
@@ -458,32 +452,88 @@ const Settings = () => {
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
This URL will be used in installation scripts and agent communications.
</p>
</div>
</div>
{/* Update Interval */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Agent Update Interval (minutes)
</label>
<input
type="number"
min="5"
max="1440"
value={formData.updateInterval}
onChange={(e) => {
console.log('Update interval input changed:', e.target.value);
handleInputChange('updateInterval', parseInt(e.target.value) || 60);
}}
className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.updateInterval ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
}`}
placeholder="60"
/>
{/* Numeric input (concise width) */}
<div className="flex items-center gap-2">
<input
type="number"
min="5"
max="1440"
step="5"
value={formData.updateInterval}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val)) {
handleInputChange('updateInterval', Math.min(1440, Math.max(5, val)));
} else {
handleInputChange('updateInterval', 60);
}
}}
className={`w-28 border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.updateInterval ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
}`}
placeholder="60"
/>
</div>
{/* Quick presets */}
<div className="mt-3 flex flex-wrap items-center gap-2">
{[15, 30, 60, 120, 360, 720, 1440].map((m) => (
<button
key={m}
type="button"
onClick={() => handleInputChange('updateInterval', m)}
className={`px-3 py-1.5 rounded-full text-xs font-medium border ${
formData.updateInterval === m
? 'bg-primary-600 text-white border-primary-600'
: 'bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 border-secondary-300 dark:border-secondary-600 hover:bg-secondary-50 dark:hover:bg-secondary-600'
}`}
aria-label={`Set ${m} minutes`}
>
{m % 60 === 0 ? `${m / 60}h` : `${m}m`}
</button>
))}
</div>
{/* Range slider */}
<div className="mt-4">
<input
type="range"
min="5"
max="1440"
step="5"
value={formData.updateInterval}
onChange={(e) => handleInputChange('updateInterval', parseInt(e.target.value))}
className="w-full accent-primary-600"
aria-label="Update interval slider"
/>
</div>
{errors.updateInterval && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.updateInterval}</p>
)}
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
How often agents should check for updates (5-1440 minutes). This affects new installations.
{/* Helper text */}
<div className="mt-2 text-sm text-secondary-600 dark:text-secondary-300">
<span className="font-medium">Effective cadence:</span>{' '}
{(() => {
const mins = parseInt(formData.updateInterval) || 60;
if (mins < 60) return `${mins} minute${mins === 1 ? '' : 's'}`;
const hrs = Math.floor(mins / 60);
const rem = mins % 60;
return `${hrs} hour${hrs === 1 ? '' : 's'}${rem ? ` ${rem} min` : ''}`;
})()}
</div>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
This affects new installations and will update existing ones when they next reach out.
</p>
</div>
@@ -505,6 +555,55 @@ const Settings = () => {
</p>
</div>
{/* User Signup Setting */}
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.signupEnabled}
onChange={(e) => handleInputChange('signupEnabled', e.target.checked)}
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
/>
Enable User Self-Registration
</div>
</label>
{/* Default User Role Dropdown */}
{formData.signupEnabled && (
<div className="mt-3 ml-6">
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Default Role for New Users
</label>
<select
value={formData.defaultUserRole}
onChange={(e) => handleInputChange('defaultUserRole', e.target.value)}
className="w-full max-w-xs border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
disabled={rolesLoading}
>
{rolesLoading ? (
<option>Loading roles...</option>
) : roles && Array.isArray(roles) ? (
roles.map((role) => (
<option key={role.role} value={role.role}>
{role.role.charAt(0).toUpperCase() + role.role.slice(1)}
</option>
))
) : (
<option value="user">User</option>
)}
</select>
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
New users will be assigned this role when they register.
</p>
</div>
)}
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
When enabled, users can create their own accounts through the signup page. When disabled, only administrators can create user accounts.
</p>
</div>
{/* Security Notice */}
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
<div className="flex">
@@ -512,7 +611,7 @@ const Settings = () => {
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">Security Notice</h3>
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
Changing these settings will affect all installation scripts and agent communications.
Changing these settings will affect all installation scripts and agent communications.
Make sure the server URL is accessible from your client networks.
</p>
</div>
@@ -558,74 +657,6 @@ const Settings = () => {
</form>
)}
{/* Frontend Configuration Tab */}
{activeTab === 'frontend' && (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="flex items-center mb-6">
<Globe className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">Frontend Configuration</h2>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
Frontend URL *
</label>
<input
type="url"
value={formData.frontendUrl}
onChange={(e) => handleInputChange('frontendUrl', e.target.value)}
className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
errors.frontendUrl ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
}`}
placeholder="https://patchmon.example.com"
/>
{errors.frontendUrl && (
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.frontendUrl}</p>
)}
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
The URL where users will access the PatchMon web interface.
</p>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={!isDirty || updateSettingsMutation.isPending}
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
!isDirty || updateSettingsMutation.isPending
? 'bg-secondary-400 cursor-not-allowed'
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
}`}
>
{updateSettingsMutation.isPending ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Saving...
</>
) : (
<>
<Save className="h-4 w-4 mr-2" />
Save Settings
</>
)}
</button>
</div>
{updateSettingsMutation.isSuccess && (
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
<div className="ml-3">
<p className="text-sm text-green-700 dark:text-green-300">Settings saved successfully!</p>
</div>
</div>
</div>
)}
</form>
)}
{/* Agent Management Tab */}
{activeTab === 'agent' && (
<div className="space-y-6">
@@ -656,14 +687,14 @@ const Settings = () => {
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Current Version:</span>
<span className="text-sm text-secondary-900 dark:text-white font-mono">
{agentVersions.find(v => v.isCurrent)?.version || 'None'}
{agentVersions.find(v => v.is_current)?.version || 'None'}
</span>
</div>
<div className="flex items-center gap-2">
<Star className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Default Version:</span>
<span className="text-sm text-secondary-900 dark:text-white font-mono">
{agentVersions.find(v => v.isDefault)?.version || 'None'}
{agentVersions.find(v => v.is_default)?.version || 'None'}
</span>
</div>
</div>
@@ -694,27 +725,27 @@ const Settings = () => {
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
Version {version.version}
</h3>
{version.isDefault && (
{version.is_default && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200">
<Star className="h-3 w-3 mr-1" />
Default
</span>
)}
{version.isCurrent && (
{version.is_current && (
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Current
</span>
)}
</div>
{version.releaseNotes && (
{version.release_notes && (
<div className="text-sm text-secondary-500 dark:text-secondary-300 mt-1">
<p className="line-clamp-3 whitespace-pre-line">
{version.releaseNotes}
{version.release_notes}
</p>
</div>
)}
<p className="text-xs text-secondary-400 dark:text-secondary-400 mt-1">
Created: {new Date(version.createdAt).toLocaleDateString()}
Created: {new Date(version.created_at).toLocaleDateString()}
</p>
</div>
</div>
@@ -731,7 +762,7 @@ const Settings = () => {
</button>
<button
onClick={() => setCurrentAgentVersionMutation.mutate(version.id)}
disabled={version.isCurrent || setCurrentAgentVersionMutation.isPending}
disabled={version.is_current || setCurrentAgentVersionMutation.isPending}
className="btn-outline text-xs flex items-center gap-1"
>
<CheckCircle className="h-3 w-3" />
@@ -739,7 +770,7 @@ const Settings = () => {
</button>
<button
onClick={() => setDefaultAgentVersionMutation.mutate(version.id)}
disabled={version.isDefault || setDefaultAgentVersionMutation.isPending}
disabled={version.is_default || setDefaultAgentVersionMutation.isPending}
className="btn-outline text-xs flex items-center gap-1"
>
<Star className="h-3 w-3" />
@@ -747,7 +778,7 @@ const Settings = () => {
</button>
<button
onClick={() => deleteAgentVersionMutation.mutate(version.id)}
disabled={version.isDefault || version.isCurrent || deleteAgentVersionMutation.isPending}
disabled={version.is_default || version.is_current || deleteAgentVersionMutation.isPending}
className="btn-danger text-xs flex items-center gap-1"
>
<Trash2 className="h-3 w-3" />
@@ -757,7 +788,7 @@ const Settings = () => {
</div>
</div>
))}
{agentVersions?.length === 0 && (
<div className="text-center py-8">
<Code className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
@@ -779,13 +810,13 @@ const Settings = () => {
<Code className="h-6 w-6 text-primary-600 mr-3" />
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">Server Version Management</h2>
</div>
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6">
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Version Check Configuration</h3>
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
Configure automatic version checking against your GitHub repository to notify users of available updates.
</p>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
@@ -825,7 +856,7 @@ const Settings = () => {
Choose whether your repository is public or private to determine the appropriate access method.
</p>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
GitHub Repository URL
@@ -841,7 +872,7 @@ const Settings = () => {
SSH or HTTPS URL to your GitHub repository
</p>
</div>
{formData.repositoryType === 'private' && (
<div>
<div className="flex items-center gap-3 mb-3">
@@ -862,7 +893,7 @@ const Settings = () => {
Set custom SSH key path
</label>
</div>
{formData.useCustomSshKey && (
<div>
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
@@ -878,7 +909,7 @@ const Settings = () => {
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
Path to your SSH deploy key. If not set, will auto-detect from common locations.
</p>
<div className="mt-3">
<button
type="button"
@@ -888,7 +919,7 @@ const Settings = () => {
>
{sshTestResult.testing ? 'Testing...' : 'Test SSH Key'}
</button>
{sshTestResult.success && (
<div className="mt-2 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-md">
<div className="flex items-center">
@@ -899,7 +930,7 @@ const Settings = () => {
</div>
</div>
)}
{sshTestResult.error && (
<div className="mt-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md">
<div className="flex items-center">
@@ -913,7 +944,7 @@ const Settings = () => {
</div>
</div>
)}
{!formData.useCustomSshKey && (
<p className="text-xs text-secondary-500 dark:text-secondary-400">
Using auto-detection for SSH key location
@@ -921,7 +952,7 @@ const Settings = () => {
)}
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
@@ -930,7 +961,7 @@ const Settings = () => {
</div>
<span className="text-lg font-mono text-secondary-900 dark:text-white">{versionInfo.currentVersion}</span>
</div>
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<Download className="h-4 w-4 text-blue-600 dark:text-blue-400" />
@@ -950,23 +981,23 @@ const Settings = () => {
</span>
</div>
</div>
{/* Last Checked Time */}
{versionInfo.lastUpdateCheck && (
{versionInfo.last_update_check && (
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
<div className="flex items-center gap-2 mb-2">
<Clock className="h-4 w-4 text-blue-600 dark:text-blue-400" />
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Last Checked</span>
</div>
<span className="text-sm text-secondary-600 dark:text-secondary-400">
{new Date(versionInfo.lastUpdateCheck).toLocaleString()}
{new Date(versionInfo.last_update_check).toLocaleString()}
</span>
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
Updates are checked automatically every 24 hours
</p>
</div>
)}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button
@@ -978,7 +1009,7 @@ const Settings = () => {
{versionInfo.checking ? 'Checking...' : 'Check for Updates'}
</button>
</div>
{/* Save Button for Version Settings */}
<button
type="button"
@@ -1003,7 +1034,7 @@ const Settings = () => {
)}
</button>
</div>
{versionInfo.error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-lg p-4">
<div className="flex">
@@ -1022,7 +1053,7 @@ const Settings = () => {
</div>
</div>
)}
{/* Success Message for Version Settings */}
{updateSettingsMutation.isSuccess && (
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
@@ -1036,7 +1067,7 @@ const Settings = () => {
)}
</div>
</div>
</div>
)}
</div>
@@ -1070,17 +1101,17 @@ const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => {
const handleSubmit = (e) => {
e.preventDefault();
// Basic validation
const newErrors = {};
if (!formData.version.trim()) newErrors.version = 'Version is required';
if (!formData.scriptContent.trim()) newErrors.scriptContent = 'Script content is required';
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
onSubmit(formData);
};
@@ -1111,7 +1142,7 @@ const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => {
</button>
</div>
</div>
<form onSubmit={handleSubmit} className="px-6 py-4">
<div className="space-y-4">
<div>
@@ -1184,7 +1215,7 @@ const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => {
</label>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
type="button"

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

@@ -53,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)
@@ -60,11 +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 }),
updateFriendlyName: (hostId, friendlyName) => api.patch(`/hosts/${hostId}/friendly-name`, { friendlyName })
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
@@ -156,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
@@ -198,6 +201,7 @@ export const versionAPI = {
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

103
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "patchmon",
"version": "1.2.4",
"version": "1.2.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "patchmon",
"version": "1.2.4",
"version": "1.2.6",
"workspaces": [
"backend",
"frontend"
@@ -20,7 +20,7 @@
},
"backend": {
"name": "patchmon-backend",
"version": "1.2.4",
"version": "1.2.6",
"dependencies": {
"@prisma/client": "^5.7.0",
"bcryptjs": "^2.4.3",
@@ -47,7 +47,7 @@
},
"frontend": {
"name": "patchmon-frontend",
"version": "1.2.4",
"version": "1.2.6",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
@@ -59,6 +59,7 @@
"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",
@@ -1773,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",
@@ -2249,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"
@@ -3495,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",
@@ -3640,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"
@@ -4152,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",
@@ -4408,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"
@@ -4462,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"
@@ -4501,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"
@@ -4534,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",
@@ -5105,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",
@@ -5675,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"
@@ -6356,6 +6424,12 @@
"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",
@@ -7350,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"
@@ -7553,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",

View File

@@ -1,6 +1,6 @@
{
"name": "patchmon",
"version": "1.2.5",
"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