mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-23 07:42:05 +00:00
first commit
This commit is contained in:
137
.gitignore
vendored
Normal file
137
.gitignore
vendored
Normal file
@@ -0,0 +1,137 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.production
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Runtime data
|
||||
pids/
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
jspm_packages/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
public
|
||||
|
||||
# Storybook build outputs
|
||||
.out
|
||||
.storybook-out
|
||||
|
||||
# Temporary folders
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# Prisma
|
||||
prisma/migrations/dev.db*
|
||||
|
||||
# Backend specific
|
||||
backend/logs/
|
||||
backend/uploads/
|
||||
backend/temp/
|
||||
|
||||
# Frontend specific
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
|
||||
# Agent specific
|
||||
agents/*.log
|
||||
|
||||
# SSL certificates
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.csr
|
||||
|
||||
# Backup files
|
||||
*.bak
|
||||
*.backup
|
||||
*.old
|
||||
|
||||
# Test files
|
||||
test-results/
|
||||
playwright-report/
|
||||
test-results.xml
|
||||
|
||||
# Package manager lock files (uncomment if you want to ignore them)
|
||||
# package-lock.json
|
||||
# yarn.lock
|
||||
# pnpm-lock.yaml
|
434
README.md
Normal file
434
README.md
Normal file
@@ -0,0 +1,434 @@
|
||||
# PatchMon - Linux Patch Monitoring System
|
||||
|
||||
A comprehensive system for monitoring Linux package updates across multiple hosts with a modern web interface and automated agent deployment.
|
||||
|
||||
## Features
|
||||
|
||||
- **Multi-Host Monitoring**: Monitor package updates across multiple Linux servers
|
||||
- **Real-time Dashboard**: Web-based dashboard with statistics and host management
|
||||
- **Automated Agents**: Lightweight agents for automatic data collection
|
||||
- **Host Grouping**: Organize hosts into groups for better management
|
||||
- **Repository Tracking**: Monitor APT/YUM repositories and their usage
|
||||
- **Security Updates**: Track security-specific package updates
|
||||
- **User Management**: Role-based access control with granular permissions
|
||||
- **Dark Mode**: Modern UI with dark/light theme support
|
||||
- **Agent Versioning**: Manage and auto-update agent versions
|
||||
- **API Credentials**: Secure agent authentication system
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Node.js**: 18.0.0 or higher
|
||||
- **PostgreSQL**: 12 or higher
|
||||
- **Linux**: Ubuntu, Debian, CentOS, RHEL, or Fedora (for agents)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Clone and Install
|
||||
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd patchmon
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Database Setup
|
||||
|
||||
Create a PostgreSQL database:
|
||||
|
||||
```sql
|
||||
CREATE DATABASE patchmon;
|
||||
CREATE USER patchmon_user WITH PASSWORD 'your_secure_password';
|
||||
GRANT ALL PRIVILEGES ON DATABASE patchmon TO patchmon_user;
|
||||
```
|
||||
|
||||
### 3. Environment Configuration
|
||||
|
||||
Create `.env` file in the project root:
|
||||
|
||||
```bash
|
||||
# Database
|
||||
DATABASE_URL="postgresql://patchmon_user:your_secure_password@localhost:5432/patchmon?schema=public"
|
||||
|
||||
# Backend
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
API_VERSION=v1
|
||||
|
||||
# Security
|
||||
CORS_ORIGINS=https://your-frontend.example
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX=100
|
||||
AUTH_RATE_LIMIT_WINDOW_MS=600000
|
||||
AUTH_RATE_LIMIT_MAX=20
|
||||
AGENT_RATE_LIMIT_WINDOW_MS=60000
|
||||
AGENT_RATE_LIMIT_MAX=120
|
||||
ENABLE_HSTS=true
|
||||
TRUST_PROXY=1
|
||||
JSON_BODY_LIMIT=5mb
|
||||
ENABLE_LOGGING=false
|
||||
LOG_LEVEL=info
|
||||
|
||||
# JWT Secret (generate a strong secret)
|
||||
JWT_SECRET=your-super-secure-jwt-secret-here
|
||||
|
||||
# Frontend
|
||||
VITE_API_URL=https://your-api.example/api/v1
|
||||
```
|
||||
|
||||
### 4. Database Migration
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npx prisma migrate deploy
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
### 5. Create Admin User
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
node -e "
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function createAdmin() {
|
||||
const hashedPassword = await bcrypt.hash('admin123', 10);
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
password: hashedPassword,
|
||||
role: 'admin'
|
||||
}
|
||||
});
|
||||
console.log('Admin user created: admin / admin123');
|
||||
await prisma.\$disconnect();
|
||||
}
|
||||
|
||||
createAdmin().catch(console.error);
|
||||
"
|
||||
```
|
||||
|
||||
### 6. Start Services
|
||||
|
||||
**Development:**
|
||||
```bash
|
||||
# Start both backend and frontend
|
||||
npm run dev
|
||||
|
||||
# Or start individually
|
||||
npm run dev:backend
|
||||
npm run dev:frontend
|
||||
```
|
||||
|
||||
**Production:**
|
||||
```bash
|
||||
# Build frontend
|
||||
npm run build:frontend
|
||||
|
||||
# Start backend
|
||||
cd backend
|
||||
npm start
|
||||
```
|
||||
|
||||
### 7. Access the Application
|
||||
|
||||
- **Frontend**: http://localhost:3000
|
||||
- **Backend API**: http://localhost:3001
|
||||
- **Default Login**: admin / admin123
|
||||
|
||||
## Agent Installation
|
||||
|
||||
### Automatic Installation
|
||||
|
||||
1. **Create a Host** in the web interface
|
||||
2. **Copy the installation command** from the host detail page
|
||||
3. **Run on your Linux server**:
|
||||
|
||||
```bash
|
||||
curl -sSL https://your-patchmon-server.com/api/v1/hosts/install | bash -s -- your-api-id your-api-key
|
||||
```
|
||||
|
||||
### Manual Installation
|
||||
|
||||
1. **Download the agent script**:
|
||||
```bash
|
||||
wget https://your-patchmon-server.com/api/v1/hosts/agent/download
|
||||
chmod +x patchmon-agent.sh
|
||||
sudo mv patchmon-agent.sh /usr/local/bin/
|
||||
```
|
||||
|
||||
2. **Configure with API credentials**:
|
||||
```bash
|
||||
sudo /usr/local/bin/patchmon-agent.sh configure your-api-id your-api-key
|
||||
```
|
||||
|
||||
3. **Test the connection**:
|
||||
```bash
|
||||
sudo /usr/local/bin/patchmon-agent.sh test
|
||||
```
|
||||
|
||||
4. **Start monitoring**:
|
||||
```bash
|
||||
sudo /usr/local/bin/patchmon-agent.sh update
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Backend Configuration
|
||||
|
||||
Key environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `DATABASE_URL` | Required | PostgreSQL connection string |
|
||||
| `JWT_SECRET` | Required | Secret for JWT token signing |
|
||||
| `CORS_ORIGINS` | `http://localhost:3000` | Comma-separated allowed origins |
|
||||
| `RATE_LIMIT_MAX` | `100` | Max requests per window |
|
||||
| `RATE_LIMIT_WINDOW_MS` | `900000` | Rate limit window (15 min) |
|
||||
| `ENABLE_LOGGING` | `false` | Enable file logging |
|
||||
| `TRUST_PROXY` | `1` | Trust reverse proxy headers |
|
||||
|
||||
### Frontend Configuration
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `VITE_API_URL` | `/api/v1` | Backend API URL |
|
||||
| `VITE_ENABLE_LOGGING` | `false` | Enable dev server logging |
|
||||
|
||||
### Agent Configuration
|
||||
|
||||
The agent automatically configures itself with:
|
||||
- **Update interval**: Set in PatchMon settings (default: 60 minutes)
|
||||
- **Auto-update**: Can be enabled per-host or globally
|
||||
- **Repository tracking**: Automatically detects APT/YUM repositories
|
||||
|
||||
## User Management
|
||||
|
||||
### Roles and Permissions
|
||||
|
||||
- **Admin**: Full system access
|
||||
- **Manager**: Host and package management
|
||||
- **Viewer**: Read-only access
|
||||
|
||||
### Creating Users
|
||||
|
||||
1. **Via Web Interface**: Admin → Users → Add User
|
||||
2. **Via API**: POST `/api/v1/auth/admin/users`
|
||||
|
||||
## Host Management
|
||||
|
||||
### Adding Hosts
|
||||
|
||||
1. **Web Interface**: Hosts → Add Host
|
||||
2. **API**: POST `/api/v1/hosts/create`
|
||||
|
||||
### Host Groups
|
||||
|
||||
Organize hosts into groups for better management:
|
||||
- Create groups in Host Groups section
|
||||
- Assign hosts to groups
|
||||
- View group-specific statistics
|
||||
|
||||
## Monitoring and Alerts
|
||||
|
||||
### Dashboard
|
||||
|
||||
- **Overview**: Total hosts, packages, updates needed
|
||||
- **Host Status**: Online/offline status
|
||||
- **Update Statistics**: Security updates, regular updates
|
||||
- **Recent Activity**: Latest host updates
|
||||
|
||||
### Package Management
|
||||
|
||||
- **Package List**: All packages across all hosts
|
||||
- **Update Status**: Which packages need updates
|
||||
- **Security Updates**: Critical security patches
|
||||
- **Host Dependencies**: Which hosts use specific packages
|
||||
|
||||
## Security Features
|
||||
|
||||
### Authentication
|
||||
|
||||
- **JWT Tokens**: Secure session management
|
||||
- **API Credentials**: Per-host authentication
|
||||
- **Password Hashing**: bcrypt with salt rounds
|
||||
|
||||
### Security Headers
|
||||
|
||||
- **Helmet.js**: Security headers (CSP, HSTS, etc.)
|
||||
- **CORS**: Configurable origin restrictions
|
||||
- **Rate Limiting**: Per-route rate limits
|
||||
- **Input Validation**: express-validator on all endpoints
|
||||
|
||||
### Agent Security
|
||||
|
||||
- **HTTPS Only**: Agents use HTTPS for communication
|
||||
- **API Key Rotation**: Regenerate credentials when needed
|
||||
- **Secure Storage**: Credentials stored in protected files
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Agent Connection Failed:**
|
||||
```bash
|
||||
# Check agent configuration
|
||||
sudo /usr/local/bin/patchmon-agent.sh test
|
||||
|
||||
# Verify API credentials
|
||||
sudo /usr/local/bin/patchmon-agent.sh ping
|
||||
```
|
||||
|
||||
**Database Connection Issues:**
|
||||
```bash
|
||||
# Test database connection
|
||||
cd backend
|
||||
npx prisma db push
|
||||
|
||||
# Check migration status
|
||||
npx prisma migrate status
|
||||
```
|
||||
|
||||
**Frontend Build Issues:**
|
||||
```bash
|
||||
# Clear node_modules and reinstall
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
|
||||
# Rebuild
|
||||
npm run build:frontend
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
**Backend Logs:**
|
||||
- Enable logging: `ENABLE_LOGGING=true`
|
||||
- Log files: `backend/logs/`
|
||||
|
||||
**Agent Logs:**
|
||||
- Log file: `/var/log/patchmon-agent.log`
|
||||
- Debug mode: `sudo /usr/local/bin/patchmon-agent.sh diagnostics`
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
patchmon/
|
||||
├── backend/ # Express.js API server
|
||||
│ ├── src/
|
||||
│ │ ├── routes/ # API route handlers
|
||||
│ │ ├── middleware/ # Authentication, validation
|
||||
│ │ └── server.js # Main server file
|
||||
│ ├── prisma/ # Database schema and migrations
|
||||
│ └── package.json
|
||||
├── frontend/ # React.js web application
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Reusable UI components
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── contexts/ # React contexts
|
||||
│ │ └── utils/ # Utility functions
|
||||
│ └── package.json
|
||||
├── agents/ # Agent scripts
|
||||
│ └── patchmon-agent.sh # Main agent script
|
||||
└── README.md
|
||||
```
|
||||
|
||||
### API Documentation
|
||||
|
||||
**Authentication:**
|
||||
- `POST /api/v1/auth/login` - User login
|
||||
- `POST /api/v1/auth/logout` - User logout
|
||||
- `GET /api/v1/auth/me` - Get current user
|
||||
|
||||
**Hosts:**
|
||||
- `GET /api/v1/hosts` - List hosts
|
||||
- `POST /api/v1/hosts/create` - Create host
|
||||
- `POST /api/v1/hosts/update` - Agent update (API credentials)
|
||||
- `DELETE /api/v1/hosts/:id` - Delete host
|
||||
|
||||
**Packages:**
|
||||
- `GET /api/v1/packages` - List packages
|
||||
- `GET /api/v1/packages/:id` - Get package details
|
||||
- `GET /api/v1/packages/search/:query` - Search packages
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Reverse Proxy (Nginx)
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-patchmon.example.com;
|
||||
|
||||
ssl_certificate /path/to/cert.pem;
|
||||
ssl_certificate_key /path/to/key.pem;
|
||||
|
||||
# Frontend
|
||||
location / {
|
||||
root /path/to/patchmon/frontend/dist;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Backend API
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:3001;
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Systemd Service
|
||||
|
||||
Create `/etc/systemd/system/patchmon.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=PatchMon Backend
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=patchmon
|
||||
WorkingDirectory=/path/to/patchmon/backend
|
||||
ExecStart=/usr/bin/node src/server.js
|
||||
Restart=always
|
||||
Environment=NODE_ENV=production
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
```bash
|
||||
sudo systemctl enable patchmon
|
||||
sudo systemctl start patchmon
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
[Add your license information here]
|
||||
|
||||
## Support
|
||||
|
||||
For issues and questions:
|
||||
- Create an issue in the repository
|
||||
- Check the troubleshooting section
|
||||
- Review agent logs for connection issues
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests if applicable
|
||||
5. Submit a pull request
|
||||
|
||||
---
|
||||
|
||||
**Note**: Remember to change default passwords and secrets before deploying to production!
|
1097
agents/patchmon-agent.sh
Executable file
1097
agents/patchmon-agent.sh
Executable file
File diff suppressed because it is too large
Load Diff
156
agents/patchmon_install.sh
Normal file
156
agents/patchmon_install.sh
Normal file
@@ -0,0 +1,156 @@
|
||||
#!/bin/bash
|
||||
|
||||
# PatchMon Agent Installation Script
|
||||
# Usage: curl -sSL {PATCHMON_URL}/api/v1/hosts/install | bash -s -- {PATCHMON_URL} {API_ID} {API_KEY}
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Functions
|
||||
error() {
|
||||
echo -e "${RED}❌ ERROR: $1${NC}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
info() {
|
||||
echo -e "${BLUE}ℹ️ $1${NC}"
|
||||
}
|
||||
|
||||
success() {
|
||||
echo -e "${GREEN}✅ $1${NC}"
|
||||
}
|
||||
|
||||
warning() {
|
||||
echo -e "${YELLOW}⚠️ $1${NC}"
|
||||
}
|
||||
|
||||
# Check if running as root
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
error "This script must be run as root (use sudo)"
|
||||
fi
|
||||
|
||||
# Default server URL (will be replaced by backend with configured URL)
|
||||
PATCHMON_URL="http://localhost:3001"
|
||||
|
||||
# Parse arguments
|
||||
if [[ $# -ne 3 ]]; then
|
||||
echo "Usage: curl -sSL {PATCHMON_URL}/api/v1/hosts/install | bash -s -- {PATCHMON_URL} {API_ID} {API_KEY}"
|
||||
echo ""
|
||||
echo "Example:"
|
||||
echo "curl -sSL http://patchmon.example.com/api/v1/hosts/install | bash -s -- http://patchmon.example.com patchmon_1a2b3c4d abcd1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
|
||||
echo ""
|
||||
echo "Contact your PatchMon administrator to get your API credentials."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PATCHMON_URL="$1"
|
||||
API_ID="$2"
|
||||
API_KEY="$3"
|
||||
|
||||
# Validate inputs
|
||||
if [[ ! "$PATCHMON_URL" =~ ^https?:// ]]; then
|
||||
error "Invalid URL format. Must start with http:// or https://"
|
||||
fi
|
||||
|
||||
if [[ ! "$API_ID" =~ ^patchmon_[a-f0-9]{16}$ ]]; then
|
||||
error "Invalid API ID format. API ID should be in format: patchmon_xxxxxxxxxxxxxxxx"
|
||||
fi
|
||||
|
||||
if [[ ! "$API_KEY" =~ ^[a-f0-9]{64}$ ]]; then
|
||||
error "Invalid API Key format. API Key should be 64 hexadecimal characters."
|
||||
fi
|
||||
|
||||
info "🚀 Installing PatchMon Agent..."
|
||||
info " Server: $PATCHMON_URL"
|
||||
info " API ID: $API_ID"
|
||||
|
||||
# Create patchmon directory
|
||||
info "📁 Creating configuration directory..."
|
||||
mkdir -p /etc/patchmon
|
||||
|
||||
# Download the agent script
|
||||
info "📥 Downloading PatchMon agent script..."
|
||||
curl -sSL "$PATCHMON_URL/api/v1/hosts/agent/download" -o /usr/local/bin/patchmon-agent.sh
|
||||
chmod +x /usr/local/bin/patchmon-agent.sh
|
||||
|
||||
# Get the agent version from the downloaded script
|
||||
AGENT_VERSION=$(grep '^AGENT_VERSION=' /usr/local/bin/patchmon-agent.sh | cut -d'"' -f2)
|
||||
info "📋 Agent version: $AGENT_VERSION"
|
||||
|
||||
# Get expected agent version from server
|
||||
EXPECTED_VERSION=$(curl -s "$PATCHMON_URL/api/v1/hosts/agent/version" | grep -o '"currentVersion":"[^"]*' | cut -d'"' -f4 2>/dev/null || echo "Unknown")
|
||||
if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then
|
||||
info "📋 Expected version: $EXPECTED_VERSION"
|
||||
if [[ "$AGENT_VERSION" != "$EXPECTED_VERSION" ]]; then
|
||||
warning "⚠️ Agent version mismatch! Installed: $AGENT_VERSION, Expected: $EXPECTED_VERSION"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Get update interval policy from server
|
||||
UPDATE_INTERVAL=$(curl -s "$PATCHMON_URL/api/v1/settings/update-interval" | grep -o '"updateInterval":[0-9]*' | cut -d':' -f2 2>/dev/null || echo "60")
|
||||
info "📋 Update interval: $UPDATE_INTERVAL minutes"
|
||||
|
||||
# Create credentials file
|
||||
info "🔐 Setting up API credentials..."
|
||||
cat > /etc/patchmon/credentials << EOF
|
||||
# PatchMon API Credentials
|
||||
# Generated on $(date)
|
||||
PATCHMON_URL="$PATCHMON_URL"
|
||||
API_ID="$API_ID"
|
||||
API_KEY="$API_KEY"
|
||||
EOF
|
||||
|
||||
chmod 600 /etc/patchmon/credentials
|
||||
|
||||
# Test the configuration
|
||||
info "🧪 Testing configuration..."
|
||||
if /usr/local/bin/patchmon-agent.sh test; then
|
||||
success "Configuration test passed!"
|
||||
else
|
||||
error "Configuration test failed. Please check your credentials."
|
||||
fi
|
||||
|
||||
# Send initial update
|
||||
info "📊 Sending initial package data..."
|
||||
if /usr/local/bin/patchmon-agent.sh update; then
|
||||
success "Initial package data sent successfully!"
|
||||
else
|
||||
warning "Initial package data failed, but agent is configured. You can run 'patchmon-agent.sh update' manually."
|
||||
fi
|
||||
|
||||
# Setup crontab for automatic updates
|
||||
info "⏰ Setting up automatic updates every $UPDATE_INTERVAL minutes..."
|
||||
if [[ $UPDATE_INTERVAL -eq 60 ]]; then
|
||||
# Hourly updates
|
||||
echo "0 * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | crontab -
|
||||
else
|
||||
# Custom interval updates
|
||||
echo "*/$UPDATE_INTERVAL * * * * /usr/local/bin/patchmon-agent.sh update >/dev/null 2>&1" | crontab -
|
||||
fi
|
||||
|
||||
success "🎉 PatchMon Agent installation complete!"
|
||||
echo ""
|
||||
echo "📋 Installation Summary:"
|
||||
echo " • Agent installed: /usr/local/bin/patchmon-agent.sh"
|
||||
echo " • Agent version: $AGENT_VERSION"
|
||||
if [[ "$EXPECTED_VERSION" != "Unknown" ]]; then
|
||||
echo " • Expected version: $EXPECTED_VERSION"
|
||||
fi
|
||||
echo " • Config directory: /etc/patchmon/"
|
||||
echo " • Credentials file: /etc/patchmon/credentials"
|
||||
echo " • Automatic updates: Every $UPDATE_INTERVAL minutes via crontab"
|
||||
echo " • View logs: tail -f /var/log/patchmon-agent.sh"
|
||||
echo ""
|
||||
echo "🔧 Manual commands:"
|
||||
echo " • Test connection: patchmon-agent.sh test"
|
||||
echo " • Send update: patchmon-agent.sh update"
|
||||
echo " • Check status: patchmon-agent.sh ping"
|
||||
echo ""
|
||||
success "Your host is now connected to PatchMon!"
|
||||
|
17
backend/env.example
Normal file
17
backend/env.example
Normal file
@@ -0,0 +1,17 @@
|
||||
# Database Configuration
|
||||
DATABASE_URL="postgresql://patchmon_user:p@tchm0n_p@55@localhost:5432/patchmon_db"
|
||||
|
||||
# Server Configuration
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
|
||||
# API Configuration
|
||||
API_VERSION=v1
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX=100
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=info
|
36
backend/package.json
Normal file
36
backend/package.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "patchmon-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API for Linux Patch Monitoring System",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon src/server.js",
|
||||
"start": "node src/server.js",
|
||||
"build": "echo 'No build step needed for Node.js'",
|
||||
"db:generate": "prisma generate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:push": "prisma db push",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.7.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-validator": "^7.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"moment": "^2.30.1",
|
||||
"uuid": "^9.0.1",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2",
|
||||
"prisma": "^5.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
77
backend/prisma/migrations/20250716185346_init/migration.sql
Normal file
77
backend/prisma/migrations/20250716185346_init/migration.sql
Normal file
@@ -0,0 +1,77 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "hosts" (
|
||||
"id" TEXT NOT NULL,
|
||||
"hostname" TEXT NOT NULL,
|
||||
"ip" TEXT,
|
||||
"os_type" TEXT NOT NULL,
|
||||
"os_version" TEXT NOT NULL,
|
||||
"architecture" TEXT,
|
||||
"last_update" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"token" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "hosts_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "packages" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"category" TEXT,
|
||||
"latest_version" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "packages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "host_packages" (
|
||||
"id" TEXT NOT NULL,
|
||||
"host_id" TEXT NOT NULL,
|
||||
"package_id" TEXT NOT NULL,
|
||||
"current_version" TEXT NOT NULL,
|
||||
"available_version" TEXT,
|
||||
"needs_update" BOOLEAN NOT NULL DEFAULT false,
|
||||
"is_security_update" BOOLEAN NOT NULL DEFAULT false,
|
||||
"last_checked" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "host_packages_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "update_history" (
|
||||
"id" TEXT NOT NULL,
|
||||
"host_id" TEXT NOT NULL,
|
||||
"packages_count" INTEGER NOT NULL,
|
||||
"security_count" INTEGER NOT NULL,
|
||||
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"status" TEXT NOT NULL DEFAULT 'success',
|
||||
"error_message" TEXT,
|
||||
|
||||
CONSTRAINT "update_history_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "hosts_hostname_key" ON "hosts"("hostname");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "hosts_token_key" ON "hosts"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "packages_name_key" ON "packages"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "host_packages_host_id_package_id_key" ON "host_packages"("host_id", "package_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "host_packages" ADD CONSTRAINT "host_packages_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "host_packages" ADD CONSTRAINT "host_packages_package_id_fkey" FOREIGN KEY ("package_id") REFERENCES "packages"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "update_history" ADD CONSTRAINT "update_history_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `token` on the `hosts` table. All the data in the column will be lost.
|
||||
- A unique constraint covering the columns `[api_id]` on the table `hosts` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[api_key]` on the table `hosts` will be added. If there are existing duplicate values, this will fail.
|
||||
- Added the required column `api_id` to the `hosts` table without a default value. This is not possible if the table is not empty.
|
||||
- Added the required column `api_key` to the `hosts` table without a default value. This is not possible if the table is not empty.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX "hosts_token_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "hosts" DROP COLUMN "token",
|
||||
ADD COLUMN "api_id" TEXT NOT NULL,
|
||||
ADD COLUMN "api_key" TEXT NOT NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password_hash" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'admin',
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"last_login" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "role_permissions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL,
|
||||
"can_view_dashboard" BOOLEAN NOT NULL DEFAULT true,
|
||||
"can_view_hosts" BOOLEAN NOT NULL DEFAULT true,
|
||||
"can_manage_hosts" BOOLEAN NOT NULL DEFAULT false,
|
||||
"can_view_packages" BOOLEAN NOT NULL DEFAULT true,
|
||||
"can_manage_packages" BOOLEAN NOT NULL DEFAULT false,
|
||||
"can_view_users" BOOLEAN NOT NULL DEFAULT false,
|
||||
"can_manage_users" BOOLEAN NOT NULL DEFAULT false,
|
||||
"can_view_reports" BOOLEAN NOT NULL DEFAULT true,
|
||||
"can_export_data" BOOLEAN NOT NULL DEFAULT false,
|
||||
"can_manage_settings" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "role_permissions_role_key" ON "role_permissions"("role");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "hosts_api_id_key" ON "hosts"("api_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "hosts_api_key_key" ON "hosts"("api_key");
|
@@ -0,0 +1,13 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "settings" (
|
||||
"id" TEXT NOT NULL,
|
||||
"server_url" TEXT NOT NULL DEFAULT 'http://localhost:3001',
|
||||
"server_protocol" TEXT NOT NULL DEFAULT 'http',
|
||||
"server_host" TEXT NOT NULL DEFAULT 'localhost',
|
||||
"server_port" INTEGER NOT NULL DEFAULT 3001,
|
||||
"frontend_url" TEXT NOT NULL DEFAULT 'http://localhost:3000',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "settings_pkey" PRIMARY KEY ("id")
|
||||
);
|
@@ -0,0 +1,18 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "dashboard_preferences" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"card_id" TEXT NOT NULL,
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "dashboard_preferences_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "dashboard_preferences_user_id_card_id_key" ON "dashboard_preferences"("user_id", "card_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "dashboard_preferences" ADD CONSTRAINT "dashboard_preferences_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
@@ -0,0 +1,20 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "hosts" ADD COLUMN "host_group_id" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "host_groups" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"color" TEXT DEFAULT '#3B82F6',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "host_groups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "host_groups_name_key" ON "host_groups"("name");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "hosts" ADD CONSTRAINT "hosts_host_group_id_fkey" FOREIGN KEY ("host_group_id") REFERENCES "host_groups"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
@@ -0,0 +1,19 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "hosts" ADD COLUMN "agent_version" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "agent_versions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"version" TEXT NOT NULL,
|
||||
"is_current" BOOLEAN NOT NULL DEFAULT false,
|
||||
"release_notes" TEXT,
|
||||
"download_url" TEXT,
|
||||
"min_server_version" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "agent_versions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "agent_versions_version_key" ON "agent_versions"("version");
|
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "agent_versions" ADD COLUMN "is_default" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "script_content" TEXT;
|
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "settings" ADD COLUMN "update_interval" INTEGER NOT NULL DEFAULT 60;
|
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "settings" ADD COLUMN "auto_update" BOOLEAN NOT NULL DEFAULT false;
|
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "hosts" ADD COLUMN "auto_update" BOOLEAN NOT NULL DEFAULT true;
|
@@ -0,0 +1,40 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "repositories" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"distribution" TEXT NOT NULL,
|
||||
"components" TEXT NOT NULL,
|
||||
"repo_type" TEXT NOT NULL,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"is_secure" BOOLEAN NOT NULL DEFAULT true,
|
||||
"priority" INTEGER,
|
||||
"description" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "repositories_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "host_repositories" (
|
||||
"id" TEXT NOT NULL,
|
||||
"host_id" TEXT NOT NULL,
|
||||
"repository_id" TEXT NOT NULL,
|
||||
"is_enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"last_checked" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "host_repositories_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "repositories_url_distribution_components_key" ON "repositories"("url", "distribution", "components");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "host_repositories_host_id_repository_id_key" ON "host_repositories"("host_id", "repository_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "host_repositories" ADD CONSTRAINT "host_repositories_host_id_fkey" FOREIGN KEY ("host_id") REFERENCES "hosts"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "host_repositories" ADD CONSTRAINT "host_repositories_repository_id_fkey" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
3
backend/prisma/migrations/migration_lock.toml
Normal file
3
backend/prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (i.e. Git)
|
||||
provider = "postgresql"
|
217
backend/prisma/schema.prisma
Normal file
217
backend/prisma/schema.prisma
Normal file
@@ -0,0 +1,217 @@
|
||||
// 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"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
email String @unique
|
||||
passwordHash String @map("password_hash")
|
||||
role String @default("admin") // admin, user
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
lastLogin DateTime? @map("last_login")
|
||||
createdAt DateTime @map("created_at") @default(now())
|
||||
updatedAt DateTime @map("updated_at") @updatedAt
|
||||
|
||||
// Relationships
|
||||
dashboardPreferences DashboardPreferences[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
model 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 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 {
|
||||
id String @id @default(cuid())
|
||||
hostname String @unique
|
||||
ip String?
|
||||
osType String @map("os_type")
|
||||
osVersion String @map("os_version")
|
||||
architecture String?
|
||||
lastUpdate DateTime @map("last_update") @default(now())
|
||||
status String @default("active") // active, inactive, error
|
||||
apiId String @unique @map("api_id") // New API ID for authentication
|
||||
apiKey String @unique @map("api_key") // New API Key for authentication
|
||||
hostGroupId String? @map("host_group_id") // Optional group association
|
||||
agentVersion String? @map("agent_version") // Agent script version
|
||||
autoUpdate Boolean @map("auto_update") @default(true) // Enable auto-update for this host
|
||||
createdAt DateTime @map("created_at") @default(now())
|
||||
updatedAt DateTime @map("updated_at") @updatedAt
|
||||
|
||||
// Relationships
|
||||
hostPackages HostPackage[]
|
||||
updateHistory UpdateHistory[]
|
||||
hostRepositories HostRepository[]
|
||||
hostGroup HostGroup? @relation(fields: [hostGroupId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@map("hosts")
|
||||
}
|
||||
|
||||
model 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 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 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 Repository {
|
||||
id String @id @default(cuid())
|
||||
name String // Repository name (e.g., "focal", "focal-updates")
|
||||
url String // Repository URL
|
||||
distribution String // Distribution (e.g., "focal", "jammy")
|
||||
components String // Components (e.g., "main restricted universe multiverse")
|
||||
repoType String @map("repo_type") // "deb" or "deb-src"
|
||||
isActive Boolean @map("is_active") @default(true)
|
||||
isSecure Boolean @map("is_secure") @default(true) // HTTPS vs HTTP
|
||||
priority Int? // Repository priority
|
||||
description String? // Optional description
|
||||
createdAt DateTime @map("created_at") @default(now())
|
||||
updatedAt DateTime @map("updated_at") @updatedAt
|
||||
|
||||
// Relationships
|
||||
hostRepositories HostRepository[]
|
||||
|
||||
@@unique([url, distribution, components])
|
||||
@@map("repositories")
|
||||
}
|
||||
|
||||
model HostRepository {
|
||||
id String @id @default(cuid())
|
||||
hostId String @map("host_id")
|
||||
repositoryId String @map("repository_id")
|
||||
isEnabled Boolean @map("is_enabled") @default(true)
|
||||
lastChecked DateTime @map("last_checked") @default(now())
|
||||
|
||||
// Relationships
|
||||
host Host @relation(fields: [hostId], references: [id], onDelete: Cascade)
|
||||
repository Repository @relation(fields: [repositoryId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([hostId, repositoryId])
|
||||
@@map("host_repositories")
|
||||
}
|
||||
|
||||
model 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
|
||||
createdAt DateTime @map("created_at") @default(now())
|
||||
updatedAt DateTime @map("updated_at") @updatedAt
|
||||
|
||||
@@map("settings")
|
||||
}
|
||||
|
||||
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 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")
|
||||
}
|
98
backend/src/middleware/auth.js
Normal file
98
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,98 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Middleware to verify JWT token
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Access token required' });
|
||||
}
|
||||
|
||||
// Verify token
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
||||
|
||||
// Get user from database
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
lastLogin: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user || !user.isActive) {
|
||||
return res.status(401).json({ error: 'Invalid or inactive user' });
|
||||
}
|
||||
|
||||
// Update last login
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLogin: new Date() }
|
||||
});
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
if (error.name === 'JsonWebTokenError') {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
if (error.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expired' });
|
||||
}
|
||||
console.error('Auth middleware error:', error);
|
||||
return res.status(500).json({ error: 'Authentication failed' });
|
||||
}
|
||||
};
|
||||
|
||||
// Middleware to check admin role
|
||||
const requireAdmin = (req, res, next) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
// Middleware to check if user is authenticated (optional)
|
||||
const optionalAuth = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (token) {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key');
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: decoded.userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (user && user.isActive) {
|
||||
req.user = user;
|
||||
}
|
||||
}
|
||||
next();
|
||||
} catch (error) {
|
||||
// Continue without authentication for optional auth
|
||||
next();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
optionalAuth
|
||||
};
|
59
backend/src/middleware/permissions.js
Normal file
59
backend/src/middleware/permissions.js
Normal file
@@ -0,0 +1,59 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Permission middleware factory
|
||||
const requirePermission = (permission) => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
// Get user's role permissions
|
||||
const rolePermissions = await prisma.rolePermissions.findUnique({
|
||||
where: { role: req.user.role }
|
||||
});
|
||||
|
||||
// If no specific permissions found, default to admin permissions (for backward compatibility)
|
||||
if (!rolePermissions) {
|
||||
console.warn(`No permissions found for role: ${req.user.role}, defaulting to admin access`);
|
||||
return next();
|
||||
}
|
||||
|
||||
// Check if user has the required permission
|
||||
if (!rolePermissions[permission]) {
|
||||
return res.status(403).json({
|
||||
error: 'Insufficient permissions',
|
||||
message: `You don't have permission to ${permission.replace('can', '').toLowerCase()}`
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Permission check error:', error);
|
||||
res.status(500).json({ error: 'Permission check failed' });
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Specific permission middlewares
|
||||
const requireViewDashboard = requirePermission('canViewDashboard');
|
||||
const requireViewHosts = requirePermission('canViewHosts');
|
||||
const requireManageHosts = requirePermission('canManageHosts');
|
||||
const requireViewPackages = requirePermission('canViewPackages');
|
||||
const requireManagePackages = requirePermission('canManagePackages');
|
||||
const requireViewUsers = requirePermission('canViewUsers');
|
||||
const requireManageUsers = requirePermission('canManageUsers');
|
||||
const requireViewReports = requirePermission('canViewReports');
|
||||
const requireExportData = requirePermission('canExportData');
|
||||
const requireManageSettings = requirePermission('canManageSettings');
|
||||
|
||||
module.exports = {
|
||||
requirePermission,
|
||||
requireViewDashboard,
|
||||
requireViewHosts,
|
||||
requireManageHosts,
|
||||
requireViewPackages,
|
||||
requireManagePackages,
|
||||
requireViewUsers,
|
||||
requireManageUsers,
|
||||
requireViewReports,
|
||||
requireExportData,
|
||||
requireManageSettings
|
||||
};
|
452
backend/src/routes/authRoutes.js
Normal file
452
backend/src/routes/authRoutes.js
Normal file
@@ -0,0 +1,452 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const { requireViewUsers, requireManageUsers } = require('../middleware/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Generate JWT token
|
||||
const generateToken = (userId) => {
|
||||
return jwt.sign(
|
||||
{ userId },
|
||||
process.env.JWT_SECRET || 'your-secret-key',
|
||||
{ expiresIn: process.env.JWT_EXPIRES_IN || '24h' }
|
||||
);
|
||||
};
|
||||
|
||||
// Admin endpoint to list all users
|
||||
router.get('/admin/users', authenticateToken, requireViewUsers, async (req, res) => {
|
||||
try {
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
lastLogin: true,
|
||||
createdAt: true,
|
||||
updatedAt: true
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
})
|
||||
|
||||
res.json(users)
|
||||
} catch (error) {
|
||||
console.error('List users error:', error)
|
||||
res.status(500).json({ error: 'Failed to fetch users' })
|
||||
}
|
||||
})
|
||||
|
||||
// Admin endpoint to create a new user
|
||||
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('role').optional().custom(async (value) => {
|
||||
if (!value) return true; // Optional field
|
||||
const rolePermissions = await prisma.rolePermissions.findUnique({
|
||||
where: { role: value }
|
||||
});
|
||||
if (!rolePermissions) {
|
||||
throw new Error('Invalid role specified');
|
||||
}
|
||||
return true;
|
||||
})
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { username, email, password, role = 'user' } = req.body;
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await prisma.user.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);
|
||||
|
||||
// Create user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email,
|
||||
passwordHash,
|
||||
role
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: 'User created successfully',
|
||||
user
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('User creation error:', error);
|
||||
res.status(500).json({ error: 'Failed to create user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to update a user
|
||||
router.put('/admin/users/:userId', authenticateToken, requireManageUsers, [
|
||||
body('username').optional().isLength({ min: 3 }).withMessage('Username must be at least 3 characters'),
|
||||
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({
|
||||
where: { role: value }
|
||||
});
|
||||
if (!rolePermissions) {
|
||||
throw new Error('Invalid role specified');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('isActive').optional().isBoolean().withMessage('isActive must be a boolean')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const errors = validationResult(req);
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { username, email, role, isActive } = req.body;
|
||||
const updateData = {};
|
||||
|
||||
if (username) updateData.username = username;
|
||||
if (email) updateData.email = email;
|
||||
if (role) updateData.role = role;
|
||||
if (typeof isActive === 'boolean') updateData.isActive = isActive;
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
if (!existingUser) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Check if username/email already exists (excluding current user)
|
||||
if (username || email) {
|
||||
const duplicateUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{ id: { not: userId } },
|
||||
{
|
||||
OR: [
|
||||
...(username ? [{ username }] : []),
|
||||
...(email ? [{ email }] : [])
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (duplicateUser) {
|
||||
return res.status(409).json({ error: 'Username or email already exists' });
|
||||
}
|
||||
}
|
||||
|
||||
// Prevent deactivating the last admin
|
||||
if (isActive === false && existingUser.role === 'admin') {
|
||||
const adminCount = await prisma.user.count({
|
||||
where: {
|
||||
role: 'admin',
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (adminCount <= 1) {
|
||||
return res.status(400).json({ error: 'Cannot deactivate the last admin user' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update user
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
lastLogin: true,
|
||||
createdAt: true,
|
||||
updatedAt: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'User updated successfully',
|
||||
user: updatedUser
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('User update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to delete a user
|
||||
router.delete('/admin/users/:userId', authenticateToken, requireManageUsers, async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
|
||||
// Prevent self-deletion
|
||||
if (userId === req.user.id) {
|
||||
return res.status(400).json({ error: 'Cannot delete your own account' });
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Prevent deleting the last admin
|
||||
if (user.role === 'admin') {
|
||||
const adminCount = await prisma.user.count({
|
||||
where: {
|
||||
role: 'admin',
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (adminCount <= 1) {
|
||||
return res.status(400).json({ error: 'Cannot delete the last admin user' });
|
||||
}
|
||||
}
|
||||
|
||||
// Delete user
|
||||
await prisma.user.delete({
|
||||
where: { id: userId }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'User deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('User deletion error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete user' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/login', [
|
||||
body('username').notEmpty().withMessage('Username is required'),
|
||||
body('password').notEmpty().withMessage('Password is required')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Find user by username or email
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username },
|
||||
{ email: username }
|
||||
],
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
// Update last login
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { lastLogin: new Date() }
|
||||
});
|
||||
|
||||
// Generate token
|
||||
const token = generateToken(user.id);
|
||||
|
||||
res.json({
|
||||
message: 'Login successful',
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user profile
|
||||
router.get('/profile', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
user: req.user
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get profile error:', error);
|
||||
res.status(500).json({ error: 'Failed to get profile' });
|
||||
}
|
||||
});
|
||||
|
||||
// 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')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { username, email } = req.body;
|
||||
const updateData = {};
|
||||
|
||||
if (username) updateData.username = username;
|
||||
if (email) updateData.email = email;
|
||||
|
||||
// Check if username/email already exists (excluding current user)
|
||||
if (username || email) {
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
{ id: { not: req.user.id } },
|
||||
{
|
||||
OR: [
|
||||
...(username ? [{ username }] : []),
|
||||
...(email ? [{ email }] : [])
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ error: 'Username or email already exists' });
|
||||
}
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: updateData,
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
lastLogin: true,
|
||||
updatedAt: true
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Profile updated successfully',
|
||||
user: updatedUser
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update profile error:', error);
|
||||
res.status(500).json({ error: 'Failed to update profile' });
|
||||
}
|
||||
});
|
||||
|
||||
// Change password
|
||||
router.put('/change-password', authenticateToken, [
|
||||
body('currentPassword').notEmpty().withMessage('Current password is required'),
|
||||
body('newPassword').isLength({ min: 6 }).withMessage('New password must be at least 6 characters')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
// Get user with password hash
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user.id }
|
||||
});
|
||||
|
||||
// Verify current password
|
||||
const isValidPassword = await bcrypt.compare(currentPassword, user.passwordHash);
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ error: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
// Update password
|
||||
await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: { passwordHash: newPasswordHash }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Password changed successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error);
|
||||
res.status(500).json({ error: 'Failed to change password' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout (client-side token removal)
|
||||
router.post('/logout', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
res.json({
|
||||
message: 'Logout successful'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
89
backend/src/routes/dashboardPreferencesRoutes.js
Normal file
89
backend/src/routes/dashboardPreferencesRoutes.js
Normal file
@@ -0,0 +1,89 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Get user's dashboard preferences
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const preferences = await prisma.dashboardPreferences.findMany({
|
||||
where: { userId: req.user.id },
|
||||
orderBy: { order: 'asc' }
|
||||
});
|
||||
|
||||
res.json(preferences);
|
||||
} catch (error) {
|
||||
console.error('Dashboard preferences fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch dashboard preferences' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update dashboard preferences (bulk update)
|
||||
router.put('/', authenticateToken, [
|
||||
body('preferences').isArray().withMessage('Preferences must be an array'),
|
||||
body('preferences.*.cardId').isString().withMessage('Card ID is required'),
|
||||
body('preferences.*.enabled').isBoolean().withMessage('Enabled must be boolean'),
|
||||
body('preferences.*.order').isInt().withMessage('Order must be integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { preferences } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Delete existing preferences for this user
|
||||
await prisma.dashboardPreferences.deleteMany({
|
||||
where: { userId }
|
||||
});
|
||||
|
||||
// Create new preferences
|
||||
const newPreferences = preferences.map(pref => ({
|
||||
userId,
|
||||
cardId: pref.cardId,
|
||||
enabled: pref.enabled,
|
||||
order: pref.order
|
||||
}));
|
||||
|
||||
const createdPreferences = await prisma.dashboardPreferences.createMany({
|
||||
data: newPreferences
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Dashboard preferences updated successfully',
|
||||
preferences: newPreferences
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Dashboard preferences update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update dashboard preferences' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get default dashboard card configuration
|
||||
router.get('/defaults', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const defaultCards = [
|
||||
{ cardId: 'totalHosts', title: 'Total Hosts', icon: 'Server', enabled: true, order: 0 },
|
||||
{ cardId: 'hostsNeedingUpdates', title: 'Needs Updating', icon: 'AlertTriangle', enabled: true, order: 1 },
|
||||
{ cardId: 'totalOutdatedPackages', title: 'Outdated Packages', icon: 'Package', enabled: true, order: 2 },
|
||||
{ cardId: 'securityUpdates', title: 'Security Updates', icon: 'Shield', enabled: true, order: 3 },
|
||||
{ cardId: 'erroredHosts', title: 'Errored Hosts', icon: 'AlertTriangle', enabled: true, order: 4 },
|
||||
{ cardId: 'osDistribution', title: 'OS Distribution', icon: 'BarChart3', enabled: true, order: 5 },
|
||||
{ cardId: 'updateStatus', title: 'Update Status', icon: 'BarChart3', enabled: true, order: 6 },
|
||||
{ cardId: 'packagePriority', title: 'Package Priority', icon: 'BarChart3', enabled: true, order: 7 },
|
||||
{ cardId: 'quickStats', title: 'Quick Stats', icon: 'TrendingUp', enabled: true, order: 8 }
|
||||
];
|
||||
|
||||
res.json(defaultCards);
|
||||
} catch (error) {
|
||||
console.error('Default dashboard cards error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch default dashboard cards' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
313
backend/src/routes/dashboardRoutes.js
Normal file
313
backend/src/routes/dashboardRoutes.js
Normal file
@@ -0,0 +1,313 @@
|
||||
const express = require('express');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const moment = require('moment');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const {
|
||||
requireViewDashboard,
|
||||
requireViewHosts,
|
||||
requireViewPackages
|
||||
} = require('../middleware/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Get dashboard statistics
|
||||
router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const twentyFourHoursAgo = moment(now).subtract(24, 'hours').toDate();
|
||||
|
||||
// Get all statistics in parallel for better performance
|
||||
const [
|
||||
totalHosts,
|
||||
hostsNeedingUpdates,
|
||||
totalOutdatedPackages,
|
||||
erroredHosts,
|
||||
securityUpdates,
|
||||
osDistribution,
|
||||
updateTrends
|
||||
] = await Promise.all([
|
||||
// Total hosts count
|
||||
prisma.host.count({
|
||||
where: { status: 'active' }
|
||||
}),
|
||||
|
||||
// Hosts needing updates (distinct hosts with packages needing updates)
|
||||
prisma.host.count({
|
||||
where: {
|
||||
status: 'active',
|
||||
hostPackages: {
|
||||
some: {
|
||||
needsUpdate: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Total outdated packages across all hosts
|
||||
prisma.hostPackage.count({
|
||||
where: { needsUpdate: true }
|
||||
}),
|
||||
|
||||
// Errored hosts (not updated in 24 hours)
|
||||
prisma.host.count({
|
||||
where: {
|
||||
status: 'active',
|
||||
lastUpdate: {
|
||||
lt: twentyFourHoursAgo
|
||||
}
|
||||
}
|
||||
}),
|
||||
|
||||
// Security updates count
|
||||
prisma.hostPackage.count({
|
||||
where: {
|
||||
needsUpdate: true,
|
||||
isSecurityUpdate: true
|
||||
}
|
||||
}),
|
||||
|
||||
// OS distribution for pie chart
|
||||
prisma.host.groupBy({
|
||||
by: ['osType'],
|
||||
where: { status: 'active' },
|
||||
_count: {
|
||||
osType: true
|
||||
}
|
||||
}),
|
||||
|
||||
// Update trends for the last 7 days
|
||||
prisma.updateHistory.groupBy({
|
||||
by: ['timestamp'],
|
||||
where: {
|
||||
timestamp: {
|
||||
gte: moment(now).subtract(7, 'days').toDate()
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
id: true
|
||||
},
|
||||
_sum: {
|
||||
packagesCount: true,
|
||||
securityCount: true
|
||||
}
|
||||
})
|
||||
]);
|
||||
|
||||
// Format OS distribution for pie chart
|
||||
const osDistributionFormatted = osDistribution.map(item => ({
|
||||
name: item.osType,
|
||||
count: item._count.osType
|
||||
}));
|
||||
|
||||
// Calculate update status distribution
|
||||
const updateStatusDistribution = [
|
||||
{ name: 'Up to date', count: totalHosts - hostsNeedingUpdates },
|
||||
{ name: 'Needs updates', count: hostsNeedingUpdates },
|
||||
{ name: 'Errored', count: erroredHosts }
|
||||
];
|
||||
|
||||
// Package update priority distribution
|
||||
const packageUpdateDistribution = [
|
||||
{ name: 'Security', count: securityUpdates },
|
||||
{ name: 'Regular', count: totalOutdatedPackages - securityUpdates }
|
||||
];
|
||||
|
||||
res.json({
|
||||
cards: {
|
||||
totalHosts,
|
||||
hostsNeedingUpdates,
|
||||
totalOutdatedPackages,
|
||||
erroredHosts,
|
||||
securityUpdates
|
||||
},
|
||||
charts: {
|
||||
osDistribution: osDistributionFormatted,
|
||||
updateStatusDistribution,
|
||||
packageUpdateDistribution
|
||||
},
|
||||
trends: updateTrends,
|
||||
lastUpdated: now.toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard stats:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch dashboard statistics' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get hosts with their update status
|
||||
router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
|
||||
try {
|
||||
const hosts = await prisma.host.findMany({
|
||||
// Show all hosts regardless of status
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
osType: true,
|
||||
osVersion: true,
|
||||
lastUpdate: true,
|
||||
status: true,
|
||||
agentVersion: true,
|
||||
autoUpdate: true,
|
||||
hostGroup: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
hostPackages: {
|
||||
where: {
|
||||
needsUpdate: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { lastUpdate: 'desc' }
|
||||
});
|
||||
|
||||
// Get update counts for each host separately
|
||||
const hostsWithUpdateInfo = await Promise.all(
|
||||
hosts.map(async (host) => {
|
||||
const updatesCount = await prisma.hostPackage.count({
|
||||
where: {
|
||||
hostId: host.id,
|
||||
needsUpdate: true
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
...host,
|
||||
updatesCount,
|
||||
isStale: moment(host.lastUpdate).isBefore(moment().subtract(24, 'hours'))
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json(hostsWithUpdateInfo);
|
||||
} catch (error) {
|
||||
console.error('Error fetching hosts:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch hosts' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get packages that need updates across all hosts
|
||||
router.get('/packages', authenticateToken, requireViewPackages, async (req, res) => {
|
||||
try {
|
||||
const packages = await prisma.package.findMany({
|
||||
where: {
|
||||
hostPackages: {
|
||||
some: {
|
||||
needsUpdate: true
|
||||
}
|
||||
}
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
category: true,
|
||||
latestVersion: true,
|
||||
hostPackages: {
|
||||
where: { needsUpdate: true },
|
||||
select: {
|
||||
currentVersion: true,
|
||||
availableVersion: true,
|
||||
isSecurityUpdate: true,
|
||||
host: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
osType: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
const packagesWithHostInfo = packages.map(pkg => ({
|
||||
id: pkg.id,
|
||||
name: pkg.name,
|
||||
description: pkg.description,
|
||||
category: pkg.category,
|
||||
latestVersion: pkg.latestVersion,
|
||||
affectedHostsCount: pkg.hostPackages.length,
|
||||
isSecurityUpdate: pkg.hostPackages.some(hp => hp.isSecurityUpdate),
|
||||
affectedHosts: pkg.hostPackages.map(hp => ({
|
||||
hostId: hp.host.id,
|
||||
hostname: hp.host.hostname,
|
||||
osType: hp.host.osType,
|
||||
currentVersion: hp.currentVersion,
|
||||
availableVersion: hp.availableVersion,
|
||||
isSecurityUpdate: hp.isSecurityUpdate
|
||||
}))
|
||||
}));
|
||||
|
||||
res.json(packagesWithHostInfo);
|
||||
} catch (error) {
|
||||
console.error('Error fetching packages:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch packages' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get detailed host information
|
||||
router.get('/hosts/:hostId', authenticateToken, requireViewHosts, async (req, res) => {
|
||||
try {
|
||||
const { hostId } = req.params;
|
||||
|
||||
const host = await prisma.host.findUnique({
|
||||
where: { id: hostId },
|
||||
include: {
|
||||
hostGroup: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true
|
||||
}
|
||||
},
|
||||
hostPackages: {
|
||||
include: {
|
||||
package: true
|
||||
},
|
||||
orderBy: {
|
||||
needsUpdate: 'desc'
|
||||
}
|
||||
},
|
||||
updateHistory: {
|
||||
orderBy: {
|
||||
timestamp: 'desc'
|
||||
},
|
||||
take: 10
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: 'Host not found' });
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
};
|
||||
|
||||
res.json(hostWithStats);
|
||||
} catch (error) {
|
||||
console.error('Error fetching host details:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch host details' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
225
backend/src/routes/hostGroupRoutes.js
Normal file
225
backend/src/routes/hostGroupRoutes.js
Normal file
@@ -0,0 +1,225 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { requireManageHosts } = require('../middleware/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Get all host groups
|
||||
router.get('/', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const hostGroups = await prisma.hostGroup.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
hosts: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
res.json(hostGroups);
|
||||
} catch (error) {
|
||||
console.error('Error fetching host groups:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch host groups' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get a specific host group by ID
|
||||
router.get('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const hostGroup = await prisma.hostGroup.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
hosts: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
osType: true,
|
||||
osVersion: true,
|
||||
status: true,
|
||||
lastUpdate: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!hostGroup) {
|
||||
return res.status(404).json({ error: 'Host group not found' });
|
||||
}
|
||||
|
||||
res.json(hostGroup);
|
||||
} catch (error) {
|
||||
console.error('Error fetching host group:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch host group' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new host group
|
||||
router.post('/', authenticateToken, requireManageHosts, [
|
||||
body('name').trim().isLength({ min: 1 }).withMessage('Name is required'),
|
||||
body('description').optional().trim(),
|
||||
body('color').optional().isHexColor().withMessage('Color must be a valid hex color')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { name, description, color } = req.body;
|
||||
|
||||
// Check if host group with this name already exists
|
||||
const existingGroup = await prisma.hostGroup.findUnique({
|
||||
where: { name }
|
||||
});
|
||||
|
||||
if (existingGroup) {
|
||||
return res.status(400).json({ error: 'A host group with this name already exists' });
|
||||
}
|
||||
|
||||
const hostGroup = await prisma.hostGroup.create({
|
||||
data: {
|
||||
name,
|
||||
description: description || null,
|
||||
color: color || '#3B82F6'
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json(hostGroup);
|
||||
} catch (error) {
|
||||
console.error('Error creating host group:', error);
|
||||
res.status(500).json({ error: 'Failed to create host group' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update a host group
|
||||
router.put('/:id', authenticateToken, requireManageHosts, [
|
||||
body('name').trim().isLength({ min: 1 }).withMessage('Name is required'),
|
||||
body('description').optional().trim(),
|
||||
body('color').optional().isHexColor().withMessage('Color must be a valid hex color')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const { name, description, color } = req.body;
|
||||
|
||||
// Check if host group exists
|
||||
const existingGroup = await prisma.hostGroup.findUnique({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
if (!existingGroup) {
|
||||
return res.status(404).json({ error: 'Host group not found' });
|
||||
}
|
||||
|
||||
// Check if another host group with this name already exists
|
||||
const duplicateGroup = await prisma.hostGroup.findFirst({
|
||||
where: {
|
||||
name,
|
||||
id: { not: id }
|
||||
}
|
||||
});
|
||||
|
||||
if (duplicateGroup) {
|
||||
return res.status(400).json({ error: 'A host group with this name already exists' });
|
||||
}
|
||||
|
||||
const hostGroup = await prisma.hostGroup.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name,
|
||||
description: description || null,
|
||||
color: color || '#3B82F6'
|
||||
}
|
||||
});
|
||||
|
||||
res.json(hostGroup);
|
||||
} catch (error) {
|
||||
console.error('Error updating host group:', error);
|
||||
res.status(500).json({ error: 'Failed to update host group' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a host group
|
||||
router.delete('/:id', authenticateToken, requireManageHosts, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if host group exists
|
||||
const existingGroup = await prisma.hostGroup.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
hosts: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!existingGroup) {
|
||||
return res.status(404).json({ error: 'Host group not found' });
|
||||
}
|
||||
|
||||
// Check if host group has hosts
|
||||
if (existingGroup._count.hosts > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Cannot delete host group that contains hosts. Please move or remove hosts first.'
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.hostGroup.delete({
|
||||
where: { id }
|
||||
});
|
||||
|
||||
res.json({ message: 'Host group deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Error deleting host group:', error);
|
||||
res.status(500).json({ error: 'Failed to delete host group' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get hosts in a specific group
|
||||
router.get('/:id/hosts', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const hosts = await prisma.host.findMany({
|
||||
where: { hostGroupId: id },
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
osType: true,
|
||||
osVersion: true,
|
||||
architecture: true,
|
||||
status: true,
|
||||
lastUpdate: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: {
|
||||
hostname: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
res.json(hosts);
|
||||
} catch (error) {
|
||||
console.error('Error fetching hosts in group:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch hosts in group' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
928
backend/src/routes/hostRoutes.js
Normal file
928
backend/src/routes/hostRoutes.js
Normal file
@@ -0,0 +1,928 @@
|
||||
const express = require('express');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const crypto = require('crypto');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const { requireManageHosts, requireManageSettings } = require('../middleware/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Public endpoint to download the agent script
|
||||
router.get('/agent/download', async (req, res) => {
|
||||
try {
|
||||
const { version } = req.query;
|
||||
|
||||
let agentVersion;
|
||||
|
||||
if (version) {
|
||||
// Download specific version
|
||||
agentVersion = await prisma.agentVersion.findUnique({
|
||||
where: { version }
|
||||
});
|
||||
|
||||
if (!agentVersion) {
|
||||
return res.status(404).json({ error: 'Agent version not found' });
|
||||
}
|
||||
} else {
|
||||
// Download current version (latest)
|
||||
agentVersion = await prisma.agentVersion.findFirst({
|
||||
where: { isCurrent: true },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
if (!agentVersion) {
|
||||
// Fallback to default version
|
||||
agentVersion = await prisma.agentVersion.findFirst({
|
||||
where: { isDefault: true },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!agentVersion) {
|
||||
return res.status(404).json({ error: 'No agent version available' });
|
||||
}
|
||||
|
||||
// Use script content from database if available, otherwise fallback to file
|
||||
if (agentVersion.scriptContent) {
|
||||
res.setHeader('Content-Type', 'application/x-shellscript');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="patchmon-agent-${agentVersion.version}.sh"`);
|
||||
res.send(agentVersion.scriptContent);
|
||||
} else {
|
||||
// Fallback to file system
|
||||
const agentPath = path.join(__dirname, '../../../agents/patchmon-agent.sh');
|
||||
|
||||
if (!fs.existsSync(agentPath)) {
|
||||
return res.status(404).json({ error: 'Agent script not found' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/x-shellscript');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="patchmon-agent-${agentVersion.version}.sh"`);
|
||||
res.sendFile(path.resolve(agentPath));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Agent download error:', error);
|
||||
res.status(500).json({ error: 'Failed to download agent script' });
|
||||
}
|
||||
});
|
||||
|
||||
// Version check endpoint for agents
|
||||
router.get('/agent/version', async (req, res) => {
|
||||
try {
|
||||
const currentVersion = await prisma.agentVersion.findFirst({
|
||||
where: { isCurrent: true },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
if (!currentVersion) {
|
||||
return res.status(404).json({ error: 'No current agent version found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
currentVersion: currentVersion.version,
|
||||
downloadUrl: currentVersion.downloadUrl || `/api/v1/hosts/agent/download`,
|
||||
releaseNotes: currentVersion.releaseNotes,
|
||||
minServerVersion: currentVersion.minServerVersion
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Version check error:', error);
|
||||
res.status(500).json({ error: 'Failed to get agent version' });
|
||||
}
|
||||
});
|
||||
|
||||
// Generate API credentials
|
||||
const generateApiCredentials = () => {
|
||||
const apiId = `patchmon_${crypto.randomBytes(8).toString('hex')}`;
|
||||
const apiKey = crypto.randomBytes(32).toString('hex');
|
||||
return { apiId, apiKey };
|
||||
};
|
||||
|
||||
// Middleware to validate API credentials
|
||||
const validateApiCredentials = async (req, res, next) => {
|
||||
try {
|
||||
const apiId = req.headers['x-api-id'] || req.body.apiId;
|
||||
const apiKey = req.headers['x-api-key'] || req.body.apiKey;
|
||||
|
||||
if (!apiId || !apiKey) {
|
||||
return res.status(401).json({ error: 'API ID and Key required' });
|
||||
}
|
||||
|
||||
const host = await prisma.host.findFirst({
|
||||
where: {
|
||||
apiId: apiId,
|
||||
apiKey: apiKey
|
||||
}
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
return res.status(401).json({ error: 'Invalid API credentials' });
|
||||
}
|
||||
|
||||
req.hostRecord = host;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('API credential validation error:', error);
|
||||
res.status(500).json({ error: 'API credential validation failed' });
|
||||
}
|
||||
};
|
||||
|
||||
// Admin endpoint to create a new host manually (replaces auto-registration)
|
||||
router.post('/create', authenticateToken, requireManageHosts, [
|
||||
body('hostname').isLength({ min: 1 }).withMessage('Hostname is required'),
|
||||
body('hostGroupId').optional()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { hostname, hostGroupId } = req.body;
|
||||
|
||||
// Generate unique API credentials for this host
|
||||
const { apiId, apiKey } = generateApiCredentials();
|
||||
|
||||
// Check if host already exists
|
||||
const existingHost = await prisma.host.findUnique({
|
||||
where: { hostname }
|
||||
});
|
||||
|
||||
if (existingHost) {
|
||||
return res.status(409).json({ error: 'Host already exists' });
|
||||
}
|
||||
|
||||
// If hostGroupId is provided, verify the group exists
|
||||
if (hostGroupId) {
|
||||
const hostGroup = await prisma.hostGroup.findUnique({
|
||||
where: { id: hostGroupId }
|
||||
});
|
||||
|
||||
if (!hostGroup) {
|
||||
return res.status(400).json({ error: 'Host group not found' });
|
||||
}
|
||||
}
|
||||
|
||||
// Create new host with API credentials - system info will be populated when agent connects
|
||||
const host = await prisma.host.create({
|
||||
data: {
|
||||
hostname,
|
||||
osType: 'unknown', // Will be updated when agent connects
|
||||
osVersion: 'unknown', // Will be updated when agent connects
|
||||
ip: null, // Will be updated when agent connects
|
||||
architecture: null, // Will be updated when agent connects
|
||||
apiId,
|
||||
apiKey,
|
||||
hostGroupId: hostGroupId || null,
|
||||
status: 'pending' // Will change to 'active' when agent connects
|
||||
},
|
||||
include: {
|
||||
hostGroup: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Host created successfully',
|
||||
hostId: host.id,
|
||||
hostname: host.hostname,
|
||||
apiId: host.apiId,
|
||||
apiKey: host.apiKey,
|
||||
hostGroup: host.hostGroup,
|
||||
instructions: 'Use these credentials in your patchmon agent configuration. System information will be automatically detected when the agent connects.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Host creation error:', error);
|
||||
res.status(500).json({ error: 'Failed to create host' });
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy register endpoint (deprecated - returns error message)
|
||||
router.post('/register', async (req, res) => {
|
||||
res.status(400).json({
|
||||
error: 'Host registration has been disabled. Please contact your administrator to add this host to PatchMon.',
|
||||
deprecated: true,
|
||||
message: 'Hosts must now be pre-created by administrators with specific API credentials.'
|
||||
});
|
||||
});
|
||||
|
||||
// Update host information and packages (now uses API credentials)
|
||||
router.post('/update', validateApiCredentials, [
|
||||
body('packages').isArray().withMessage('Packages must be an array'),
|
||||
body('packages.*.name').isLength({ min: 1 }).withMessage('Package name is required'),
|
||||
body('packages.*.currentVersion').isLength({ min: 1 }).withMessage('Current version is required'),
|
||||
body('packages.*.availableVersion').optional().isLength({ min: 1 }),
|
||||
body('packages.*.needsUpdate').isBoolean().withMessage('needsUpdate must be boolean'),
|
||||
body('packages.*.isSecurityUpdate').optional().isBoolean().withMessage('isSecurityUpdate must be boolean'),
|
||||
body('agentVersion').optional().isLength({ min: 1 }).withMessage('Agent version must be a non-empty string')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { packages, repositories } = req.body;
|
||||
const host = req.hostRecord;
|
||||
|
||||
// Update host last update timestamp and OS info if provided
|
||||
const updateData = { lastUpdate: new Date() };
|
||||
if (req.body.osType) updateData.osType = req.body.osType;
|
||||
if (req.body.osVersion) updateData.osVersion = req.body.osVersion;
|
||||
if (req.body.ip) updateData.ip = req.body.ip;
|
||||
if (req.body.architecture) updateData.architecture = req.body.architecture;
|
||||
if (req.body.agentVersion) updateData.agentVersion = req.body.agentVersion;
|
||||
|
||||
// If this is the first update (status is 'pending'), change to 'active'
|
||||
if (host.status === 'pending') {
|
||||
updateData.status = 'active';
|
||||
}
|
||||
|
||||
await prisma.host.update({
|
||||
where: { id: host.id },
|
||||
data: updateData
|
||||
});
|
||||
|
||||
// Process packages in transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Clear existing host packages
|
||||
await tx.hostPackage.deleteMany({
|
||||
where: { hostId: host.id }
|
||||
});
|
||||
|
||||
// Process each package
|
||||
for (const packageData of packages) {
|
||||
// Find or create package
|
||||
let pkg = await tx.package.findUnique({
|
||||
where: { name: packageData.name }
|
||||
});
|
||||
|
||||
if (!pkg) {
|
||||
pkg = await tx.package.create({
|
||||
data: {
|
||||
name: packageData.name,
|
||||
description: packageData.description || null,
|
||||
category: packageData.category || null,
|
||||
latestVersion: packageData.availableVersion || packageData.currentVersion
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Update package latest version if newer
|
||||
if (packageData.availableVersion && packageData.availableVersion !== pkg.latestVersion) {
|
||||
await tx.package.update({
|
||||
where: { id: pkg.id },
|
||||
data: { latestVersion: packageData.availableVersion }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Create host package relationship
|
||||
await tx.hostPackage.create({
|
||||
data: {
|
||||
hostId: host.id,
|
||||
packageId: pkg.id,
|
||||
currentVersion: packageData.currentVersion,
|
||||
availableVersion: packageData.availableVersion || null,
|
||||
needsUpdate: packageData.needsUpdate,
|
||||
isSecurityUpdate: packageData.isSecurityUpdate || false,
|
||||
lastChecked: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process repositories if provided
|
||||
if (repositories && Array.isArray(repositories)) {
|
||||
// Clear existing host repositories
|
||||
await tx.hostRepository.deleteMany({
|
||||
where: { hostId: host.id }
|
||||
});
|
||||
|
||||
// Process each repository
|
||||
for (const repoData of repositories) {
|
||||
// Find or create repository
|
||||
let repo = await tx.repository.findFirst({
|
||||
where: {
|
||||
url: repoData.url,
|
||||
distribution: repoData.distribution,
|
||||
components: repoData.components
|
||||
}
|
||||
});
|
||||
|
||||
if (!repo) {
|
||||
repo = await tx.repository.create({
|
||||
data: {
|
||||
name: repoData.name,
|
||||
url: repoData.url,
|
||||
distribution: repoData.distribution,
|
||||
components: repoData.components,
|
||||
repoType: repoData.repoType,
|
||||
isActive: true,
|
||||
isSecure: repoData.isSecure || false,
|
||||
description: `${repoData.repoType} repository for ${repoData.distribution}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create host repository relationship
|
||||
await tx.hostRepository.create({
|
||||
data: {
|
||||
hostId: host.id,
|
||||
repositoryId: repo.id,
|
||||
isEnabled: repoData.isEnabled !== false, // Default to enabled
|
||||
lastChecked: new Date()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create update history record
|
||||
const securityCount = packages.filter(pkg => pkg.isSecurityUpdate).length;
|
||||
const updatesCount = packages.filter(pkg => pkg.needsUpdate).length;
|
||||
|
||||
await prisma.updateHistory.create({
|
||||
data: {
|
||||
hostId: host.id,
|
||||
packagesCount: updatesCount,
|
||||
securityCount,
|
||||
status: 'success'
|
||||
}
|
||||
});
|
||||
|
||||
// Check if auto-update is enabled and if there's a newer agent version available
|
||||
let autoUpdateResponse = null;
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
// Check both global auto-update setting AND host-specific auto-update setting
|
||||
if (settings && settings.autoUpdate && host.autoUpdate) {
|
||||
// Get current agent version from the request
|
||||
const currentAgentVersion = req.body.agentVersion;
|
||||
|
||||
if (currentAgentVersion) {
|
||||
// Get the latest agent version
|
||||
const latestAgentVersion = await prisma.agentVersion.findFirst({
|
||||
where: { isCurrent: true },
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
if (latestAgentVersion && latestAgentVersion.version !== currentAgentVersion) {
|
||||
// There's a newer version available
|
||||
autoUpdateResponse = {
|
||||
shouldUpdate: true,
|
||||
currentVersion: currentAgentVersion,
|
||||
latestVersion: latestAgentVersion.version,
|
||||
message: 'A newer agent version is available. Run: /usr/local/bin/patchmon-agent.sh update-agent',
|
||||
updateCommand: 'update-agent'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auto-update check error:', error);
|
||||
// Don't fail the update if auto-update check fails
|
||||
}
|
||||
|
||||
const response = {
|
||||
message: 'Host updated successfully',
|
||||
packagesProcessed: packages.length,
|
||||
updatesAvailable: updatesCount,
|
||||
securityUpdates: securityCount
|
||||
};
|
||||
|
||||
// Add auto-update response if available
|
||||
if (autoUpdateResponse) {
|
||||
response.autoUpdate = autoUpdateResponse;
|
||||
}
|
||||
|
||||
// Check if crontab update is needed (when update interval changes)
|
||||
// This is a simple check - if the host has auto-update enabled, we'll suggest crontab update
|
||||
if (host.autoUpdate) {
|
||||
// For now, we'll always suggest crontab update to ensure it's current
|
||||
// In a more sophisticated implementation, we could track when the interval last changed
|
||||
response.crontabUpdate = {
|
||||
shouldUpdate: true,
|
||||
message: 'Please ensure your crontab is up to date with current interval settings',
|
||||
command: 'update-crontab'
|
||||
};
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Host update error:', error);
|
||||
|
||||
// Log error in update history
|
||||
try {
|
||||
await prisma.updateHistory.create({
|
||||
data: {
|
||||
hostId: req.hostRecord.id,
|
||||
packagesCount: 0,
|
||||
securityCount: 0,
|
||||
status: 'error',
|
||||
errorMessage: error.message
|
||||
}
|
||||
});
|
||||
} catch (logError) {
|
||||
console.error('Failed to log update error:', logError);
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to update host' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get host information (now uses API credentials)
|
||||
router.get('/info', validateApiCredentials, async (req, res) => {
|
||||
try {
|
||||
const host = await prisma.host.findUnique({
|
||||
where: { id: req.hostRecord.id },
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
osType: true,
|
||||
osVersion: true,
|
||||
architecture: true,
|
||||
lastUpdate: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
apiId: true // Include API ID for reference
|
||||
}
|
||||
});
|
||||
|
||||
res.json(host);
|
||||
} catch (error) {
|
||||
console.error('Get host info error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch host information' });
|
||||
}
|
||||
});
|
||||
|
||||
// Ping endpoint for health checks (now uses API credentials)
|
||||
router.post('/ping', validateApiCredentials, async (req, res) => {
|
||||
try {
|
||||
// Update last update timestamp
|
||||
await prisma.host.update({
|
||||
where: { id: req.hostRecord.id },
|
||||
data: { lastUpdate: new Date() }
|
||||
});
|
||||
|
||||
const response = {
|
||||
message: 'Ping successful',
|
||||
timestamp: new Date().toISOString(),
|
||||
hostname: req.hostRecord.hostname
|
||||
};
|
||||
|
||||
// Check if this is a crontab update trigger
|
||||
if (req.body.triggerCrontabUpdate && req.hostRecord.autoUpdate) {
|
||||
console.log(`Triggering crontab update for host: ${req.hostRecord.hostname}`);
|
||||
response.crontabUpdate = {
|
||||
shouldUpdate: true,
|
||||
message: 'Update interval changed, please run: /usr/local/bin/patchmon-agent.sh update-crontab',
|
||||
command: 'update-crontab'
|
||||
};
|
||||
}
|
||||
|
||||
res.json(response);
|
||||
} catch (error) {
|
||||
console.error('Ping error:', error);
|
||||
res.status(500).json({ error: 'Ping failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to regenerate API credentials for a host
|
||||
router.post('/:hostId/regenerate-credentials', authenticateToken, requireManageHosts, async (req, res) => {
|
||||
try {
|
||||
const { hostId } = req.params;
|
||||
|
||||
const host = await prisma.host.findUnique({
|
||||
where: { id: hostId }
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: 'Host not found' });
|
||||
}
|
||||
|
||||
// Generate new API credentials
|
||||
const { apiId, apiKey } = generateApiCredentials();
|
||||
|
||||
// Update host with new credentials
|
||||
const updatedHost = await prisma.host.update({
|
||||
where: { id: hostId },
|
||||
data: { apiId, apiKey }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'API credentials regenerated successfully',
|
||||
hostname: updatedHost.hostname,
|
||||
apiId: updatedHost.apiId,
|
||||
apiKey: updatedHost.apiKey,
|
||||
warning: 'Previous credentials are now invalid. Update your agent configuration.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Credential regeneration error:', error);
|
||||
res.status(500).json({ error: 'Failed to regenerate credentials' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to bulk update host groups
|
||||
router.put('/bulk/group', authenticateToken, requireManageHosts, [
|
||||
body('hostIds').isArray().withMessage('Host IDs must be an array'),
|
||||
body('hostIds.*').isLength({ min: 1 }).withMessage('Each host ID must be provided'),
|
||||
body('hostGroupId').optional()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { hostIds, hostGroupId } = req.body;
|
||||
|
||||
// If hostGroupId is provided, verify the group exists
|
||||
if (hostGroupId) {
|
||||
const hostGroup = await prisma.hostGroup.findUnique({
|
||||
where: { id: hostGroupId }
|
||||
});
|
||||
|
||||
if (!hostGroup) {
|
||||
return res.status(400).json({ error: 'Host group not found' });
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all hosts exist
|
||||
const existingHosts = await prisma.host.findMany({
|
||||
where: { id: { in: hostIds } },
|
||||
select: { id: true, hostname: true }
|
||||
});
|
||||
|
||||
if (existingHosts.length !== hostIds.length) {
|
||||
const foundIds = existingHosts.map(h => h.id);
|
||||
const missingIds = hostIds.filter(id => !foundIds.includes(id));
|
||||
return res.status(400).json({
|
||||
error: 'Some hosts not found',
|
||||
missingHostIds: missingIds
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk update host groups
|
||||
const updateResult = await prisma.host.updateMany({
|
||||
where: { id: { in: hostIds } },
|
||||
data: {
|
||||
hostGroupId: hostGroupId || null
|
||||
}
|
||||
});
|
||||
|
||||
// Get updated hosts with group information
|
||||
const updatedHosts = await prisma.host.findMany({
|
||||
where: { id: { in: hostIds } },
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
hostGroup: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `Successfully updated ${updateResult.count} host${updateResult.count !== 1 ? 's' : ''}`,
|
||||
updatedCount: updateResult.count,
|
||||
hosts: updatedHosts
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Bulk host group update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update host groups' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to update host group
|
||||
router.put('/:hostId/group', authenticateToken, requireManageHosts, [
|
||||
body('hostGroupId').optional()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { hostId } = req.params;
|
||||
const { hostGroupId } = req.body;
|
||||
|
||||
// Check if host exists
|
||||
const host = await prisma.host.findUnique({
|
||||
where: { id: hostId }
|
||||
});
|
||||
|
||||
if (!host) {
|
||||
return res.status(404).json({ error: 'Host not found' });
|
||||
}
|
||||
|
||||
// If hostGroupId is provided, verify the group exists
|
||||
if (hostGroupId) {
|
||||
const hostGroup = await prisma.hostGroup.findUnique({
|
||||
where: { id: hostGroupId }
|
||||
});
|
||||
|
||||
if (!hostGroup) {
|
||||
return res.status(400).json({ error: 'Host group not found' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update host group
|
||||
const updatedHost = await prisma.host.update({
|
||||
where: { id: hostId },
|
||||
data: {
|
||||
hostGroupId: hostGroupId || null
|
||||
},
|
||||
include: {
|
||||
hostGroup: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
color: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Host group updated successfully',
|
||||
host: updatedHost
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Host group update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update host group' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to list all hosts
|
||||
router.get('/admin/list', authenticateToken, requireManageHosts, async (req, res) => {
|
||||
try {
|
||||
const hosts = await prisma.host.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
osType: true,
|
||||
osVersion: true,
|
||||
architecture: true,
|
||||
lastUpdate: true,
|
||||
status: true,
|
||||
apiId: true,
|
||||
agentVersion: true,
|
||||
autoUpdate: true,
|
||||
createdAt: true
|
||||
},
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
res.json(hosts);
|
||||
} catch (error) {
|
||||
console.error('List hosts error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch hosts' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to delete host
|
||||
router.delete('/:hostId', authenticateToken, requireManageHosts, async (req, res) => {
|
||||
try {
|
||||
const { hostId } = req.params;
|
||||
|
||||
// Delete host and all related data (cascade)
|
||||
await prisma.host.delete({
|
||||
where: { id: hostId }
|
||||
});
|
||||
|
||||
res.json({ message: 'Host deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Host deletion error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete host' });
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle host auto-update setting
|
||||
router.patch('/:hostId/auto-update', authenticateToken, requireManageHosts, [
|
||||
body('autoUpdate').isBoolean().withMessage('Auto-update must be a boolean')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { hostId } = req.params;
|
||||
const { autoUpdate } = req.body;
|
||||
|
||||
const host = await prisma.host.update({
|
||||
where: { id: hostId },
|
||||
data: { autoUpdate }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `Host auto-update ${autoUpdate ? 'enabled' : 'disabled'} successfully`,
|
||||
host: {
|
||||
id: host.id,
|
||||
hostname: host.hostname,
|
||||
autoUpdate: host.autoUpdate
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Host auto-update toggle error:', error);
|
||||
res.status(500).json({ error: 'Failed to toggle host auto-update' });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve the installation script
|
||||
router.get('/install', async (req, res) => {
|
||||
try {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const scriptPath = path.join(__dirname, '../../../agents/patchmon_install.sh');
|
||||
|
||||
if (!fs.existsSync(scriptPath)) {
|
||||
return res.status(404).json({ error: 'Installation script not found' });
|
||||
}
|
||||
|
||||
let script = fs.readFileSync(scriptPath, 'utf8');
|
||||
|
||||
// Get the configured server URL from settings
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
if (settings) {
|
||||
// Replace the default server URL in the script with the configured one
|
||||
script = script.replace(
|
||||
/PATCHMON_URL="[^"]*"/g,
|
||||
`PATCHMON_URL="${settings.serverUrl}"`
|
||||
);
|
||||
}
|
||||
} catch (settingsError) {
|
||||
console.warn('Could not fetch settings, using default server URL:', settingsError.message);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'text/plain');
|
||||
res.setHeader('Content-Disposition', 'inline; filename="patchmon_install.sh"');
|
||||
res.send(script);
|
||||
} catch (error) {
|
||||
console.error('Installation script error:', error);
|
||||
res.status(500).json({ error: 'Failed to serve installation script' });
|
||||
}
|
||||
});
|
||||
|
||||
// ==================== AGENT VERSION MANAGEMENT ====================
|
||||
|
||||
// Get all agent versions (admin only)
|
||||
router.get('/agent/versions', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
const versions = await prisma.agentVersion.findMany({
|
||||
orderBy: { createdAt: 'desc' }
|
||||
});
|
||||
|
||||
res.json(versions);
|
||||
} catch (error) {
|
||||
console.error('Get agent versions error:', error);
|
||||
res.status(500).json({ error: 'Failed to get agent versions' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create new agent version (admin only)
|
||||
router.post('/agent/versions', authenticateToken, requireManageSettings, [
|
||||
body('version').isLength({ min: 1 }).withMessage('Version is required'),
|
||||
body('releaseNotes').optional().isString(),
|
||||
body('downloadUrl').optional().isURL().withMessage('Download URL must be valid'),
|
||||
body('minServerVersion').optional().isString(),
|
||||
body('scriptContent').optional().isString(),
|
||||
body('isDefault').optional().isBoolean()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { version, releaseNotes, downloadUrl, minServerVersion, scriptContent, isDefault } = req.body;
|
||||
|
||||
// Check if version already exists
|
||||
const existingVersion = await prisma.agentVersion.findUnique({
|
||||
where: { version }
|
||||
});
|
||||
|
||||
if (existingVersion) {
|
||||
return res.status(400).json({ error: 'Version already exists' });
|
||||
}
|
||||
|
||||
// If this is being set as default, unset other defaults
|
||||
if (isDefault) {
|
||||
await prisma.agentVersion.updateMany({
|
||||
where: { isDefault: true },
|
||||
data: { isDefault: false }
|
||||
});
|
||||
}
|
||||
|
||||
const agentVersion = await prisma.agentVersion.create({
|
||||
data: {
|
||||
version,
|
||||
releaseNotes,
|
||||
downloadUrl,
|
||||
minServerVersion,
|
||||
scriptContent,
|
||||
isDefault: isDefault || false,
|
||||
isCurrent: false
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json(agentVersion);
|
||||
} catch (error) {
|
||||
console.error('Create agent version error:', error);
|
||||
res.status(500).json({ error: 'Failed to create agent version' });
|
||||
}
|
||||
});
|
||||
|
||||
// Set current agent version (admin only)
|
||||
router.patch('/agent/versions/:versionId/current', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
const { versionId } = req.params;
|
||||
|
||||
// First, unset all current versions
|
||||
await prisma.agentVersion.updateMany({
|
||||
where: { isCurrent: true },
|
||||
data: { isCurrent: false }
|
||||
});
|
||||
|
||||
// Set the specified version as current
|
||||
const agentVersion = await prisma.agentVersion.update({
|
||||
where: { id: versionId },
|
||||
data: { isCurrent: true }
|
||||
});
|
||||
|
||||
res.json(agentVersion);
|
||||
} catch (error) {
|
||||
console.error('Set current agent version error:', error);
|
||||
res.status(500).json({ error: 'Failed to set current agent version' });
|
||||
}
|
||||
});
|
||||
|
||||
// Set default agent version (admin only)
|
||||
router.patch('/agent/versions/:versionId/default', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
const { versionId } = req.params;
|
||||
|
||||
// First, unset all default versions
|
||||
await prisma.agentVersion.updateMany({
|
||||
where: { isDefault: true },
|
||||
data: { isDefault: false }
|
||||
});
|
||||
|
||||
// Set the specified version as default
|
||||
const agentVersion = await prisma.agentVersion.update({
|
||||
where: { id: versionId },
|
||||
data: { isDefault: true }
|
||||
});
|
||||
|
||||
res.json(agentVersion);
|
||||
} catch (error) {
|
||||
console.error('Set default agent version error:', error);
|
||||
res.status(500).json({ error: 'Failed to set default agent version' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete agent version (admin only)
|
||||
router.delete('/agent/versions/:versionId', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
const { versionId } = req.params;
|
||||
|
||||
const agentVersion = await prisma.agentVersion.findUnique({
|
||||
where: { id: versionId }
|
||||
});
|
||||
|
||||
if (!agentVersion) {
|
||||
return res.status(404).json({ error: 'Agent version not found' });
|
||||
}
|
||||
|
||||
if (agentVersion.isCurrent) {
|
||||
return res.status(400).json({ error: 'Cannot delete current agent version' });
|
||||
}
|
||||
|
||||
await prisma.agentVersion.delete({
|
||||
where: { id: versionId }
|
||||
});
|
||||
|
||||
res.json({ message: 'Agent version deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Delete agent version error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete agent version' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
213
backend/src/routes/packageRoutes.js
Normal file
213
backend/src/routes/packageRoutes.js
Normal file
@@ -0,0 +1,213 @@
|
||||
const express = require('express');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Get all packages with their update status
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 50,
|
||||
search = '',
|
||||
category = '',
|
||||
needsUpdate = '',
|
||||
isSecurityUpdate = ''
|
||||
} = req.query;
|
||||
|
||||
const skip = (parseInt(page) - 1) * parseInt(limit);
|
||||
const take = parseInt(limit);
|
||||
|
||||
// Build where clause
|
||||
const where = {
|
||||
AND: [
|
||||
// Search filter
|
||||
search ? {
|
||||
OR: [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } }
|
||||
]
|
||||
} : {},
|
||||
// Category filter
|
||||
category ? { category: { equals: category } } : {},
|
||||
// Update status filters
|
||||
needsUpdate ? {
|
||||
hostPackages: {
|
||||
some: {
|
||||
needsUpdate: needsUpdate === 'true'
|
||||
}
|
||||
}
|
||||
} : {},
|
||||
isSecurityUpdate ? {
|
||||
hostPackages: {
|
||||
some: {
|
||||
isSecurityUpdate: isSecurityUpdate === 'true'
|
||||
}
|
||||
}
|
||||
} : {}
|
||||
]
|
||||
};
|
||||
|
||||
// Get packages with counts
|
||||
const [packages, totalCount] = await Promise.all([
|
||||
prisma.package.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
category: true,
|
||||
latestVersion: true,
|
||||
createdAt: true,
|
||||
_count: {
|
||||
hostPackages: true
|
||||
}
|
||||
},
|
||||
skip,
|
||||
take,
|
||||
orderBy: {
|
||||
name: 'asc'
|
||||
}
|
||||
}),
|
||||
prisma.package.count({ where })
|
||||
]);
|
||||
|
||||
// Get additional stats for each package
|
||||
const packagesWithStats = await Promise.all(
|
||||
packages.map(async (pkg) => {
|
||||
const [updatesCount, securityCount, affectedHosts] = await Promise.all([
|
||||
prisma.hostPackage.count({
|
||||
where: {
|
||||
packageId: pkg.id,
|
||||
needsUpdate: true
|
||||
}
|
||||
}),
|
||||
prisma.hostPackage.count({
|
||||
where: {
|
||||
packageId: pkg.id,
|
||||
needsUpdate: true,
|
||||
isSecurityUpdate: true
|
||||
}
|
||||
}),
|
||||
prisma.hostPackage.findMany({
|
||||
where: {
|
||||
packageId: pkg.id,
|
||||
needsUpdate: true
|
||||
},
|
||||
select: {
|
||||
host: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
osType: true
|
||||
}
|
||||
}
|
||||
},
|
||||
take: 10 // Limit to first 10 for performance
|
||||
})
|
||||
]);
|
||||
|
||||
return {
|
||||
...pkg,
|
||||
stats: {
|
||||
totalInstalls: pkg._count.hostPackages,
|
||||
updatesNeeded: updatesCount,
|
||||
securityUpdates: securityCount,
|
||||
affectedHosts: affectedHosts.map(hp => hp.host)
|
||||
}
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json({
|
||||
packages: packagesWithStats,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total: totalCount,
|
||||
pages: Math.ceil(totalCount / parseInt(limit))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching packages:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch packages' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get package details by ID
|
||||
router.get('/:packageId', async (req, res) => {
|
||||
try {
|
||||
const { packageId } = req.params;
|
||||
|
||||
const packageData = await prisma.package.findUnique({
|
||||
where: { id: packageId },
|
||||
include: {
|
||||
hostPackages: {
|
||||
include: {
|
||||
host: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
osType: true,
|
||||
osVersion: true,
|
||||
lastUpdate: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
needsUpdate: 'desc'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!packageData) {
|
||||
return res.status(404).json({ error: 'Package not found' });
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const stats = {
|
||||
totalInstalls: packageData.hostPackages.length,
|
||||
updatesNeeded: packageData.hostPackages.filter(hp => hp.needsUpdate).length,
|
||||
securityUpdates: packageData.hostPackages.filter(hp => hp.needsUpdate && hp.isSecurityUpdate).length,
|
||||
upToDate: packageData.hostPackages.filter(hp => !hp.needsUpdate).length
|
||||
};
|
||||
|
||||
// Group by version
|
||||
const versionDistribution = packageData.hostPackages.reduce((acc, hp) => {
|
||||
const version = hp.currentVersion;
|
||||
acc[version] = (acc[version] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Group by OS type
|
||||
const osDistribution = packageData.hostPackages.reduce((acc, hp) => {
|
||||
const osType = hp.host.osType;
|
||||
acc[osType] = (acc[osType] || 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
res.json({
|
||||
...packageData,
|
||||
stats,
|
||||
distributions: {
|
||||
versions: Object.entries(versionDistribution).map(([version, count]) => ({
|
||||
version,
|
||||
count
|
||||
})),
|
||||
osTypes: Object.entries(osDistribution).map(([osType, count]) => ({
|
||||
osType,
|
||||
count
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching package details:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch package details' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
173
backend/src/routes/permissionsRoutes.js
Normal file
173
backend/src/routes/permissionsRoutes.js
Normal file
@@ -0,0 +1,173 @@
|
||||
const express = require('express');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const { requireManageSettings } = require('../middleware/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Get all role permissions
|
||||
router.get('/roles', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
const permissions = await prisma.rolePermissions.findMany({
|
||||
orderBy: {
|
||||
role: 'asc'
|
||||
}
|
||||
});
|
||||
|
||||
res.json(permissions);
|
||||
} catch (error) {
|
||||
console.error('Get role permissions error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch role permissions' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get permissions for a specific role
|
||||
router.get('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
const { role } = req.params;
|
||||
|
||||
const permissions = await prisma.rolePermissions.findUnique({
|
||||
where: { role }
|
||||
});
|
||||
|
||||
if (!permissions) {
|
||||
return res.status(404).json({ error: 'Role not found' });
|
||||
}
|
||||
|
||||
res.json(permissions);
|
||||
} catch (error) {
|
||||
console.error('Get role permission error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch role permission' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create or update role permissions
|
||||
router.put('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
const { role } = req.params;
|
||||
const {
|
||||
canViewDashboard,
|
||||
canViewHosts,
|
||||
canManageHosts,
|
||||
canViewPackages,
|
||||
canManagePackages,
|
||||
canViewUsers,
|
||||
canManageUsers,
|
||||
canViewReports,
|
||||
canExportData,
|
||||
canManageSettings
|
||||
} = req.body;
|
||||
|
||||
// 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' });
|
||||
}
|
||||
|
||||
const permissions = await prisma.rolePermissions.upsert({
|
||||
where: { role },
|
||||
update: {
|
||||
canViewDashboard,
|
||||
canViewHosts,
|
||||
canManageHosts,
|
||||
canViewPackages,
|
||||
canManagePackages,
|
||||
canViewUsers,
|
||||
canManageUsers,
|
||||
canViewReports,
|
||||
canExportData,
|
||||
canManageSettings
|
||||
},
|
||||
create: {
|
||||
role,
|
||||
canViewDashboard,
|
||||
canViewHosts,
|
||||
canManageHosts,
|
||||
canViewPackages,
|
||||
canManagePackages,
|
||||
canViewUsers,
|
||||
canManageUsers,
|
||||
canViewReports,
|
||||
canExportData,
|
||||
canManageSettings
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: 'Role permissions updated successfully',
|
||||
permissions
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update role permissions error:', error);
|
||||
res.status(500).json({ error: 'Failed to update role permissions' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a role (and its permissions)
|
||||
router.delete('/roles/:role', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
const { role } = req.params;
|
||||
|
||||
// Prevent deleting admin role
|
||||
if (role === 'admin') {
|
||||
return res.status(400).json({ error: 'Cannot delete admin role' });
|
||||
}
|
||||
|
||||
// Check if any users are using this role
|
||||
const usersWithRole = await prisma.user.count({
|
||||
where: { role }
|
||||
});
|
||||
|
||||
if (usersWithRole > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Cannot delete role "${role}" because ${usersWithRole} user(s) are currently using it`
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.rolePermissions.delete({
|
||||
where: { role }
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `Role "${role}" deleted successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Delete role error:', error);
|
||||
res.status(500).json({ error: 'Failed to delete role' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user's permissions based on their role
|
||||
router.get('/user-permissions', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const userRole = req.user.role;
|
||||
|
||||
const permissions = await prisma.rolePermissions.findUnique({
|
||||
where: { role: userRole }
|
||||
});
|
||||
|
||||
if (!permissions) {
|
||||
// If no specific permissions found, return default admin permissions
|
||||
return res.json({
|
||||
role: userRole,
|
||||
canViewDashboard: true,
|
||||
canViewHosts: true,
|
||||
canManageHosts: true,
|
||||
canViewPackages: true,
|
||||
canManagePackages: true,
|
||||
canViewUsers: true,
|
||||
canManageUsers: true,
|
||||
canViewReports: true,
|
||||
canExportData: true,
|
||||
canManageSettings: true,
|
||||
});
|
||||
}
|
||||
|
||||
res.json(permissions);
|
||||
} catch (error) {
|
||||
console.error('Get user permissions error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch user permissions' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
301
backend/src/routes/repositoryRoutes.js
Normal file
301
backend/src/routes/repositoryRoutes.js
Normal file
@@ -0,0 +1,301 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { requireViewHosts, requireManageHosts } = require('../middleware/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Get all repositories with host count
|
||||
router.get('/', authenticateToken, requireViewHosts, async (req, res) => {
|
||||
try {
|
||||
const repositories = await prisma.repository.findMany({
|
||||
include: {
|
||||
hostRepositories: {
|
||||
include: {
|
||||
host: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
status: true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
hostRepositories: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [
|
||||
{ name: 'asc' },
|
||||
{ url: 'asc' }
|
||||
]
|
||||
});
|
||||
|
||||
// Transform data to include host counts and status
|
||||
const transformedRepos = repositories.map(repo => ({
|
||||
...repo,
|
||||
hostCount: repo._count.hostRepositories,
|
||||
enabledHostCount: repo.hostRepositories.filter(hr => hr.isEnabled).length,
|
||||
activeHostCount: repo.hostRepositories.filter(hr => hr.host.status === 'active').length,
|
||||
hosts: repo.hostRepositories.map(hr => ({
|
||||
id: hr.host.id,
|
||||
hostname: hr.host.hostname,
|
||||
status: hr.host.status,
|
||||
isEnabled: hr.isEnabled,
|
||||
lastChecked: hr.lastChecked
|
||||
}))
|
||||
}));
|
||||
|
||||
res.json(transformedRepos);
|
||||
} catch (error) {
|
||||
console.error('Repository list error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch repositories' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get repositories for a specific host
|
||||
router.get('/host/:hostId', authenticateToken, requireViewHosts, async (req, res) => {
|
||||
try {
|
||||
const { hostId } = req.params;
|
||||
|
||||
const hostRepositories = await prisma.hostRepository.findMany({
|
||||
where: { hostId },
|
||||
include: {
|
||||
repository: true,
|
||||
host: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
repository: {
|
||||
name: 'asc'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json(hostRepositories);
|
||||
} catch (error) {
|
||||
console.error('Host repositories error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch host repositories' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get repository details with all hosts
|
||||
router.get('/:repositoryId', authenticateToken, requireViewHosts, async (req, res) => {
|
||||
try {
|
||||
const { repositoryId } = req.params;
|
||||
|
||||
const repository = await prisma.repository.findUnique({
|
||||
where: { id: repositoryId },
|
||||
include: {
|
||||
hostRepositories: {
|
||||
include: {
|
||||
host: {
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
ip: true,
|
||||
osType: true,
|
||||
osVersion: true,
|
||||
status: true,
|
||||
lastUpdate: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: {
|
||||
host: {
|
||||
hostname: 'asc'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
return res.status(404).json({ error: 'Repository not found' });
|
||||
}
|
||||
|
||||
res.json(repository);
|
||||
} catch (error) {
|
||||
console.error('Repository detail error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch repository details' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update repository information (admin only)
|
||||
router.put('/:repositoryId', authenticateToken, requireManageHosts, [
|
||||
body('name').optional().isLength({ min: 1 }).withMessage('Name is required'),
|
||||
body('description').optional(),
|
||||
body('isActive').optional().isBoolean().withMessage('isActive must be a boolean'),
|
||||
body('priority').optional().isInt({ min: 0 }).withMessage('Priority must be a positive integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { repositoryId } = req.params;
|
||||
const { name, description, isActive, priority } = req.body;
|
||||
|
||||
const repository = await prisma.repository.update({
|
||||
where: { id: repositoryId },
|
||||
data: {
|
||||
...(name && { name }),
|
||||
...(description !== undefined && { description }),
|
||||
...(isActive !== undefined && { isActive }),
|
||||
...(priority !== undefined && { priority })
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
hostRepositories: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json(repository);
|
||||
} catch (error) {
|
||||
console.error('Repository update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update repository' });
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle repository status for a specific host
|
||||
router.patch('/host/:hostId/repository/:repositoryId', authenticateToken, requireManageHosts, [
|
||||
body('isEnabled').isBoolean().withMessage('isEnabled must be a boolean')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { hostId, repositoryId } = req.params;
|
||||
const { isEnabled } = req.body;
|
||||
|
||||
const hostRepository = await prisma.hostRepository.update({
|
||||
where: {
|
||||
hostId_repositoryId: {
|
||||
hostId,
|
||||
repositoryId
|
||||
}
|
||||
},
|
||||
data: {
|
||||
isEnabled,
|
||||
lastChecked: new Date()
|
||||
},
|
||||
include: {
|
||||
repository: true,
|
||||
host: {
|
||||
select: {
|
||||
hostname: true
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `Repository ${isEnabled ? 'enabled' : 'disabled'} for host ${hostRepository.host.hostname}`,
|
||||
hostRepository
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Host repository toggle error:', error);
|
||||
res.status(500).json({ error: 'Failed to toggle repository status' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get repository statistics
|
||||
router.get('/stats/summary', authenticateToken, requireViewHosts, async (req, res) => {
|
||||
try {
|
||||
const stats = await prisma.repository.aggregate({
|
||||
_count: true
|
||||
});
|
||||
|
||||
const hostRepoStats = await prisma.hostRepository.aggregate({
|
||||
_count: {
|
||||
isEnabled: true
|
||||
},
|
||||
where: {
|
||||
isEnabled: true
|
||||
}
|
||||
});
|
||||
|
||||
const secureRepos = await prisma.repository.count({
|
||||
where: { isSecure: true }
|
||||
});
|
||||
|
||||
const activeRepos = await prisma.repository.count({
|
||||
where: { isActive: true }
|
||||
});
|
||||
|
||||
res.json({
|
||||
totalRepositories: stats._count,
|
||||
activeRepositories: activeRepos,
|
||||
secureRepositories: secureRepos,
|
||||
enabledHostRepositories: hostRepoStats._count.isEnabled,
|
||||
securityPercentage: stats._count > 0 ? Math.round((secureRepos / stats._count) * 100) : 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Repository stats error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch repository statistics' });
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup orphaned repositories (admin only)
|
||||
router.delete('/cleanup/orphaned', authenticateToken, requireManageHosts, async (req, res) => {
|
||||
try {
|
||||
console.log('Cleaning up orphaned repositories...');
|
||||
|
||||
// Find repositories with no host relationships
|
||||
const orphanedRepos = await prisma.repository.findMany({
|
||||
where: {
|
||||
hostRepositories: {
|
||||
none: {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (orphanedRepos.length === 0) {
|
||||
return res.json({
|
||||
message: 'No orphaned repositories found',
|
||||
deletedCount: 0,
|
||||
deletedRepositories: []
|
||||
});
|
||||
}
|
||||
|
||||
// Delete orphaned repositories
|
||||
const deleteResult = await prisma.repository.deleteMany({
|
||||
where: {
|
||||
hostRepositories: {
|
||||
none: {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Deleted ${deleteResult.count} orphaned repositories`);
|
||||
|
||||
res.json({
|
||||
message: `Successfully deleted ${deleteResult.count} orphaned repositories`,
|
||||
deletedCount: deleteResult.count,
|
||||
deletedRepositories: orphanedRepos.map(repo => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
url: repo.url
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Repository cleanup error:', error);
|
||||
res.status(500).json({ error: 'Failed to cleanup orphaned repositories' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
253
backend/src/routes/settingsRoutes.js
Normal file
253
backend/src/routes/settingsRoutes.js
Normal file
@@ -0,0 +1,253 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { requireManageSettings } = require('../middleware/permissions');
|
||||
|
||||
const router = express.Router();
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Function to trigger crontab updates on all hosts with auto-update enabled
|
||||
async function triggerCrontabUpdates() {
|
||||
try {
|
||||
console.log('Triggering crontab updates on all hosts with auto-update enabled...');
|
||||
|
||||
// Get all hosts that have auto-update enabled
|
||||
const hosts = await prisma.host.findMany({
|
||||
where: {
|
||||
autoUpdate: true,
|
||||
status: 'active' // Only update active hosts
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
hostname: true,
|
||||
apiId: true,
|
||||
apiKey: true
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`Found ${hosts.length} hosts with auto-update enabled`);
|
||||
|
||||
// For each host, we'll send a special update command that triggers crontab update
|
||||
// This is done by sending a ping with a special flag
|
||||
for (const host of hosts) {
|
||||
try {
|
||||
console.log(`Triggering crontab update for host: ${host.hostname}`);
|
||||
|
||||
// We'll use the existing ping endpoint but add a special parameter
|
||||
// The agent will detect this and run update-crontab command
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
|
||||
const serverUrl = process.env.SERVER_URL || 'http://localhost:3001';
|
||||
const url = new URL(`${serverUrl}/api/v1/hosts/ping`);
|
||||
const isHttps = url.protocol === 'https:';
|
||||
const client = isHttps ? https : http;
|
||||
|
||||
const postData = JSON.stringify({
|
||||
triggerCrontabUpdate: true,
|
||||
message: 'Update interval changed, please update your crontab'
|
||||
});
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(postData),
|
||||
'X-API-ID': host.apiId,
|
||||
'X-API-KEY': host.apiKey
|
||||
}
|
||||
};
|
||||
|
||||
const req = client.request(options, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
console.log(`Successfully triggered crontab update for ${host.hostname}`);
|
||||
} else {
|
||||
console.error(`Failed to trigger crontab update for ${host.hostname}: ${res.statusCode}`);
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
|
||||
});
|
||||
|
||||
req.write(postData);
|
||||
req.end();
|
||||
} catch (error) {
|
||||
console.error(`Error triggering crontab update for ${host.hostname}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Crontab update trigger completed');
|
||||
} catch (error) {
|
||||
console.error('Error in triggerCrontabUpdates:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get current settings
|
||||
router.get('/', authenticateToken, requireManageSettings, async (req, res) => {
|
||||
try {
|
||||
let settings = await prisma.settings.findFirst();
|
||||
|
||||
// If no settings exist, create default settings
|
||||
if (!settings) {
|
||||
settings = await prisma.settings.create({
|
||||
data: {
|
||||
serverUrl: 'http://localhost:3001',
|
||||
serverProtocol: 'http',
|
||||
serverHost: 'localhost',
|
||||
serverPort: 3001,
|
||||
frontendUrl: 'http://localhost:3000',
|
||||
updateInterval: 60,
|
||||
autoUpdate: false
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Returning settings:', settings);
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
console.error('Settings fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update settings
|
||||
router.put('/', authenticateToken, requireManageSettings, [
|
||||
body('serverProtocol').isIn(['http', 'https']).withMessage('Protocol must be http or https'),
|
||||
body('serverHost').isLength({ min: 1 }).withMessage('Server host is required'),
|
||||
body('serverPort').isInt({ min: 1, max: 65535 }).withMessage('Port must be between 1 and 65535'),
|
||||
body('frontendUrl').isLength({ min: 1 }).withMessage('Frontend URL is required'),
|
||||
body('updateInterval').isInt({ min: 5, max: 1440 }).withMessage('Update interval must be between 5 and 1440 minutes'),
|
||||
body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean')
|
||||
], 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 } = req.body;
|
||||
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate });
|
||||
|
||||
// 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
|
||||
});
|
||||
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
|
||||
}
|
||||
});
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Settings updated successfully',
|
||||
settings
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Settings update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update settings' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get server URL for public use (used by installation scripts)
|
||||
router.get('/server-url', async (req, res) => {
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
|
||||
if (!settings) {
|
||||
return res.json({ serverUrl: 'http://localhost:3001' });
|
||||
}
|
||||
|
||||
res.json({ serverUrl: settings.serverUrl });
|
||||
} catch (error) {
|
||||
console.error('Server URL fetch error:', error);
|
||||
res.json({ serverUrl: 'http://localhost:3001' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get update interval policy for agents (public endpoint)
|
||||
router.get('/update-interval', async (req, res) => {
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
|
||||
if (!settings) {
|
||||
return res.json({ updateInterval: 60 });
|
||||
}
|
||||
|
||||
res.json({
|
||||
updateInterval: settings.updateInterval,
|
||||
cronExpression: `*/${settings.updateInterval} * * * *` // Generate cron expression
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update interval fetch error:', error);
|
||||
res.json({ updateInterval: 60, cronExpression: '0 * * * *' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get auto-update policy for agents (public endpoint)
|
||||
router.get('/auto-update', async (req, res) => {
|
||||
try {
|
||||
const settings = await prisma.settings.findFirst();
|
||||
|
||||
if (!settings) {
|
||||
return res.json({ autoUpdate: false });
|
||||
}
|
||||
|
||||
res.json({
|
||||
autoUpdate: settings.autoUpdate || false
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Auto-update fetch error:', error);
|
||||
res.json({ autoUpdate: false });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
179
backend/src/server.js
Normal file
179
backend/src/server.js
Normal file
@@ -0,0 +1,179 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const winston = require('winston');
|
||||
|
||||
// Import routes
|
||||
const authRoutes = require('./routes/authRoutes');
|
||||
const hostRoutes = require('./routes/hostRoutes');
|
||||
const hostGroupRoutes = require('./routes/hostGroupRoutes');
|
||||
const packageRoutes = require('./routes/packageRoutes');
|
||||
const dashboardRoutes = require('./routes/dashboardRoutes');
|
||||
const permissionsRoutes = require('./routes/permissionsRoutes');
|
||||
const settingsRoutes = require('./routes/settingsRoutes');
|
||||
const dashboardPreferencesRoutes = require('./routes/dashboardPreferencesRoutes');
|
||||
const repositoryRoutes = require('./routes/repositoryRoutes');
|
||||
|
||||
// Initialize Prisma client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Initialize logger - only if logging is enabled
|
||||
const logger = process.env.ENABLE_LOGGING === 'true' ? winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.json()
|
||||
),
|
||||
transports: [
|
||||
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
|
||||
new winston.transports.File({ filename: 'logs/combined.log' }),
|
||||
],
|
||||
}) : {
|
||||
info: () => {},
|
||||
error: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {}
|
||||
};
|
||||
|
||||
if (process.env.ENABLE_LOGGING === 'true' && process.env.NODE_ENV !== 'production') {
|
||||
logger.add(new winston.transports.Console({
|
||||
format: winston.format.simple()
|
||||
}));
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Trust proxy (needed when behind reverse proxy) and remove X-Powered-By
|
||||
if (process.env.TRUST_PROXY) {
|
||||
app.set('trust proxy', process.env.TRUST_PROXY === 'true' ? 1 : parseInt(process.env.TRUST_PROXY, 10) || true);
|
||||
} else {
|
||||
app.set('trust proxy', 1);
|
||||
}
|
||||
app.disable('x-powered-by');
|
||||
|
||||
// Rate limiting
|
||||
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.',
|
||||
});
|
||||
|
||||
// Middleware
|
||||
// Helmet with stricter defaults (CSP/HSTS only in production)
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: process.env.NODE_ENV === 'production' ? {
|
||||
useDefaults: true,
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:'],
|
||||
fontSrc: ["'self'", 'data:'],
|
||||
connectSrc: ["'self'"],
|
||||
frameAncestors: ["'none'"],
|
||||
objectSrc: ["'none'"]
|
||||
}
|
||||
} : false,
|
||||
hsts: process.env.ENABLE_HSTS === 'true' || process.env.NODE_ENV === 'production'
|
||||
}));
|
||||
|
||||
// CORS allowlist from comma-separated env
|
||||
const parseOrigins = (val) => (val || '').split(',').map(s => s.trim()).filter(Boolean);
|
||||
const allowedOrigins = parseOrigins(process.env.CORS_ORIGINS || process.env.CORS_ORIGIN || 'http://localhost:3000');
|
||||
app.use(cors({
|
||||
origin: function(origin, callback) {
|
||||
// Allow non-browser/SSR tools with no origin
|
||||
if (!origin) return callback(null, true);
|
||||
if (allowedOrigins.includes(origin)) return callback(null, true);
|
||||
return callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true
|
||||
}));
|
||||
app.use(limiter);
|
||||
// Reduce body size limits to reasonable defaults
|
||||
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '5mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: process.env.JSON_BODY_LIMIT || '5mb' }));
|
||||
|
||||
// Request logging - only if logging is enabled
|
||||
if (process.env.ENABLE_LOGGING === 'true') {
|
||||
app.use((req, res, next) => {
|
||||
logger.info(`${req.method} ${req.path} - ${req.ip}`);
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// API routes
|
||||
const apiVersion = process.env.API_VERSION || 'v1';
|
||||
|
||||
// Per-route rate limits
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: parseInt(process.env.AUTH_RATE_LIMIT_WINDOW_MS) || 10 * 60 * 1000,
|
||||
max: parseInt(process.env.AUTH_RATE_LIMIT_MAX) || 20
|
||||
});
|
||||
const agentLimiter = rateLimit({
|
||||
windowMs: parseInt(process.env.AGENT_RATE_LIMIT_WINDOW_MS) || 60 * 1000,
|
||||
max: parseInt(process.env.AGENT_RATE_LIMIT_MAX) || 120
|
||||
});
|
||||
|
||||
app.use(`/api/${apiVersion}/auth`, authLimiter, authRoutes);
|
||||
app.use(`/api/${apiVersion}/hosts`, agentLimiter, hostRoutes);
|
||||
app.use(`/api/${apiVersion}/host-groups`, hostGroupRoutes);
|
||||
app.use(`/api/${apiVersion}/packages`, packageRoutes);
|
||||
app.use(`/api/${apiVersion}/dashboard`, dashboardRoutes);
|
||||
app.use(`/api/${apiVersion}/permissions`, permissionsRoutes);
|
||||
app.use(`/api/${apiVersion}/settings`, settingsRoutes);
|
||||
app.use(`/api/${apiVersion}/dashboard-preferences`, dashboardPreferencesRoutes);
|
||||
app.use(`/api/${apiVersion}/repositories`, repositoryRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
if (process.env.ENABLE_LOGGING === 'true') {
|
||||
logger.error(err.stack);
|
||||
}
|
||||
res.status(500).json({
|
||||
error: 'Something went wrong!',
|
||||
message: process.env.NODE_ENV === 'development' ? err.message : undefined
|
||||
});
|
||||
});
|
||||
|
||||
// 404 handler
|
||||
app.use('*', (req, res) => {
|
||||
res.status(404).json({ error: 'Route not found' });
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
if (process.env.ENABLE_LOGGING === 'true') {
|
||||
logger.info('SIGTERM received, shutting down gracefully');
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', async () => {
|
||||
if (process.env.ENABLE_LOGGING === 'true') {
|
||||
logger.info('SIGINT received, shutting down gracefully');
|
||||
}
|
||||
await prisma.$disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
if (process.env.ENABLE_LOGGING === 'true') {
|
||||
logger.info(`Server running on port ${PORT}`);
|
||||
logger.info(`Environment: ${process.env.NODE_ENV}`);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = app;
|
16
frontend/index.html
Normal file
16
frontend/index.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>PatchMon - Linux Patch Monitoring Dashboard</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@300;400;500;600&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
43
frontend/package.json
Normal file
43
frontend/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "patchmon-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tanstack/react-query": "^5.87.4",
|
||||
"axios": "^1.6.2",
|
||||
"chart.js": "^4.4.0",
|
||||
"clsx": "^2.0.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"lucide-react": "^0.294.0",
|
||||
"react": "^18.2.0",
|
||||
"react-chartjs-2": "^5.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.53.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.4",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"vite": "^7.1.5"
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "^0.24.4"
|
||||
}
|
||||
}
|
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
117
frontend/src/App.jsx
Normal file
117
frontend/src/App.jsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import React from 'react'
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import Layout from './components/Layout'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Hosts from './pages/Hosts'
|
||||
import HostGroups from './pages/HostGroups'
|
||||
import Packages from './pages/Packages'
|
||||
import Repositories from './pages/Repositories'
|
||||
import RepositoryDetail from './pages/RepositoryDetail'
|
||||
import Users from './pages/Users'
|
||||
import Permissions from './pages/Permissions'
|
||||
import Settings from './pages/Settings'
|
||||
import Profile from './pages/Profile'
|
||||
import HostDetail from './pages/HostDetail'
|
||||
import PackageDetail from './pages/PackageDetail'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute requirePermission="canViewDashboard">
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/hosts" element={
|
||||
<ProtectedRoute requirePermission="canViewHosts">
|
||||
<Layout>
|
||||
<Hosts />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/hosts/:hostId" element={
|
||||
<ProtectedRoute requirePermission="canViewHosts">
|
||||
<Layout>
|
||||
<HostDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/host-groups" element={
|
||||
<ProtectedRoute requirePermission="canManageHosts">
|
||||
<Layout>
|
||||
<HostGroups />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/packages" element={
|
||||
<ProtectedRoute requirePermission="canViewPackages">
|
||||
<Layout>
|
||||
<Packages />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/repositories" element={
|
||||
<ProtectedRoute requirePermission="canViewHosts">
|
||||
<Layout>
|
||||
<Repositories />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/repositories/:repositoryId" element={
|
||||
<ProtectedRoute requirePermission="canViewHosts">
|
||||
<Layout>
|
||||
<RepositoryDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/users" element={
|
||||
<ProtectedRoute requirePermission="canViewUsers">
|
||||
<Layout>
|
||||
<Users />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/permissions" element={
|
||||
<ProtectedRoute requirePermission="canManageSettings">
|
||||
<Layout>
|
||||
<Permissions />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/settings" element={
|
||||
<ProtectedRoute requirePermission="canManageSettings">
|
||||
<Layout>
|
||||
<Settings />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/profile" element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Profile />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/packages/:packageId" element={
|
||||
<ProtectedRoute requirePermission="canViewPackages">
|
||||
<Layout>
|
||||
<PackageDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
306
frontend/src/components/DashboardSettingsModal.jsx
Normal file
306
frontend/src/components/DashboardSettingsModal.jsx
Normal file
@@ -0,0 +1,306 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import {
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import {
|
||||
X,
|
||||
GripVertical,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Save,
|
||||
RotateCcw,
|
||||
Settings as SettingsIcon
|
||||
} from 'lucide-react';
|
||||
import { dashboardPreferencesAPI } from '../utils/api';
|
||||
|
||||
// Sortable Card Item Component
|
||||
const SortableCardItem = ({ card, onToggle }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: card.cardId });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-center justify-between p-3 bg-white border border-secondary-200 rounded-lg ${
|
||||
isDragging ? 'shadow-lg' : 'shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="text-secondary-400 hover:text-secondary-600 cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-medium text-secondary-900">
|
||||
{card.title}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onToggle(card.cardId)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-xs font-medium transition-colors ${
|
||||
card.enabled
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-secondary-100 text-secondary-600 hover:bg-secondary-200'
|
||||
}`}
|
||||
>
|
||||
{card.enabled ? (
|
||||
<>
|
||||
<Eye className="h-3 w-3" />
|
||||
Visible
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="h-3 w-3" />
|
||||
Hidden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DashboardSettingsModal = ({ isOpen, onClose }) => {
|
||||
const [cards, setCards] = useState([]);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
// Fetch user's dashboard preferences
|
||||
const { data: preferences, isLoading } = useQuery({
|
||||
queryKey: ['dashboardPreferences'],
|
||||
queryFn: () => dashboardPreferencesAPI.get().then(res => res.data),
|
||||
enabled: isOpen
|
||||
});
|
||||
|
||||
// Fetch default card configuration
|
||||
const { data: defaultCards } = useQuery({
|
||||
queryKey: ['dashboardDefaultCards'],
|
||||
queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data),
|
||||
enabled: isOpen
|
||||
});
|
||||
|
||||
// Update preferences mutation
|
||||
const updatePreferencesMutation = useMutation({
|
||||
mutationFn: (preferences) => dashboardPreferencesAPI.update(preferences),
|
||||
onSuccess: (response) => {
|
||||
// Optimistically update the query cache with the correct data structure
|
||||
queryClient.setQueryData(['dashboardPreferences'], response.data.preferences);
|
||||
// Also invalidate to ensure fresh data
|
||||
queryClient.invalidateQueries(['dashboardPreferences']);
|
||||
setHasChanges(false);
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to update dashboard preferences:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize cards when preferences or defaults are loaded
|
||||
useEffect(() => {
|
||||
if (preferences && defaultCards) {
|
||||
// Merge user preferences with default cards
|
||||
const mergedCards = defaultCards.map(defaultCard => {
|
||||
const userPreference = preferences.find(p => p.cardId === defaultCard.cardId);
|
||||
return {
|
||||
...defaultCard,
|
||||
enabled: userPreference ? userPreference.enabled : defaultCard.enabled,
|
||||
order: userPreference ? userPreference.order : defaultCard.order
|
||||
};
|
||||
}).sort((a, b) => a.order - b.order);
|
||||
|
||||
setCards(mergedCards);
|
||||
}
|
||||
}, [preferences, defaultCards]);
|
||||
|
||||
const handleDragEnd = (event) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (active.id !== over.id) {
|
||||
setCards((items) => {
|
||||
const oldIndex = items.findIndex(item => item.cardId === active.id);
|
||||
const newIndex = items.findIndex(item => item.cardId === over.id);
|
||||
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
|
||||
// Update order values
|
||||
return newItems.map((item, index) => ({
|
||||
...item,
|
||||
order: index
|
||||
}));
|
||||
});
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = (cardId) => {
|
||||
setCards(prevCards =>
|
||||
prevCards.map(card =>
|
||||
card.cardId === cardId
|
||||
? { ...card, enabled: !card.enabled }
|
||||
: card
|
||||
)
|
||||
);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const preferences = cards.map(card => ({
|
||||
cardId: card.cardId,
|
||||
enabled: card.enabled,
|
||||
order: card.order
|
||||
}));
|
||||
|
||||
updatePreferencesMutation.mutate(preferences);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (defaultCards) {
|
||||
const resetCards = defaultCards.map(card => ({
|
||||
...card,
|
||||
enabled: true,
|
||||
order: card.order
|
||||
}));
|
||||
setCards(resetCards);
|
||||
setHasChanges(true);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-secondary-500 bg-opacity-75 transition-opacity" onClick={onClose} />
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<SettingsIcon className="h-5 w-5 text-primary-600" />
|
||||
<h3 className="text-lg font-medium text-secondary-900">
|
||||
Dashboard Settings
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-secondary-400 hover:text-secondary-600"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-secondary-600 mb-6">
|
||||
Customize your dashboard by reordering cards and toggling their visibility.
|
||||
Drag cards to reorder them, and click the visibility toggle to show/hide cards.
|
||||
</p>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={cards.map(card => card.cardId)} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{cards.map((card) => (
|
||||
<SortableCardItem
|
||||
key={card.cardId}
|
||||
card={card}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || updatePreferencesMutation.isPending}
|
||||
className={`w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 text-base font-medium text-white sm:ml-3 sm:w-auto sm:text-sm ${
|
||||
!hasChanges || updatePreferencesMutation.isPending
|
||||
? 'bg-secondary-400 cursor-not-allowed'
|
||||
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
|
||||
}`}
|
||||
>
|
||||
{updatePreferencesMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-secondary-700 hover:bg-secondary-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Reset to Defaults
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-secondary-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-secondary-700 hover:bg-secondary-50 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardSettingsModal;
|
439
frontend/src/components/Layout.jsx
Normal file
439
frontend/src/components/Layout.jsx
Normal file
@@ -0,0 +1,439 @@
|
||||
import React from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import {
|
||||
Home,
|
||||
Server,
|
||||
Package,
|
||||
Shield,
|
||||
BarChart3,
|
||||
Menu,
|
||||
X,
|
||||
LogOut,
|
||||
User,
|
||||
Users,
|
||||
Settings,
|
||||
UserCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
Wrench
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { dashboardAPI, formatRelativeTime } from '../utils/api'
|
||||
|
||||
const Layout = ({ children }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
||||
// Load sidebar state from localStorage, default to false
|
||||
const saved = localStorage.getItem('sidebarCollapsed')
|
||||
return saved ? JSON.parse(saved) : false
|
||||
})
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false)
|
||||
const location = useLocation()
|
||||
const { user, logout, canViewHosts, canManageHosts, canViewPackages, canViewUsers, canManageUsers, canManageSettings } = useAuth()
|
||||
const userMenuRef = useRef(null)
|
||||
|
||||
// Fetch dashboard stats for the "Last updated" info
|
||||
const { data: stats, refetch } = useQuery({
|
||||
queryKey: ['dashboardStats'],
|
||||
queryFn: () => dashboardAPI.getStats().then(res => res.data),
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
staleTime: 30000, // Consider data stale after 30 seconds
|
||||
})
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: Home },
|
||||
{
|
||||
section: 'Inventory',
|
||||
items: [
|
||||
...(canViewHosts() ? [{ name: 'Hosts', href: '/hosts', icon: Server }] : []),
|
||||
...(canManageHosts() ? [{ name: 'Host Groups', href: '/host-groups', icon: Users }] : []),
|
||||
...(canViewPackages() ? [{ name: 'Packages', href: '/packages', icon: Package }] : []),
|
||||
...(canViewHosts() ? [{ name: 'Repos', href: '/repositories', icon: GitBranch }] : []),
|
||||
{ name: 'Services', href: '/services', icon: Wrench, comingSoon: true },
|
||||
{ name: 'Reporting', href: '/reporting', icon: BarChart3, comingSoon: true },
|
||||
]
|
||||
},
|
||||
{
|
||||
section: 'Users',
|
||||
items: [
|
||||
...(canViewUsers() ? [{ name: 'Users', href: '/users', icon: Users }] : []),
|
||||
...(canManageSettings() ? [{ name: 'Permissions', href: '/permissions', icon: Shield }] : []),
|
||||
]
|
||||
},
|
||||
{
|
||||
section: 'Settings',
|
||||
items: [
|
||||
...(canManageSettings() ? [{ name: 'Settings', href: '/settings', icon: Settings }] : []),
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const isActive = (path) => location.pathname === path
|
||||
|
||||
// Get page title based on current route
|
||||
const getPageTitle = () => {
|
||||
const path = location.pathname
|
||||
|
||||
if (path === '/') return 'Dashboard'
|
||||
if (path === '/hosts') return 'Hosts'
|
||||
if (path === '/host-groups') return 'Host Groups'
|
||||
if (path === '/packages') return 'Packages'
|
||||
if (path === '/repositories' || path.startsWith('/repositories/')) return 'Repositories'
|
||||
if (path === '/services') return 'Services'
|
||||
if (path === '/users') return 'Users'
|
||||
if (path === '/permissions') return 'Permissions'
|
||||
if (path === '/settings') return 'Settings'
|
||||
if (path === '/profile') return 'My Profile'
|
||||
if (path.startsWith('/hosts/')) return 'Host Details'
|
||||
if (path.startsWith('/packages/')) return 'Package Details'
|
||||
|
||||
return 'PatchMon'
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout()
|
||||
setUserMenuOpen(false)
|
||||
}
|
||||
|
||||
// Save sidebar collapsed state to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed))
|
||||
}, [sidebarCollapsed])
|
||||
|
||||
// Close user menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
if (userMenuRef.current && !userMenuRef.current.contains(event.target)) {
|
||||
setUserMenuOpen(false)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-secondary-50">
|
||||
{/* Mobile sidebar */}
|
||||
<div className={`fixed inset-0 z-50 lg:hidden ${sidebarOpen ? 'block' : 'hidden'}`}>
|
||||
<div className="fixed inset-0 bg-secondary-600 bg-opacity-75" onClick={() => setSidebarOpen(false)} />
|
||||
<div className="relative flex w-full max-w-xs flex-col bg-white pb-4 pt-5 shadow-xl">
|
||||
<div className="absolute right-0 top-0 -mr-12 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 flex h-10 w-10 items-center justify-center rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<X className="h-6 w-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center px-4">
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-8 w-8 text-primary-600" />
|
||||
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">PatchMon</h1>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="mt-8 flex-1 space-y-6 px-2">
|
||||
{navigation.map((item, index) => {
|
||||
if (item.name) {
|
||||
// Single item (Dashboard)
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
|
||||
isActive(item.href)
|
||||
? 'bg-primary-100 text-primary-900'
|
||||
: 'text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900'
|
||||
}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<item.icon className="mr-3 h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
)
|
||||
} else if (item.section) {
|
||||
// Section with items
|
||||
return (
|
||||
<div key={item.section}>
|
||||
<h3 className="text-xs font-semibold text-secondary-500 uppercase tracking-wider mb-2 px-2">
|
||||
{item.section}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{item.items.map((subItem) => (
|
||||
<Link
|
||||
key={subItem.name}
|
||||
to={subItem.href}
|
||||
className={`group flex items-center px-2 py-2 text-sm font-medium rounded-md ${
|
||||
isActive(subItem.href)
|
||||
? 'bg-primary-100 text-primary-900'
|
||||
: 'text-secondary-600 hover:bg-secondary-50 hover:text-secondary-900'
|
||||
} ${subItem.comingSoon ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
onClick={subItem.comingSoon ? (e) => e.preventDefault() : () => setSidebarOpen(false)}
|
||||
>
|
||||
<subItem.icon className="mr-3 h-5 w-5" />
|
||||
<span className="flex items-center gap-2">
|
||||
{subItem.name}
|
||||
{subItem.comingSoon && (
|
||||
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className={`hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:flex-col transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'lg:w-16' : 'lg:w-64'
|
||||
} bg-white dark:bg-secondary-800`}>
|
||||
<div className={`flex grow flex-col gap-y-5 overflow-y-auto border-r border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 ${
|
||||
sidebarCollapsed ? 'px-2 shadow-lg' : 'px-6'
|
||||
}`}>
|
||||
<div className={`flex h-16 shrink-0 items-center border-b border-secondary-200 ${
|
||||
sidebarCollapsed ? 'justify-center' : 'justify-between'
|
||||
}`}>
|
||||
{sidebarCollapsed ? (
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
|
||||
title="Expand sidebar"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-8 w-8 text-primary-600" />
|
||||
<h1 className="ml-2 text-xl font-bold text-secondary-900 dark:text-white">PatchMon</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="flex items-center justify-center w-8 h-8 rounded-md hover:bg-secondary-100 transition-colors"
|
||||
title="Collapse sidebar"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<nav className="flex flex-1 flex-col">
|
||||
<ul className="flex flex-1 flex-col gap-y-6">
|
||||
{navigation.map((item, index) => {
|
||||
if (item.name) {
|
||||
// Single item (Dashboard)
|
||||
return (
|
||||
<li key={item.name}>
|
||||
<Link
|
||||
to={item.href}
|
||||
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-semibold transition-all duration-200 ${
|
||||
isActive(item.href)
|
||||
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
|
||||
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
} ${sidebarCollapsed ? 'justify-center p-2' : 'p-2'}`}
|
||||
title={sidebarCollapsed ? item.name : ''}
|
||||
>
|
||||
<item.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
|
||||
{!sidebarCollapsed && (
|
||||
<span className="truncate">{item.name}</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
} else if (item.section) {
|
||||
// Section with items
|
||||
return (
|
||||
<li key={item.section}>
|
||||
{!sidebarCollapsed && (
|
||||
<h3 className="text-xs font-semibold text-secondary-500 dark:text-secondary-300 uppercase tracking-wider mb-2 px-2">
|
||||
{item.section}
|
||||
</h3>
|
||||
)}
|
||||
<ul className={`space-y-1 ${sidebarCollapsed ? '' : '-mx-2'}`}>
|
||||
{item.items.map((subItem) => (
|
||||
<li key={subItem.name}>
|
||||
<Link
|
||||
to={subItem.href}
|
||||
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 ${
|
||||
isActive(subItem.href)
|
||||
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
|
||||
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
} ${sidebarCollapsed ? 'justify-center p-2' : 'p-2'} ${
|
||||
subItem.comingSoon ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
title={sidebarCollapsed ? subItem.name : ''}
|
||||
onClick={subItem.comingSoon ? (e) => e.preventDefault() : undefined}
|
||||
>
|
||||
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
|
||||
{!sidebarCollapsed && (
|
||||
<span className="truncate flex items-center gap-2">
|
||||
{subItem.name}
|
||||
{subItem.comingSoon && (
|
||||
<span className="text-xs bg-secondary-100 text-secondary-600 px-1.5 py-0.5 rounded">
|
||||
Soon
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* Profile Section - Bottom of Sidebar */}
|
||||
<div className="border-t border-secondary-200">
|
||||
{!sidebarCollapsed ? (
|
||||
<div className="space-y-1">
|
||||
{/* My Profile Link */}
|
||||
<Link
|
||||
to="/profile"
|
||||
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 p-2 ${
|
||||
isActive('/profile')
|
||||
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
|
||||
: 'text-secondary-700 dark:text-secondary-200 hover:text-primary-700 dark:hover:text-primary-300 hover:bg-secondary-50 dark:hover:bg-secondary-500'
|
||||
}`}
|
||||
>
|
||||
<UserCircle className="h-5 w-5 shrink-0" />
|
||||
<span className="truncate">My Profile</span>
|
||||
</Link>
|
||||
|
||||
{/* User Info with Sign Out */}
|
||||
<div className="flex items-center justify-between p-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-sm font-medium text-secondary-900 dark:text-white truncate">
|
||||
{user?.username}
|
||||
</p>
|
||||
{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">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 truncate">
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="ml-2 p-1.5 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded transition-colors"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
to="/profile"
|
||||
className="flex items-center justify-center p-2 text-secondary-700 hover:bg-secondary-50 rounded-md transition-colors"
|
||||
title="My Profile"
|
||||
>
|
||||
<UserCircle className="h-5 w-5 text-white" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center justify-center w-full p-2 text-secondary-700 hover:bg-secondary-50 rounded-md transition-colors"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="h-5 w-5 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className={`transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'lg:pl-16' : 'lg:pl-64'
|
||||
}`}>
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-secondary-200 dark:border-secondary-600 bg-white dark:bg-secondary-800 px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
|
||||
<button
|
||||
type="button"
|
||||
className="-m-2.5 p-2.5 text-secondary-700 lg:hidden"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="h-6 w-px bg-secondary-200 lg:hidden" />
|
||||
|
||||
<div className="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
|
||||
<div className="relative flex flex-1 items-center">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-secondary-100">
|
||||
{getPageTitle()}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4 lg:gap-x-6">
|
||||
|
||||
{/* Last updated info */}
|
||||
{stats && (
|
||||
<div className="flex items-center gap-x-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>Last updated: {formatRelativeTime(stats.lastUpdated)}</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-800 rounded"
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Customize Dashboard
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="py-6 bg-secondary-50 dark:bg-secondary-800 min-h-screen">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Layout
|
47
frontend/src/components/ProtectedRoute.jsx
Normal file
47
frontend/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const ProtectedRoute = ({ children, requireAdmin = false, requirePermission = null }) => {
|
||||
const { isAuthenticated, isAdmin, isLoading, hasPermission } = useAuth()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!isAuthenticated()) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
// Check admin requirement
|
||||
if (requireAdmin && !isAdmin()) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
|
||||
<p className="text-secondary-600">You don't have permission to access this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Check specific permission requirement
|
||||
if (requirePermission && !hasPermission(requirePermission)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-secondary-900 mb-2">Access Denied</h2>
|
||||
<p className="text-secondary-600">You don't have permission to access this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
|
||||
export default ProtectedRoute
|
246
frontend/src/contexts/AuthContext.jsx
Normal file
246
frontend/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react'
|
||||
|
||||
const AuthContext = createContext()
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null)
|
||||
const [token, setToken] = useState(null)
|
||||
const [permissions, setPermissions] = useState(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
// Initialize auth state from localStorage
|
||||
useEffect(() => {
|
||||
const storedToken = localStorage.getItem('token')
|
||||
const storedUser = localStorage.getItem('user')
|
||||
const storedPermissions = localStorage.getItem('permissions')
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
try {
|
||||
setToken(storedToken)
|
||||
setUser(JSON.parse(storedUser))
|
||||
if (storedPermissions) {
|
||||
setPermissions(JSON.parse(storedPermissions))
|
||||
} else {
|
||||
// Fetch permissions if not stored
|
||||
fetchPermissions(storedToken)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing stored user data:', error)
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('permissions')
|
||||
}
|
||||
}
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
// Periodically refresh permissions when user is logged in
|
||||
useEffect(() => {
|
||||
if (token && user) {
|
||||
// Refresh permissions every 30 seconds
|
||||
const interval = setInterval(() => {
|
||||
refreshPermissions()
|
||||
}, 30000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [token, user])
|
||||
|
||||
const fetchPermissions = async (authToken) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/permissions/user-permissions', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setPermissions(data)
|
||||
localStorage.setItem('permissions', JSON.stringify(data))
|
||||
return data
|
||||
} else {
|
||||
console.error('Failed to fetch permissions')
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching permissions:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const refreshPermissions = async () => {
|
||||
if (token) {
|
||||
const updatedPermissions = await fetchPermissions(token)
|
||||
return updatedPermissions
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
setToken(data.token)
|
||||
setUser(data.user)
|
||||
localStorage.setItem('token', data.token)
|
||||
localStorage.setItem('user', JSON.stringify(data.user))
|
||||
|
||||
// Fetch user permissions after successful login
|
||||
const userPermissions = await fetchPermissions(data.token)
|
||||
if (userPermissions) {
|
||||
setPermissions(userPermissions)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} else {
|
||||
return { success: false, error: data.error || 'Login failed' }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: 'Network error occurred' }
|
||||
}
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
if (token) {
|
||||
await fetch('/api/v1/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error)
|
||||
} finally {
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
setPermissions(null)
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
localStorage.removeItem('permissions')
|
||||
}
|
||||
}
|
||||
|
||||
const updateProfile = async (profileData) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/profile', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(profileData),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
setUser(data.user)
|
||||
localStorage.setItem('user', JSON.stringify(data.user))
|
||||
return { success: true, user: data.user }
|
||||
} else {
|
||||
return { success: false, error: data.error || 'Update failed' }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: 'Network error occurred' }
|
||||
}
|
||||
}
|
||||
|
||||
const changePassword = async (currentPassword, newPassword) => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/change-password', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ currentPassword, newPassword }),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true }
|
||||
} else {
|
||||
return { success: false, error: data.error || 'Password change failed' }
|
||||
}
|
||||
} catch (error) {
|
||||
return { success: false, error: 'Network error occurred' }
|
||||
}
|
||||
}
|
||||
|
||||
const isAuthenticated = () => {
|
||||
return !!(token && user)
|
||||
}
|
||||
|
||||
const isAdmin = () => {
|
||||
return user?.role === 'admin'
|
||||
}
|
||||
|
||||
// Permission checking functions
|
||||
const hasPermission = (permission) => {
|
||||
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 value = {
|
||||
user,
|
||||
token,
|
||||
permissions,
|
||||
isLoading,
|
||||
login,
|
||||
logout,
|
||||
updateProfile,
|
||||
changePassword,
|
||||
refreshPermissions,
|
||||
isAuthenticated,
|
||||
isAdmin,
|
||||
hasPermission,
|
||||
canViewDashboard,
|
||||
canViewHosts,
|
||||
canManageHosts,
|
||||
canViewPackages,
|
||||
canManagePackages,
|
||||
canViewUsers,
|
||||
canManageUsers,
|
||||
canViewReports,
|
||||
canExportData,
|
||||
canManageSettings
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
54
frontend/src/contexts/ThemeContext.jsx
Normal file
54
frontend/src/contexts/ThemeContext.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||
|
||||
const ThemeContext = createContext()
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext)
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export const ThemeProvider = ({ children }) => {
|
||||
const [theme, setTheme] = useState(() => {
|
||||
// Check localStorage first, then system preference
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
if (savedTheme) {
|
||||
return savedTheme
|
||||
}
|
||||
// Check system preference
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark'
|
||||
}
|
||||
return 'light'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
// Apply theme to document
|
||||
if (theme === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
localStorage.setItem('theme', theme)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
toggleTheme,
|
||||
isDark: theme === 'dark'
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
)
|
||||
}
|
127
frontend/src/index.css
Normal file
127
frontend/src/index.css
Normal file
@@ -0,0 +1,127 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: Inter, ui-sans-serif, system-ui;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-secondary-50 dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-150;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply btn bg-secondary-600 text-white hover:bg-secondary-700 focus:ring-secondary-500;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply btn bg-success-600 text-white hover:bg-success-700 focus:ring-success-500;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
@apply btn bg-warning-600 text-white hover:bg-warning-700 focus:ring-warning-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply btn bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@apply btn border-secondary-300 dark:border-secondary-600 text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-800 hover:bg-secondary-50 dark:hover:bg-secondary-700 focus:ring-secondary-500;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white dark:bg-secondary-800 rounded-lg shadow-card dark:shadow-card-dark border border-secondary-200 dark:border-secondary-600;
|
||||
}
|
||||
|
||||
.card-hover {
|
||||
@apply card hover:shadow-card-hover transition-shadow duration-150;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply block w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm bg-white dark:bg-secondary-800 text-secondary-900 dark:text-secondary-100;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-secondary-700 dark:text-secondary-200;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
@apply badge bg-primary-100 text-primary-800;
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
@apply badge bg-secondary-100 text-secondary-800;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply badge bg-success-100 text-success-800;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply badge bg-warning-100 text-warning-800;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
@apply badge bg-danger-100 text-danger-800;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-shadow {
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 #f1f5f9;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin {
|
||||
scrollbar-color: #64748b #475569;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #cbd5e1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: #64748b;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #94a3b8;
|
||||
}
|
||||
|
||||
.dark .scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #94a3b8;
|
||||
}
|
||||
}
|
27
frontend/src/main.jsx
Normal file
27
frontend/src/main.jsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
// Create a client for React Query
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
)
|
460
frontend/src/pages/Dashboard.jsx
Normal file
460
frontend/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,460 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Server,
|
||||
Package,
|
||||
AlertTriangle,
|
||||
Shield,
|
||||
TrendingUp,
|
||||
RefreshCw,
|
||||
Clock
|
||||
} 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, formatRelativeTime } from '../utils/api'
|
||||
import DashboardSettingsModal from '../components/DashboardSettingsModal'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
|
||||
// Register Chart.js components
|
||||
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title)
|
||||
|
||||
const Dashboard = () => {
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
||||
const [cardPreferences, setCardPreferences] = useState([])
|
||||
const navigate = useNavigate()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
// Navigation handlers
|
||||
const handleTotalHostsClick = () => {
|
||||
navigate('/hosts')
|
||||
}
|
||||
|
||||
const handleHostsNeedingUpdatesClick = () => {
|
||||
navigate('/hosts?filter=needsUpdates')
|
||||
}
|
||||
|
||||
const handleOutdatedPackagesClick = () => {
|
||||
navigate('/packages?filter=outdated')
|
||||
}
|
||||
|
||||
const handleSecurityUpdatesClick = () => {
|
||||
navigate('/packages?filter=security')
|
||||
}
|
||||
|
||||
const { data: stats, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['dashboardStats'],
|
||||
queryFn: () => dashboardAPI.getStats().then(res => res.data),
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
staleTime: 30000, // Consider data stale after 30 seconds
|
||||
})
|
||||
|
||||
// Fetch user's dashboard preferences
|
||||
const { data: preferences, refetch: refetchPreferences } = useQuery({
|
||||
queryKey: ['dashboardPreferences'],
|
||||
queryFn: () => dashboardPreferencesAPI.get().then(res => res.data),
|
||||
staleTime: 5 * 60 * 1000, // Consider data fresh for 5 minutes
|
||||
})
|
||||
|
||||
// Fetch default card configuration
|
||||
const { data: defaultCards } = useQuery({
|
||||
queryKey: ['dashboardDefaultCards'],
|
||||
queryFn: () => dashboardPreferencesAPI.getDefaults().then(res => res.data),
|
||||
})
|
||||
|
||||
// Merge preferences with default cards
|
||||
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);
|
||||
} else if (defaultCards) {
|
||||
// If no preferences exist, use defaults
|
||||
setCardPreferences(defaultCards.sort((a, b) => a.order - b.order));
|
||||
}
|
||||
}, [preferences, defaultCards])
|
||||
|
||||
// Listen for custom event from Layout component
|
||||
useEffect(() => {
|
||||
const handleOpenSettings = () => {
|
||||
setShowSettingsModal(true);
|
||||
};
|
||||
|
||||
window.addEventListener('openDashboardSettings', handleOpenSettings);
|
||||
return () => {
|
||||
window.removeEventListener('openDashboardSettings', handleOpenSettings);
|
||||
};
|
||||
}, [])
|
||||
|
||||
// Helper function to check if a card should be displayed
|
||||
const isCardEnabled = (cardId) => {
|
||||
const card = cardPreferences.find(c => c.cardId === cardId);
|
||||
return card ? card.enabled : true; // Default to enabled if not found
|
||||
}
|
||||
|
||||
// Helper function to get card type for layout grouping
|
||||
const getCardType = (cardId) => {
|
||||
if (['totalHosts', 'hostsNeedingUpdates', 'totalOutdatedPackages', 'securityUpdates'].includes(cardId)) {
|
||||
return 'stats';
|
||||
} else if (['osDistribution', 'updateStatus', 'packagePriority'].includes(cardId)) {
|
||||
return 'charts';
|
||||
} else if (['erroredHosts', 'quickStats'].includes(cardId)) {
|
||||
return 'fullwidth';
|
||||
}
|
||||
return 'fullwidth'; // Default to full width
|
||||
}
|
||||
|
||||
// Helper function to get CSS class for card group
|
||||
const getGroupClassName = (cardType) => {
|
||||
switch (cardType) {
|
||||
case 'stats':
|
||||
return 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4';
|
||||
case 'charts':
|
||||
return 'grid grid-cols-1 lg:grid-cols-3 gap-6';
|
||||
case 'fullwidth':
|
||||
return 'space-y-6';
|
||||
default:
|
||||
return 'space-y-6';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to render a card by ID
|
||||
const renderCard = (cardId) => {
|
||||
switch (cardId) {
|
||||
case 'totalHosts':
|
||||
return (
|
||||
<div
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleTotalHostsClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Server 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">Total Hosts</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{stats.cards.totalHosts}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'hostsNeedingUpdates':
|
||||
return (
|
||||
<div
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleHostsNeedingUpdatesClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle 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">Needs Updating</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{stats.cards.hostsNeedingUpdates}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'totalOutdatedPackages':
|
||||
return (
|
||||
<div
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleOutdatedPackagesClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Package className="h-5 w-5 text-secondary-600 mr-2" />
|
||||
</div>
|
||||
<div className="w-0 flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Outdated Packages</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{stats.cards.totalOutdatedPackages}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'securityUpdates':
|
||||
return (
|
||||
<div
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleSecurityUpdatesClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Shield className="h-5 w-5 text-danger-600 mr-2" />
|
||||
</div>
|
||||
<div className="w-0 flex-1">
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Security Updates</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{stats.cards.securityUpdates}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'erroredHosts':
|
||||
return (
|
||||
<div className={`border rounded-lg p-4 ${
|
||||
stats.cards.erroredHosts > 0
|
||||
? 'bg-danger-50 border-danger-200'
|
||||
: 'bg-success-50 border-success-200'
|
||||
}`}>
|
||||
<div className="flex">
|
||||
<AlertTriangle className={`h-5 w-5 ${
|
||||
stats.cards.erroredHosts > 0 ? 'text-danger-400' : 'text-success-400'
|
||||
}`} />
|
||||
<div className="ml-3">
|
||||
{stats.cards.erroredHosts > 0 ? (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-danger-800">
|
||||
{stats.cards.erroredHosts} host{stats.cards.erroredHosts > 1 ? 's' : ''} haven't reported in 24+ hours
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
These hosts may be offline or experiencing connectivity issues.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="text-sm font-medium text-success-800">
|
||||
All hosts are reporting normally
|
||||
</h3>
|
||||
<p className="text-sm text-success-700 mt-1">
|
||||
No hosts have failed to report in the last 24 hours.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'osDistribution':
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">OS Distribution</h3>
|
||||
<div className="h-64">
|
||||
<Pie data={osChartData} options={chartOptions} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'updateStatus':
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'packagePriority':
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'quickStats':
|
||||
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" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-primary-600">
|
||||
{((stats.cards.hostsNeedingUpdates / stats.cards.totalHosts) * 100).toFixed(1)}%
|
||||
</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>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-success-600">
|
||||
{stats.cards.totalHosts - stats.cards.erroredHosts}
|
||||
</div>
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">Hosts online</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">Error loading dashboard</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
{error.message || 'Failed to load dashboard statistics'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-2 btn-danger text-xs"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
color: isDark ? '#ffffff' : '#374151',
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const osChartData = {
|
||||
labels: stats.charts.osDistribution.map(item => item.name),
|
||||
datasets: [
|
||||
{
|
||||
data: stats.charts.osDistribution.map(item => item.count),
|
||||
backgroundColor: [
|
||||
'#3B82F6', // Blue
|
||||
'#10B981', // Green
|
||||
'#F59E0B', // Yellow
|
||||
'#EF4444', // Red
|
||||
'#8B5CF6', // Purple
|
||||
'#06B6D4', // Cyan
|
||||
],
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const updateStatusChartData = {
|
||||
labels: stats.charts.updateStatusDistribution.map(item => item.name),
|
||||
datasets: [
|
||||
{
|
||||
data: stats.charts.updateStatusDistribution.map(item => item.count),
|
||||
backgroundColor: [
|
||||
'#10B981', // Green - Up to date
|
||||
'#F59E0B', // Yellow - Needs updates
|
||||
'#EF4444', // Red - Errored
|
||||
],
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const packagePriorityChartData = {
|
||||
labels: stats.charts.packageUpdateDistribution.map(item => item.name),
|
||||
datasets: [
|
||||
{
|
||||
data: stats.charts.packageUpdateDistribution.map(item => item.count),
|
||||
backgroundColor: [
|
||||
'#EF4444', // Red - Security
|
||||
'#3B82F6', // Blue - Regular
|
||||
],
|
||||
borderWidth: 2,
|
||||
borderColor: '#ffffff',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Dynamically Rendered Cards - Unified Order */}
|
||||
{(() => {
|
||||
const enabledCards = cardPreferences
|
||||
.filter(card => isCardEnabled(card.cardId))
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
// Group consecutive cards of the same type for proper layout
|
||||
const cardGroups = [];
|
||||
let currentGroup = null;
|
||||
|
||||
enabledCards.forEach(card => {
|
||||
const cardType = getCardType(card.cardId);
|
||||
|
||||
if (!currentGroup || currentGroup.type !== cardType) {
|
||||
// Start a new group
|
||||
currentGroup = {
|
||||
type: cardType,
|
||||
cards: [card]
|
||||
};
|
||||
cardGroups.push(currentGroup);
|
||||
} else {
|
||||
// Add to existing group
|
||||
currentGroup.cards.push(card);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{cardGroups.map((group, groupIndex) => (
|
||||
<div key={groupIndex} className={getGroupClassName(group.type)}>
|
||||
{group.cards.map(card => (
|
||||
<div key={card.cardId}>
|
||||
{renderCard(card.cardId)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Dashboard Settings Modal */}
|
||||
<DashboardSettingsModal
|
||||
isOpen={showSettingsModal}
|
||||
onClose={() => setShowSettingsModal(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
732
frontend/src/pages/HostDetail.jsx
Normal file
732
frontend/src/pages/HostDetail.jsx
Normal file
@@ -0,0 +1,732 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Server,
|
||||
ArrowLeft,
|
||||
Package,
|
||||
Shield,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Calendar,
|
||||
Monitor,
|
||||
HardDrive,
|
||||
Key,
|
||||
Trash2,
|
||||
X,
|
||||
Copy,
|
||||
Eye,
|
||||
Code,
|
||||
EyeOff,
|
||||
ToggleLeft,
|
||||
ToggleRight
|
||||
} from 'lucide-react'
|
||||
import { dashboardAPI, adminHostsAPI, settingsAPI, formatRelativeTime, formatDate } from '../utils/api'
|
||||
|
||||
const HostDetail = () => {
|
||||
const { hostId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const [showCredentialsModal, setShowCredentialsModal] = useState(false)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
|
||||
const { data: host, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['host', hostId],
|
||||
queryFn: () => dashboardAPI.getHostDetail(hostId).then(res => res.data),
|
||||
refetchInterval: 60000,
|
||||
staleTime: 30000,
|
||||
})
|
||||
|
||||
const deleteHostMutation = useMutation({
|
||||
mutationFn: (hostId) => adminHostsAPI.delete(hostId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hosts'])
|
||||
navigate('/hosts')
|
||||
},
|
||||
})
|
||||
|
||||
// Toggle auto-update mutation
|
||||
const toggleAutoUpdateMutation = useMutation({
|
||||
mutationFn: (autoUpdate) => adminHostsAPI.toggleAutoUpdate(hostId, autoUpdate).then(res => res.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['host', hostId])
|
||||
queryClient.invalidateQueries(['hosts'])
|
||||
}
|
||||
})
|
||||
|
||||
const handleDeleteHost = async () => {
|
||||
if (window.confirm(`Are you sure you want to delete host "${host.hostname}"? This action cannot be undone.`)) {
|
||||
try {
|
||||
await deleteHostMutation.mutateAsync(hostId)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete host:', error)
|
||||
alert('Failed to delete host')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/hosts" className="text-secondary-500 hover:text-secondary-700">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">Error loading host</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
{error.message || 'Failed to load host details'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-2 btn-danger text-xs"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/hosts" className="text-secondary-500 hover:text-secondary-700">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-8 text-center">
|
||||
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">Host Not Found</h3>
|
||||
<p className="text-secondary-600 dark:text-secondary-300">
|
||||
The requested host could not be found.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusColor = (isStale, needsUpdate) => {
|
||||
if (isStale) return 'text-danger-600'
|
||||
if (needsUpdate) return 'text-warning-600'
|
||||
return 'text-success-600'
|
||||
}
|
||||
|
||||
const getStatusIcon = (isStale, needsUpdate) => {
|
||||
if (isStale) return <AlertTriangle className="h-5 w-5" />
|
||||
if (needsUpdate) return <Clock className="h-5 w-5" />
|
||||
return <CheckCircle className="h-5 w-5" />
|
||||
}
|
||||
|
||||
const getStatusText = (isStale, needsUpdate) => {
|
||||
if (isStale) return 'Stale'
|
||||
if (needsUpdate) return 'Needs Updates'
|
||||
return 'Up to Date'
|
||||
}
|
||||
|
||||
const isStale = new Date() - new Date(host.lastUpdate) > 24 * 60 * 60 * 1000
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<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-2xl font-bold text-secondary-900 dark:text-white">{host.hostname}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setShowCredentialsModal(true)}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
>
|
||||
<Key className="h-4 w-4" />
|
||||
View Credentials
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
className="btn-danger flex items-center gap-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete Host
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Host Information */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Basic Info */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Host Information</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-secondary-400" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">Hostname</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white">{host.hostname}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-5 w-5 text-secondary-400" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">Host Group</p>
|
||||
{host.hostGroup ? (
|
||||
<span
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium text-white"
|
||||
style={{ backgroundColor: host.hostGroup.color }}
|
||||
>
|
||||
{host.hostGroup.name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-secondary-100 dark:bg-secondary-700 text-secondary-800 dark:text-secondary-200">
|
||||
Ungrouped
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Monitor className="h-5 w-5 text-secondary-400" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">Operating System</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white">{host.osType} {host.osVersion}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{host.ip && (
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="h-5 w-5 text-secondary-400" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">IP Address</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white">{host.ip}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{host.architecture && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-5 w-5 text-secondary-400" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">Architecture</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white">{host.architecture}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Calendar className="h-5 w-5 text-secondary-400" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">Last Update</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white">{formatRelativeTime(host.lastUpdate)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{host.agentVersion && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Code className="h-5 w-5 text-secondary-400" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">Agent Version</p>
|
||||
<p className="font-medium text-secondary-900 dark:text-white">{host.agentVersion}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-Update Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-secondary-500 dark:text-secondary-300">Auto-update</span>
|
||||
<button
|
||||
onClick={() => toggleAutoUpdateMutation.mutate(!host.autoUpdate)}
|
||||
disabled={toggleAutoUpdateMutation.isPending}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
|
||||
host.autoUpdate
|
||||
? 'bg-primary-600 dark:bg-primary-500'
|
||||
: 'bg-secondary-200 dark:bg-secondary-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
host.autoUpdate ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="card p-6">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Statistics</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-primary-100 rounded-lg mx-auto mb-2">
|
||||
<Package className="h-6 w-6 text-primary-600" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.totalPackages}</p>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">Total Packages</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-warning-100 rounded-lg mx-auto mb-2">
|
||||
<Clock className="h-6 w-6 text-warning-600" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.outdatedPackages}</p>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">Outdated</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center w-12 h-12 bg-danger-100 rounded-lg mx-auto mb-2">
|
||||
<Shield className="h-6 w-6 text-danger-600" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{host.stats.securityUpdates}</p>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">Security Updates</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div className="mt-6 pt-4 border-t border-secondary-200 dark:border-secondary-600">
|
||||
<div className={`flex items-center gap-2 ${getStatusColor(isStale, host.stats.outdatedPackages > 0)}`}>
|
||||
{getStatusIcon(isStale, host.stats.outdatedPackages > 0)}
|
||||
<span className="font-medium">{getStatusText(isStale, host.stats.outdatedPackages > 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Packages */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Packages</h3>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Package
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Current Version
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Available Version
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{host.hostPackages?.map((hostPackage) => (
|
||||
<tr key={hostPackage.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Package className="h-4 w-4 text-secondary-400 mr-3" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{hostPackage.package.name}
|
||||
</div>
|
||||
{hostPackage.package.description && (
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{hostPackage.package.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{hostPackage.currentVersion}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{hostPackage.availableVersion || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{hostPackage.needsUpdate ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`badge ${hostPackage.isSecurityUpdate ? 'badge-danger' : 'badge-warning'}`}>
|
||||
{hostPackage.isSecurityUpdate ? 'Security Update' : 'Update Available'}
|
||||
</span>
|
||||
{hostPackage.isSecurityUpdate && (
|
||||
<Shield className="h-4 w-4 text-danger-600" />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="badge-success">Up to date</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{host.hostPackages?.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">No packages found</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Update History */}
|
||||
<div className="card">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Update History</h3>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{host.updateHistory?.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{host.updateHistory.map((update, index) => (
|
||||
<div key={update.id} className="flex items-center justify-between py-3 border-b border-secondary-100 dark:border-secondary-700 last:border-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2 h-2 rounded-full ${update.status === 'success' ? 'bg-success-500' : 'bg-danger-500'}`} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{update.status === 'success' ? 'Update Successful' : 'Update Failed'}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
{formatDate(update.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm text-secondary-900 dark:text-white">
|
||||
{update.packagesCount} packages
|
||||
</p>
|
||||
{update.securityCount > 0 && (
|
||||
<p className="text-xs text-danger-600">
|
||||
{update.securityCount} security updates
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Calendar className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">No update history available</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Credentials Modal */}
|
||||
{showCredentialsModal && (
|
||||
<CredentialsModal
|
||||
host={host}
|
||||
isOpen={showCredentialsModal}
|
||||
onClose={() => setShowCredentialsModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && (
|
||||
<DeleteConfirmationModal
|
||||
host={host}
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={handleDeleteHost}
|
||||
isLoading={deleteHostMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Credentials Modal Component
|
||||
const CredentialsModal = ({ host, isOpen, onClose }) => {
|
||||
const [showApiKey, setShowApiKey] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState('credentials')
|
||||
|
||||
const { data: serverUrlData } = useQuery({
|
||||
queryKey: ['serverUrl'],
|
||||
queryFn: () => settingsAPI.getServerUrl().then(res => res.data),
|
||||
})
|
||||
|
||||
const serverUrl = serverUrlData?.serverUrl || 'http://localhost:3001'
|
||||
|
||||
const copyToClipboard = (text) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
const getSetupCommands = () => {
|
||||
return `# Run this on the target host: ${host?.hostname}
|
||||
|
||||
echo "🔄 Setting up PatchMon agent..."
|
||||
|
||||
# Download and install agent
|
||||
echo "📥 Downloading agent script..."
|
||||
curl -o /tmp/patchmon-agent.sh ${serverUrl}/api/v1/hosts/agent/download
|
||||
sudo mkdir -p /etc/patchmon
|
||||
sudo mv /tmp/patchmon-agent.sh /usr/local/bin/patchmon-agent.sh
|
||||
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}"
|
||||
|
||||
# Test configuration
|
||||
echo "🧪 Testing configuration..."
|
||||
sudo /usr/local/bin/patchmon-agent.sh test
|
||||
|
||||
# Send initial update
|
||||
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 -
|
||||
|
||||
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 " - View logs: tail -f /var/log/patchmon-agent.log"`
|
||||
}
|
||||
|
||||
if (!isOpen || !host) return null
|
||||
|
||||
const commands = getSetupCommands()
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-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.hostname}</h3>
|
||||
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-secondary-200 dark:border-secondary-600 mb-6">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('credentials')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'credentials'
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500'
|
||||
}`}
|
||||
>
|
||||
API Credentials
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('quick-install')}
|
||||
className={`py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'quick-install'
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500'
|
||||
}`}
|
||||
>
|
||||
Quick Install
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'credentials' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">API Credentials</h4>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">API ID</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={host.apiId}
|
||||
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)}
|
||||
className="btn-outline flex items-center gap-1"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">API Key</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={host.apiKey}
|
||||
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={() => setShowApiKey(!showApiKey)}
|
||||
className="btn-outline flex items-center gap-1"
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copyToClipboard(host.apiKey)}
|
||||
className="btn-outline flex items-center gap-1"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-warning-50 dark:bg-warning-900 border border-warning-200 dark:border-warning-700 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-warning-400 dark:text-warning-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-warning-800 dark:text-warning-200">Security Notice</h3>
|
||||
<p className="text-sm text-warning-700 dark:text-warning-300 mt-1">
|
||||
Keep these credentials secure. They provide full access to this host's monitoring data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'quick-install' && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-primary-50 dark:bg-primary-900 border border-primary-200 dark:border-primary-700 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-primary-900 dark:text-primary-200 mb-2">One-Line Installation</h4>
|
||||
<p className="text-sm text-primary-700 dark:text-primary-300 mb-3">
|
||||
Copy and run this command on the target host to automatically install and configure the PatchMon agent:
|
||||
</p>
|
||||
<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}"`}
|
||||
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}"`)}
|
||||
className="btn-primary flex items-center gap-1"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-3">Manual Installation</h4>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-3">
|
||||
If you prefer manual installation, run these commands on the target host:
|
||||
</p>
|
||||
<pre className="bg-secondary-900 dark:bg-secondary-800 text-secondary-100 dark:text-secondary-200 p-4 rounded-md text-sm overflow-x-auto">
|
||||
<code>{commands}</code>
|
||||
</pre>
|
||||
<button
|
||||
onClick={() => copyToClipboard(commands)}
|
||||
className="mt-3 btn-outline flex items-center gap-1"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy Commands
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end pt-6">
|
||||
<button onClick={onClose} className="btn-primary">
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Delete Confirmation Modal Component
|
||||
const DeleteConfirmationModal = ({ host, isOpen, onClose, onConfirm, isLoading }) => {
|
||||
if (!isOpen || !host) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-danger-100 dark:bg-danger-900 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-600 dark:text-danger-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Delete Host
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
||||
This action cannot be undone
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-secondary-700 dark:text-secondary-300">
|
||||
Are you sure you want to delete the host{' '}
|
||||
<span className="font-semibold">"{host.hostname}"</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">
|
||||
<strong>Warning:</strong> This will permanently remove the host and all its associated data,
|
||||
including package information and update history.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="btn-danger"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Deleting...' : 'Delete Host'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HostDetail
|
||||
|
||||
|
498
frontend/src/pages/HostGroups.jsx
Normal file
498
frontend/src/pages/HostGroups.jsx
Normal file
@@ -0,0 +1,498 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Server,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
CheckCircle
|
||||
} from 'lucide-react'
|
||||
import { hostGroupsAPI } from '../utils/api'
|
||||
|
||||
const HostGroups = () => {
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [selectedGroup, setSelectedGroup] = useState(null)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [groupToDelete, setGroupToDelete] = useState(null)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Fetch host groups
|
||||
const { data: hostGroups, isLoading, error } = useQuery({
|
||||
queryKey: ['hostGroups'],
|
||||
queryFn: () => hostGroupsAPI.list().then(res => res.data),
|
||||
})
|
||||
|
||||
// Create host group mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data) => hostGroupsAPI.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowCreateModal(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create host group:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// Update host group mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => hostGroupsAPI.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowEditModal(false)
|
||||
setSelectedGroup(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to update host group:', error)
|
||||
}
|
||||
})
|
||||
|
||||
// Delete host group mutation
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id) => hostGroupsAPI.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['hostGroups'])
|
||||
setShowDeleteModal(false)
|
||||
setGroupToDelete(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to delete host group:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const handleCreate = (data) => {
|
||||
createMutation.mutate(data)
|
||||
}
|
||||
|
||||
const handleEdit = (group) => {
|
||||
setSelectedGroup(group)
|
||||
setShowEditModal(true)
|
||||
}
|
||||
|
||||
const handleUpdate = (data) => {
|
||||
updateMutation.mutate({ id: selectedGroup.id, data })
|
||||
}
|
||||
|
||||
const handleDeleteClick = (group) => {
|
||||
setGroupToDelete(group)
|
||||
setShowDeleteModal(true)
|
||||
}
|
||||
|
||||
const handleDeleteConfirm = () => {
|
||||
deleteMutation.mutate(groupToDelete.id)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">
|
||||
Error loading host groups
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
{error.message || 'Failed to load host groups'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-secondary-600 dark:text-secondary-300">
|
||||
Organize your hosts into logical groups for better management
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Host Groups Grid */}
|
||||
{hostGroups && hostGroups.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{hostGroups.map((group) => (
|
||||
<div key={group.id} className="bg-white dark:bg-secondary-800 border border-secondary-200 dark:border-secondary-600 rounded-lg p-6 hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: group.color }}
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
{group.name}
|
||||
</h3>
|
||||
{group.description && (
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mt-1">
|
||||
{group.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(group)}
|
||||
className="p-1 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded"
|
||||
title="Edit group"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteClick(group)}
|
||||
className="p-1 text-secondary-400 hover:text-danger-600 hover:bg-danger-50 rounded"
|
||||
title="Delete group"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4 text-sm text-secondary-600 dark:text-secondary-300">
|
||||
<div className="flex items-center gap-1">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Server className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-2">
|
||||
No host groups yet
|
||||
</h3>
|
||||
<p className="text-secondary-600 dark:text-secondary-300 mb-6">
|
||||
Create your first host group to organize your hosts
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn-primary flex items-center gap-2 mx-auto"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Create Group
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateHostGroupModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={handleCreate}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{showEditModal && selectedGroup && (
|
||||
<EditHostGroupModal
|
||||
group={selectedGroup}
|
||||
onClose={() => {
|
||||
setShowEditModal(false)
|
||||
setSelectedGroup(null)
|
||||
}}
|
||||
onSubmit={handleUpdate}
|
||||
isLoading={updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteModal && groupToDelete && (
|
||||
<DeleteHostGroupModal
|
||||
group={groupToDelete}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false)
|
||||
setGroupToDelete(null)
|
||||
}}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Create Host Group Modal
|
||||
const CreateHostGroupModal = ({ onClose, onSubmit, isLoading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
color: '#3B82F6'
|
||||
})
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(formData)
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
Create Host Group
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="e.g., Production Servers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="Optional description for this group"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
name="color"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="#3B82F6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Group'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Edit Host Group Modal
|
||||
const EditHostGroupModal = ({ group, onClose, onSubmit, isLoading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: group.name,
|
||||
description: group.description || '',
|
||||
color: group.color || '#3B82F6'
|
||||
})
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
onSubmit(formData)
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white mb-4">
|
||||
Edit Host Group
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="e.g., Production Servers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
placeholder="Optional description for this group"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Color
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
name="color"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="w-12 h-10 border border-secondary-300 rounded cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.color}
|
||||
onChange={handleChange}
|
||||
className="flex-1 px-3 py-2 border border-secondary-300 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
placeholder="#3B82F6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn-primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update Group'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Delete Confirmation Modal
|
||||
const DeleteHostGroupModal = ({ group, onClose, onConfirm, isLoading }) => {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-danger-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Delete Host Group
|
||||
</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
||||
This action cannot be undone
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<p className="text-secondary-700 dark:text-secondary-200">
|
||||
Are you sure you want to delete the host group{' '}
|
||||
<span className="font-semibold">"{group.name}"</span>?
|
||||
</p>
|
||||
{group._count.hosts > 0 && (
|
||||
<div className="mt-3 p-3 bg-warning-50 border border-warning-200 rounded-md">
|
||||
<p className="text-sm text-warning-800">
|
||||
<strong>Warning:</strong> This group contains {group._count.hosts} host{group._count.hosts !== 1 ? 's' : ''}.
|
||||
You must move or remove these hosts before deleting the group.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="btn-danger"
|
||||
disabled={isLoading || group._count.hosts > 0}
|
||||
>
|
||||
{isLoading ? 'Deleting...' : 'Delete Group'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HostGroups
|
1598
frontend/src/pages/Hosts.jsx
Normal file
1598
frontend/src/pages/Hosts.jsx
Normal file
File diff suppressed because it is too large
Load Diff
158
frontend/src/pages/Login.jsx
Normal file
158
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Lock, User, AlertCircle } from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const Login = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
})
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const navigate = useNavigate()
|
||||
const { login } = useAuth()
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const result = await login(formData.username, formData.password)
|
||||
|
||||
if (result.success) {
|
||||
// Redirect to dashboard
|
||||
navigate('/')
|
||||
} else {
|
||||
setError(result.error || 'Login failed')
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Network error occurred')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-secondary-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-primary-100">
|
||||
<Lock className="h-6 w-6 text-primary-600" />
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-secondary-900">
|
||||
Sign in to PatchMon
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-secondary-600">
|
||||
Monitor and manage your Linux package updates
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-secondary-700">
|
||||
Username or Email
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="h-5 w-5 text-secondary-400" />
|
||||
</div>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-3 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your username or email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-secondary-700">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock className="h-5 w-5 text-secondary-400" />
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className="appearance-none rounded-md relative block w-full pl-10 pr-10 py-2 border border-secondary-300 placeholder-secondary-500 text-secondary-900 focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="text-secondary-400 hover:text-secondary-500"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-5 w-5" />
|
||||
) : (
|
||||
<Eye className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Signing in...
|
||||
</div>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-secondary-600">
|
||||
Need help? Contact your system administrator.
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
25
frontend/src/pages/PackageDetail.jsx
Normal file
25
frontend/src/pages/PackageDetail.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Package } from 'lucide-react'
|
||||
|
||||
const PackageDetail = () => {
|
||||
const { packageId } = useParams()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
<div className="card p-8 text-center">
|
||||
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 mb-2">Package Details</h3>
|
||||
<p className="text-secondary-600">
|
||||
Detailed view for package: {packageId}
|
||||
</p>
|
||||
<p className="text-secondary-600 mt-2">
|
||||
This page will show package information, affected hosts, version distribution, and more.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PackageDetail
|
294
frontend/src/pages/Packages.jsx
Normal file
294
frontend/src/pages/Packages.jsx
Normal file
@@ -0,0 +1,294 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Package,
|
||||
Server,
|
||||
Shield,
|
||||
RefreshCw,
|
||||
Search,
|
||||
AlertTriangle,
|
||||
Filter,
|
||||
ExternalLink
|
||||
} from 'lucide-react'
|
||||
import { dashboardAPI } from '../utils/api'
|
||||
|
||||
const Packages = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [categoryFilter, setCategoryFilter] = useState('all')
|
||||
const [securityFilter, setSecurityFilter] = useState('all')
|
||||
const [searchParams] = useSearchParams()
|
||||
|
||||
// Handle URL filter parameters
|
||||
useEffect(() => {
|
||||
const filter = searchParams.get('filter')
|
||||
if (filter === 'outdated') {
|
||||
// For outdated packages, we want to show all packages that need updates
|
||||
// This is the default behavior, so we don't need to change filters
|
||||
setCategoryFilter('all')
|
||||
setSecurityFilter('all')
|
||||
} else if (filter === 'security') {
|
||||
// For security updates, filter to show only security updates
|
||||
setSecurityFilter('security')
|
||||
setCategoryFilter('all')
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
const { data: packages, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['packages'],
|
||||
queryFn: () => dashboardAPI.getPackages().then(res => res.data),
|
||||
refetchInterval: 60000,
|
||||
staleTime: 30000,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-primary-600" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">Error loading packages</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
{error.message || 'Failed to load packages'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-2 btn-danger text-xs"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Filter packages based on search and filters
|
||||
const filteredPackages = packages?.filter(pkg => {
|
||||
const matchesSearch = pkg.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(pkg.description && pkg.description.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
|
||||
const matchesCategory = categoryFilter === 'all' || pkg.category === categoryFilter
|
||||
|
||||
const matchesSecurity = securityFilter === 'all' ||
|
||||
(securityFilter === 'security' && pkg.isSecurityUpdate) ||
|
||||
(securityFilter === 'regular' && !pkg.isSecurityUpdate)
|
||||
|
||||
return matchesSearch && matchesCategory && matchesSecurity
|
||||
}) || []
|
||||
|
||||
// Get unique categories
|
||||
const categories = [...new Set(packages?.map(pkg => pkg.category).filter(Boolean))] || []
|
||||
|
||||
// Calculate unique affected hosts
|
||||
const uniqueAffectedHosts = new Set()
|
||||
packages?.forEach(pkg => {
|
||||
pkg.affectedHosts.forEach(host => {
|
||||
uniqueAffectedHosts.add(host.hostId)
|
||||
})
|
||||
})
|
||||
const uniqueAffectedHostsCount = uniqueAffectedHosts.size
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4">
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<Package className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Total Packages</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{packages?.length || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-5 w-5 text-danger-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Security Updates</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{packages?.filter(pkg => pkg.isSecurityUpdate).length || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<Server className="h-5 w-5 text-warning-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Affected Hosts</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{uniqueAffectedHostsCount}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4">
|
||||
<div className="flex items-center">
|
||||
<Filter className="h-5 w-5 text-secondary-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Categories</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">{categories.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="card p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search packages..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white placeholder-secondary-500 dark:placeholder-secondary-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Filter */}
|
||||
<div className="sm:w-48">
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
|
||||
>
|
||||
<option value="all">All Categories</option>
|
||||
{categories.map(category => (
|
||||
<option key={category} value={category}>{category}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Security Filter */}
|
||||
<div className="sm:w-48">
|
||||
<select
|
||||
value={securityFilter}
|
||||
onChange={(e) => setSecurityFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-secondary-800 text-secondary-900 dark:text-white"
|
||||
>
|
||||
<option value="all">All Updates</option>
|
||||
<option value="security">Security Only</option>
|
||||
<option value="regular">Regular Only</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Packages List */}
|
||||
<div className="card">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Packages Needing Updates ({filteredPackages.length})
|
||||
</h3>
|
||||
|
||||
{filteredPackages.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Package className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">
|
||||
{packages?.length === 0 ? 'No packages need updates' : 'No packages match your filters'}
|
||||
</p>
|
||||
{packages?.length === 0 && (
|
||||
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
||||
All packages are up to date across all hosts
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
<thead className="bg-secondary-50 dark:bg-secondary-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Package
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Latest Version
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Affected Hosts
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-secondary-500 dark:text-secondary-300 uppercase tracking-wider">
|
||||
Priority
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{filteredPackages.map((pkg) => (
|
||||
<tr key={pkg.id} className="hover:bg-secondary-50 dark:hover:bg-secondary-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<Package className="h-5 w-5 text-secondary-400 mr-3" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{pkg.name}
|
||||
</div>
|
||||
{pkg.description && (
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-300 max-w-md truncate">
|
||||
{pkg.description}
|
||||
</div>
|
||||
)}
|
||||
{pkg.category && (
|
||||
<div className="text-xs text-secondary-400 dark:text-secondary-400">
|
||||
Category: {pkg.category}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-secondary-900 dark:text-white">
|
||||
{pkg.latestVersion || 'Unknown'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-secondary-900 dark:text-white">
|
||||
{pkg.affectedHostsCount} host{pkg.affectedHostsCount !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-300">
|
||||
{pkg.affectedHosts.slice(0, 2).map(host => host.hostname).join(', ')}
|
||||
{pkg.affectedHosts.length > 2 && ` +${pkg.affectedHosts.length - 2} more`}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{pkg.isSecurityUpdate ? (
|
||||
<span className="badge-danger flex items-center gap-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
Security Update
|
||||
</span>
|
||||
) : (
|
||||
<span className="badge-warning">Regular Update</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Packages
|
389
frontend/src/pages/Permissions.jsx
Normal file
389
frontend/src/pages/Permissions.jsx
Normal file
@@ -0,0 +1,389 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Shield,
|
||||
Settings,
|
||||
Users,
|
||||
Server,
|
||||
Package,
|
||||
BarChart3,
|
||||
Download,
|
||||
Eye,
|
||||
Edit,
|
||||
Trash2,
|
||||
Plus,
|
||||
Save,
|
||||
X,
|
||||
AlertTriangle,
|
||||
RefreshCw
|
||||
} from 'lucide-react'
|
||||
import { permissionsAPI } from '../utils/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const Permissions = () => {
|
||||
const [editingRole, setEditingRole] = useState(null)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { refreshPermissions } = useAuth()
|
||||
|
||||
// Fetch all role permissions
|
||||
const { data: roles, isLoading, error } = useQuery({
|
||||
queryKey: ['rolePermissions'],
|
||||
queryFn: () => permissionsAPI.getRoles().then(res => res.data)
|
||||
})
|
||||
|
||||
// Update role permissions mutation
|
||||
const updateRoleMutation = useMutation({
|
||||
mutationFn: ({ role, permissions }) => permissionsAPI.updateRole(role, permissions),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['rolePermissions'])
|
||||
setEditingRole(null)
|
||||
// Refresh user permissions to apply changes immediately
|
||||
refreshPermissions()
|
||||
}
|
||||
})
|
||||
|
||||
// Delete role mutation
|
||||
const deleteRoleMutation = useMutation({
|
||||
mutationFn: (role) => permissionsAPI.deleteRole(role),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['rolePermissions'])
|
||||
}
|
||||
})
|
||||
|
||||
const handleSavePermissions = async (role, permissions) => {
|
||||
try {
|
||||
await updateRoleMutation.mutateAsync({ role, permissions })
|
||||
} catch (error) {
|
||||
console.error('Failed to update permissions:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteRole = async (role) => {
|
||||
if (window.confirm(`Are you sure you want to delete the "${role}" role? This action cannot be undone.`)) {
|
||||
try {
|
||||
await deleteRoleMutation.mutateAsync(role)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete role:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">Error loading permissions</h3>
|
||||
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-end items-center">
|
||||
<div className="flex space-x-3">
|
||||
<button
|
||||
onClick={() => refreshPermissions()}
|
||||
className="inline-flex items-center px-4 py-2 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh Permissions
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Role
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Roles List */}
|
||||
<div className="space-y-4">
|
||||
{roles && Array.isArray(roles) && roles.map((role) => (
|
||||
<RolePermissionsCard
|
||||
key={role.id}
|
||||
role={role}
|
||||
isEditing={editingRole === role.role}
|
||||
onEdit={() => setEditingRole(role.role)}
|
||||
onCancel={() => setEditingRole(null)}
|
||||
onSave={handleSavePermissions}
|
||||
onDelete={handleDeleteRole}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Role Modal */}
|
||||
<AddRoleModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries(['rolePermissions'])
|
||||
setShowAddModal(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Role Permissions Card Component
|
||||
const RolePermissionsCard = ({ role, isEditing, onEdit, onCancel, onSave, onDelete }) => {
|
||||
const [permissions, setPermissions] = useState(role)
|
||||
|
||||
const permissionFields = [
|
||||
{ key: 'canViewDashboard', label: 'View Dashboard', icon: BarChart3, description: 'Access to the main dashboard' },
|
||||
{ key: 'canViewHosts', label: 'View Hosts', icon: Server, description: 'See host information and status' },
|
||||
{ key: 'canManageHosts', label: 'Manage Hosts', icon: Edit, description: 'Add, edit, and delete hosts' },
|
||||
{ key: 'canViewPackages', label: 'View Packages', icon: Package, description: 'See package information' },
|
||||
{ key: 'canManagePackages', label: 'Manage Packages', icon: Settings, description: 'Edit package details' },
|
||||
{ key: 'canViewUsers', label: 'View Users', icon: Users, description: 'See user list and details' },
|
||||
{ key: 'canManageUsers', label: 'Manage Users', icon: Shield, description: 'Add, edit, and delete users' },
|
||||
{ key: 'canViewReports', label: 'View Reports', icon: BarChart3, description: 'Access to reports and analytics' },
|
||||
{ key: 'canExportData', label: 'Export Data', icon: Download, description: 'Download data and reports' },
|
||||
{ key: 'canManageSettings', label: 'Manage Settings', icon: Settings, description: 'System configuration access' }
|
||||
]
|
||||
|
||||
const handlePermissionChange = (key, value) => {
|
||||
setPermissions(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(role.role, permissions)
|
||||
}
|
||||
|
||||
const isAdminRole = role.role === 'admin'
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<Shield className="h-5 w-5 text-primary-600 mr-3" />
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white capitalize">{role.role}</h3>
|
||||
{isAdminRole && (
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800">
|
||||
System Role
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="inline-flex items-center px-3 py-1 border border-secondary-300 text-sm font-medium rounded-md text-secondary-700 bg-white hover:bg-secondary-50"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
disabled={isAdminRole}
|
||||
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</button>
|
||||
{!isAdminRole && (
|
||||
<button
|
||||
onClick={() => onDelete(role.role)}
|
||||
className="inline-flex items-center px-3 py-1 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{permissionFields.map((field) => {
|
||||
const Icon = field.icon
|
||||
const isChecked = permissions[field.key]
|
||||
|
||||
return (
|
||||
<div key={field.key} className="flex items-start">
|
||||
<div className="flex items-center h-5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={(e) => handlePermissionChange(field.key, e.target.checked)}
|
||||
disabled={!isEditing || (isAdminRole && field.key === 'canManageUsers')}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center">
|
||||
<Icon className="h-4 w-4 text-secondary-400 mr-2" />
|
||||
<label className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{field.label}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500 mt-1">
|
||||
{field.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Add Role Modal Component
|
||||
const AddRoleModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
role: '',
|
||||
canViewDashboard: true,
|
||||
canViewHosts: true,
|
||||
canManageHosts: false,
|
||||
canViewPackages: true,
|
||||
canManagePackages: false,
|
||||
canViewUsers: false,
|
||||
canManageUsers: false,
|
||||
canViewReports: true,
|
||||
canExportData: false,
|
||||
canManageSettings: false
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await permissionsAPI.updateRole(formData.role, formData)
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to create role')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value, type, checked } = e.target
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
})
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<h3 className="text-lg font-medium text-secondary-900 mb-4">Add New Role</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
Role Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="role"
|
||||
required
|
||||
value={formData.role}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full border-secondary-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="e.g., host_manager, readonly"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-secondary-500">Use lowercase with underscores (e.g., host_manager)</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white">Permissions</h4>
|
||||
{[
|
||||
{ key: 'canViewDashboard', label: 'View Dashboard' },
|
||||
{ key: 'canViewHosts', label: 'View Hosts' },
|
||||
{ key: 'canManageHosts', label: 'Manage Hosts' },
|
||||
{ key: 'canViewPackages', label: 'View Packages' },
|
||||
{ key: 'canManagePackages', label: 'Manage Packages' },
|
||||
{ key: 'canViewUsers', label: 'View Users' },
|
||||
{ key: 'canManageUsers', label: 'Manage Users' },
|
||||
{ key: 'canViewReports', label: 'View Reports' },
|
||||
{ key: 'canExportData', label: 'Export Data' },
|
||||
{ key: 'canManageSettings', label: 'Manage Settings' }
|
||||
].map((permission) => (
|
||||
<div key={permission.key} className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name={permission.key}
|
||||
checked={formData[permission.key]}
|
||||
onChange={handleInputChange}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-secondary-700">
|
||||
{permission.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-secondary-700 bg-white border border-secondary-300 rounded-md hover:bg-secondary-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Role'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Permissions
|
414
frontend/src/pages/Profile.jsx
Normal file
414
frontend/src/pages/Profile.jsx
Normal file
@@ -0,0 +1,414 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
import {
|
||||
User,
|
||||
Mail,
|
||||
Shield,
|
||||
Key,
|
||||
Save,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Sun,
|
||||
Moon,
|
||||
Settings
|
||||
} from 'lucide-react'
|
||||
|
||||
const Profile = () => {
|
||||
const { user, updateProfile, changePassword } = useAuth()
|
||||
const { theme, toggleTheme, isDark } = useTheme()
|
||||
const [activeTab, setActiveTab] = useState('profile')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [message, setMessage] = useState({ type: '', text: '' })
|
||||
|
||||
const [profileData, setProfileData] = useState({
|
||||
username: user?.username || '',
|
||||
email: user?.email || ''
|
||||
})
|
||||
|
||||
const [passwordData, setPasswordData] = useState({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
|
||||
const [showPasswords, setShowPasswords] = useState({
|
||||
current: false,
|
||||
new: false,
|
||||
confirm: false
|
||||
})
|
||||
|
||||
const handleProfileSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setMessage({ type: '', text: '' })
|
||||
|
||||
try {
|
||||
const result = await updateProfile(profileData)
|
||||
if (result.success) {
|
||||
setMessage({ type: 'success', text: 'Profile updated successfully!' })
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error || 'Failed to update profile' })
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: 'Network error occurred' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePasswordSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setMessage({ type: '', text: '' })
|
||||
|
||||
if (passwordData.newPassword !== passwordData.confirmPassword) {
|
||||
setMessage({ type: 'error', text: 'New passwords do not match' })
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordData.newPassword.length < 6) {
|
||||
setMessage({ type: 'error', text: 'New password must be at least 6 characters' })
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await changePassword(passwordData.currentPassword, passwordData.newPassword)
|
||||
if (result.success) {
|
||||
setMessage({ type: 'success', text: 'Password changed successfully!' })
|
||||
setPasswordData({
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
} else {
|
||||
setMessage({ type: 'error', text: result.error || 'Failed to change password' })
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: 'Network error occurred' })
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target
|
||||
if (activeTab === 'profile') {
|
||||
setProfileData(prev => ({ ...prev, [name]: value }))
|
||||
} else {
|
||||
setPasswordData(prev => ({ ...prev, [name]: value }))
|
||||
}
|
||||
}
|
||||
|
||||
const togglePasswordVisibility = (field) => {
|
||||
setShowPasswords(prev => ({ ...prev, [field]: !prev[field] }))
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: 'profile', name: 'Profile Information', icon: User },
|
||||
{ id: 'password', name: 'Change Password', icon: Key },
|
||||
{ id: 'preferences', name: 'Preferences', icon: Settings }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
||||
Manage your account information and security settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* User Info Card */}
|
||||
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg p-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-16 w-16 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User className="h-8 w-8 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">{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 ${
|
||||
user?.role === 'admin'
|
||||
? 'bg-primary-100 text-primary-800'
|
||||
: user?.role === 'host_manager'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: user?.role === 'readonly'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-secondary-100 text-secondary-800'
|
||||
}`}>
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
{user?.role?.charAt(0).toUpperCase() + user?.role?.slice(1).replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
|
||||
<div className="border-b border-secondary-200 dark:border-secondary-600">
|
||||
<nav className="-mb-px flex space-x-8 px-6">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4 mr-2" />
|
||||
{tab.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Success/Error Message */}
|
||||
{message.text && (
|
||||
<div className={`mb-6 rounded-md p-4 ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700'
|
||||
: 'bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700'
|
||||
}`}>
|
||||
<div className="flex">
|
||||
{message.type === 'success' ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
)}
|
||||
<div className="ml-3">
|
||||
<p className={`text-sm font-medium ${
|
||||
message.type === 'success' ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'
|
||||
}`}>
|
||||
{message.text}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile Information Tab */}
|
||||
{activeTab === 'profile' && (
|
||||
<form onSubmit={handleProfileSubmit} className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Profile Information</h3>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||
Username
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
id="username"
|
||||
value={profileData.username}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 pl-10 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
<User 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="email" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||
Email Address
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
value={profileData.email}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 pl-10 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isLoading ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Change Password Tab */}
|
||||
{activeTab === 'password' && (
|
||||
<form onSubmit={handlePasswordSubmit} className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Change Password</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="currentPassword" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||
Current Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
type={showPasswords.current ? 'text' : 'password'}
|
||||
name="currentPassword"
|
||||
id="currentPassword"
|
||||
value={passwordData.currentPassword}
|
||||
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 pl-10 pr-10 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
required
|
||||
/>
|
||||
<Key className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePasswordVisibility('current')}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-secondary-400 dark:text-secondary-500 hover:text-secondary-600 dark:hover:text-secondary-300"
|
||||
>
|
||||
{showPasswords.current ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="newPassword" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||
New Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
type={showPasswords.new ? 'text' : 'password'}
|
||||
name="newPassword"
|
||||
id="newPassword"
|
||||
value={passwordData.newPassword}
|
||||
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 pl-10 pr-10 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
required
|
||||
minLength="6"
|
||||
/>
|
||||
<Key className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePasswordVisibility('new')}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-secondary-400 dark:text-secondary-500 hover:text-secondary-600 dark:hover:text-secondary-300"
|
||||
>
|
||||
{showPasswords.new ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">Must be at least 6 characters long</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-secondary-700 dark:text-secondary-200">
|
||||
Confirm New Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
type={showPasswords.confirm ? 'text' : 'password'}
|
||||
name="confirmPassword"
|
||||
id="confirmPassword"
|
||||
value={passwordData.confirmPassword}
|
||||
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 pl-10 pr-10 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
required
|
||||
minLength="6"
|
||||
/>
|
||||
<Key className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => togglePasswordVisibility('confirm')}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-secondary-400 dark:text-secondary-500 hover:text-secondary-600 dark:hover:text-secondary-300"
|
||||
>
|
||||
{showPasswords.confirm ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50"
|
||||
>
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
{isLoading ? 'Changing...' : 'Change Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Preferences Tab */}
|
||||
{activeTab === 'preferences' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Preferences</h3>
|
||||
|
||||
{/* Theme Settings */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-3">Appearance</h4>
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 p-4 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
{isDark ? (
|
||||
<Moon className="h-5 w-5 text-secondary-600 dark:text-secondary-400" />
|
||||
) : (
|
||||
<Sun className="h-5 w-5 text-secondary-600 dark:text-secondary-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-secondary-900 dark:text-white">
|
||||
{isDark ? 'Dark Mode' : 'Light Mode'}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400">
|
||||
{isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 ${
|
||||
isDark ? 'bg-primary-600' : 'bg-secondary-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
isDark ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Profile
|
272
frontend/src/pages/Repositories.jsx
Normal file
272
frontend/src/pages/Repositories.jsx
Normal file
@@ -0,0 +1,272 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Server,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
AlertTriangle,
|
||||
Users,
|
||||
Globe,
|
||||
Lock,
|
||||
Unlock,
|
||||
Database,
|
||||
Eye
|
||||
} from 'lucide-react';
|
||||
import { repositoryAPI } from '../utils/api';
|
||||
|
||||
const Repositories = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterType, setFilterType] = useState('all'); // all, secure, insecure
|
||||
const [filterStatus, setFilterStatus] = useState('all'); // all, active, inactive
|
||||
|
||||
// Fetch repositories
|
||||
const { data: repositories = [], isLoading, error } = useQuery({
|
||||
queryKey: ['repositories'],
|
||||
queryFn: () => repositoryAPI.list().then(res => res.data)
|
||||
});
|
||||
|
||||
// Fetch repository statistics
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['repository-stats'],
|
||||
queryFn: () => repositoryAPI.getStats().then(res => res.data)
|
||||
});
|
||||
|
||||
// Filter repositories based on search and filters
|
||||
const filteredRepositories = repositories.filter(repo => {
|
||||
const matchesSearch = repo.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
repo.url.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
repo.distribution.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesType = filterType === 'all' ||
|
||||
(filterType === 'secure' && repo.isSecure) ||
|
||||
(filterType === 'insecure' && !repo.isSecure);
|
||||
|
||||
const matchesStatus = filterStatus === 'all' ||
|
||||
(filterStatus === 'active' && repo.isActive) ||
|
||||
(filterStatus === 'inactive' && !repo.isActive);
|
||||
|
||||
return matchesSearch && matchesType && matchesStatus;
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400 mr-2" />
|
||||
<span className="text-red-700 dark:text-red-300">
|
||||
Failed to load repositories: {error.message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
Repositories
|
||||
</h1>
|
||||
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
|
||||
Manage and monitor package repositories across your infrastructure
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Database className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Total Repositories</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.totalRepositories}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Server className="h-8 w-8 text-green-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Active Repositories</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.activeRepositories}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Shield className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Secure (HTTPS)</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.secureRepositories}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow hover:shadow-md transition-shadow p-6">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="relative">
|
||||
<ShieldCheck className="h-8 w-8 text-green-600" />
|
||||
<span className="absolute -top-1 -right-1 bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200 text-xs font-medium px-1.5 py-0.5 rounded-full">
|
||||
{stats.securityPercentage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<p className="text-sm font-medium text-secondary-500 dark:text-secondary-300">Security Score</p>
|
||||
<p className="text-2xl font-bold text-secondary-900 dark:text-white">{stats.securityPercentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow p-6">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search repositories..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Security Filter */}
|
||||
<div>
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
className="px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
|
||||
>
|
||||
<option value="all">All Security Types</option>
|
||||
<option value="secure">HTTPS Only</option>
|
||||
<option value="insecure">HTTP Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => setFilterStatus(e.target.value)}
|
||||
className="px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="active">Active Only</option>
|
||||
<option value="inactive">Inactive Only</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repositories List */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Repositories ({filteredRepositories.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{filteredRepositories.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<Database className="mx-auto h-12 w-12 text-secondary-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">No repositories found</h3>
|
||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
|
||||
{searchTerm || filterType !== 'all' || filterStatus !== 'all'
|
||||
? 'Try adjusting your search or filters.'
|
||||
: 'No repositories have been reported by your hosts yet.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
{filteredRepositories.map((repo) => (
|
||||
<div key={repo.id} className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{repo.isSecure ? (
|
||||
<Lock className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Unlock className="h-4 w-4 text-orange-600" />
|
||||
)}
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
{repo.name}
|
||||
</h3>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
repo.isActive
|
||||
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||
}`}>
|
||||
{repo.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300">
|
||||
<Globe className="inline h-4 w-4 mr-1" />
|
||||
{repo.url}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<span>Distribution: <span className="font-medium">{repo.distribution}</span></span>
|
||||
<span>Type: <span className="font-medium">{repo.repoType}</span></span>
|
||||
<span>Components: <span className="font-medium">{repo.components}</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Host Count */}
|
||||
<div className="text-center">
|
||||
<div className="flex items-center gap-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
<Users className="h-4 w-4" />
|
||||
<span>{repo.hostCount} hosts</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* View Details */}
|
||||
<Link
|
||||
to={`/repositories/${repo.id}`}
|
||||
className="btn-outline text-sm flex items-center gap-1"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
View
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Repositories;
|
369
frontend/src/pages/RepositoryDetail.jsx
Normal file
369
frontend/src/pages/RepositoryDetail.jsx
Normal file
@@ -0,0 +1,369 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Server,
|
||||
Shield,
|
||||
ShieldOff,
|
||||
AlertTriangle,
|
||||
Users,
|
||||
Globe,
|
||||
Lock,
|
||||
Unlock,
|
||||
Database,
|
||||
Calendar,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
import { repositoryAPI } from '../utils/api';
|
||||
|
||||
const RepositoryDetail = () => {
|
||||
const { repositoryId } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [formData, setFormData] = useState({});
|
||||
|
||||
// Fetch repository details
|
||||
const { data: repository, isLoading, error } = useQuery({
|
||||
queryKey: ['repository', repositoryId],
|
||||
queryFn: () => repositoryAPI.getById(repositoryId).then(res => res.data),
|
||||
enabled: !!repositoryId
|
||||
});
|
||||
|
||||
// Update repository mutation
|
||||
const updateRepositoryMutation = useMutation({
|
||||
mutationFn: (data) => repositoryAPI.update(repositoryId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['repository', repositoryId]);
|
||||
queryClient.invalidateQueries(['repositories']);
|
||||
setEditMode(false);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
const handleEdit = () => {
|
||||
setFormData({
|
||||
name: repository.name,
|
||||
description: repository.description || '',
|
||||
isActive: repository.isActive,
|
||||
priority: repository.priority || ''
|
||||
});
|
||||
setEditMode(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateRepositoryMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditMode(false);
|
||||
setFormData({});
|
||||
};
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/repositories"
|
||||
className="btn-outline flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Repositories
|
||||
</Link>
|
||||
</div>
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400 mr-2" />
|
||||
<span className="text-red-700 dark:text-red-300">
|
||||
Failed to load repository: {error.message}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!repository) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/repositories"
|
||||
className="btn-outline flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Repositories
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-center py-12">
|
||||
<Database className="mx-auto h-12 w-12 text-secondary-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">Repository not found</h3>
|
||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
|
||||
The repository you're looking for doesn't exist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
to="/repositories"
|
||||
className="btn-outline flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Link>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
{repository.isSecure ? (
|
||||
<Lock className="h-6 w-6 text-green-600" />
|
||||
) : (
|
||||
<Unlock className="h-6 w-6 text-orange-600" />
|
||||
)}
|
||||
<h1 className="text-2xl font-bold text-secondary-900 dark:text-white">
|
||||
{repository.name}
|
||||
</h1>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
repository.isActive
|
||||
? 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300'
|
||||
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300'
|
||||
}`}>
|
||||
{repository.isActive ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-secondary-500 dark:text-secondary-300 mt-1">
|
||||
Repository configuration and host assignments
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{editMode ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="btn-outline"
|
||||
disabled={updateRepositoryMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="btn-primary"
|
||||
disabled={updateRepositoryMutation.isPending}
|
||||
>
|
||||
{updateRepositoryMutation.isPending ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="btn-primary"
|
||||
>
|
||||
Edit Repository
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Repository Information */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white">
|
||||
Repository Information
|
||||
</h2>
|
||||
</div>
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{editMode ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Repository Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Priority
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
|
||||
placeholder="Optional priority"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-300 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows="3"
|
||||
className="w-full px-3 py-2 border border-secondary-300 dark:border-secondary-600 rounded-md focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-secondary-700 dark:text-white"
|
||||
placeholder="Optional description"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={formData.isActive}
|
||||
onChange={(e) => setFormData({ ...formData, isActive: e.target.checked })}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 rounded"
|
||||
/>
|
||||
<label htmlFor="isActive" className="ml-2 block text-sm text-secondary-900 dark:text-white">
|
||||
Repository is active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">URL</label>
|
||||
<div className="flex items-center mt-1">
|
||||
<Globe className="h-4 w-4 text-secondary-400 mr-2" />
|
||||
<span className="text-secondary-900 dark:text-white">{repository.url}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Distribution</label>
|
||||
<p className="text-secondary-900 dark:text-white mt-1">{repository.distribution}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Components</label>
|
||||
<p className="text-secondary-900 dark:text-white mt-1">{repository.components}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Repository Type</label>
|
||||
<p className="text-secondary-900 dark:text-white mt-1">{repository.repoType}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Security</label>
|
||||
<div className="flex items-center mt-1">
|
||||
{repository.isSecure ? (
|
||||
<>
|
||||
<Shield className="h-4 w-4 text-green-600 mr-2" />
|
||||
<span className="text-green-600">Secure (HTTPS)</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldOff className="h-4 w-4 text-orange-600 mr-2" />
|
||||
<span className="text-orange-600">Insecure (HTTP)</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{repository.priority && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Priority</label>
|
||||
<p className="text-secondary-900 dark:text-white mt-1">{repository.priority}</p>
|
||||
</div>
|
||||
)}
|
||||
{repository.description && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Description</label>
|
||||
<p className="text-secondary-900 dark:text-white mt-1">{repository.description}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-secondary-500 dark:text-secondary-400">Created</label>
|
||||
<div className="flex items-center mt-1">
|
||||
<Calendar className="h-4 w-4 text-secondary-400 mr-2" />
|
||||
<span className="text-secondary-900 dark:text-white">
|
||||
{new Date(repository.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hosts Using This Repository */}
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-700">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 dark:text-white flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
Hosts Using This Repository ({repository.hostRepositories?.length || 0})
|
||||
</h2>
|
||||
</div>
|
||||
{!repository.hostRepositories || repository.hostRepositories.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center">
|
||||
<Server className="mx-auto h-12 w-12 text-secondary-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-secondary-900 dark:text-white">No hosts using this repository</h3>
|
||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-300">
|
||||
This repository hasn't been reported by any hosts yet.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-secondary-200 dark:divide-secondary-700">
|
||||
{repository.hostRepositories.map((hostRepo) => (
|
||||
<div key={hostRepo.id} className="px-6 py-4 hover:bg-secondary-50 dark:hover:bg-secondary-700/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
hostRepo.host.status === 'active'
|
||||
? 'bg-green-500'
|
||||
: hostRepo.host.status === 'pending'
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500'
|
||||
}`} />
|
||||
<div>
|
||||
<Link
|
||||
to={`/hosts/${hostRepo.host.id}`}
|
||||
className="text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
{hostRepo.host.hostname}
|
||||
</Link>
|
||||
<div className="flex items-center gap-4 text-sm text-secondary-500 dark:text-secondary-400 mt-1">
|
||||
<span>IP: {hostRepo.host.ip}</span>
|
||||
<span>OS: {hostRepo.host.osType} {hostRepo.host.osVersion}</span>
|
||||
<span>Last Update: {new Date(hostRepo.host.lastUpdate).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">Last Checked</div>
|
||||
<div className="text-sm text-secondary-900 dark:text-white">
|
||||
{new Date(hostRepo.lastChecked).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RepositoryDetail;
|
824
frontend/src/pages/Settings.jsx
Normal file
824
frontend/src/pages/Settings.jsx
Normal file
@@ -0,0 +1,824 @@
|
||||
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 } from 'lucide-react';
|
||||
import { settingsAPI, agentVersionAPI } from '../utils/api';
|
||||
|
||||
const Settings = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
serverProtocol: 'http',
|
||||
serverHost: 'localhost',
|
||||
serverPort: 3001,
|
||||
frontendUrl: 'http://localhost:3000',
|
||||
updateInterval: 60,
|
||||
autoUpdate: false
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
// Tab management
|
||||
const [activeTab, setActiveTab] = useState('server');
|
||||
|
||||
// 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 }
|
||||
];
|
||||
|
||||
// Agent version management state
|
||||
const [showAgentVersionModal, setShowAgentVersionModal] = useState(false);
|
||||
const [editingAgentVersion, setEditingAgentVersion] = useState(null);
|
||||
const [agentVersionForm, setAgentVersionForm] = useState({
|
||||
version: '',
|
||||
releaseNotes: '',
|
||||
scriptContent: '',
|
||||
isDefault: false
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch current settings
|
||||
const { data: settings, isLoading, error } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => settingsAPI.get().then(res => res.data)
|
||||
});
|
||||
|
||||
// Update form data when settings are loaded
|
||||
useEffect(() => {
|
||||
if (settings) {
|
||||
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
|
||||
};
|
||||
console.log('Setting form data to:', newFormData);
|
||||
setFormData(newFormData);
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [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;
|
||||
});
|
||||
},
|
||||
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.serverProtocol || 'http',
|
||||
serverHost: data.serverHost || 'localhost',
|
||||
serverPort: data.serverPort || 3001,
|
||||
frontendUrl: data.frontendUrl || 'http://localhost:3000',
|
||||
updateInterval: data.updateInterval || 60,
|
||||
autoUpdate: data.autoUpdate || false
|
||||
});
|
||||
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;
|
||||
return acc;
|
||||
}, {}));
|
||||
} else {
|
||||
setErrors({ general: error.response?.data?.error || 'Failed to update settings' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Agent version queries and mutations
|
||||
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
|
||||
useEffect(() => {
|
||||
console.log('Agent versions data:', agentVersions);
|
||||
console.log('Agent versions loading:', agentVersionsLoading);
|
||||
console.log('Agent versions error:', agentVersionsError);
|
||||
}, [agentVersions, agentVersionsLoading, agentVersionsError]);
|
||||
|
||||
const createAgentVersionMutation = useMutation({
|
||||
mutationFn: (data) => agentVersionAPI.create(data).then(res => res.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['agentVersions']);
|
||||
setShowAgentVersionModal(false);
|
||||
setAgentVersionForm({ version: '', releaseNotes: '', scriptContent: '', isDefault: false });
|
||||
}
|
||||
});
|
||||
|
||||
const setCurrentAgentVersionMutation = useMutation({
|
||||
mutationFn: (id) => agentVersionAPI.setCurrent(id).then(res => res.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['agentVersions']);
|
||||
}
|
||||
});
|
||||
|
||||
const setDefaultAgentVersionMutation = useMutation({
|
||||
mutationFn: (id) => agentVersionAPI.setDefault(id).then(res => res.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['agentVersions']);
|
||||
}
|
||||
});
|
||||
|
||||
const deleteAgentVersionMutation = useMutation({
|
||||
mutationFn: (id) => agentVersionAPI.delete(id).then(res => res.data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['agentVersions']);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
updateSettingsMutation.mutate(formData);
|
||||
};
|
||||
|
||||
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');
|
||||
updateSettingsMutation.mutate(formData);
|
||||
} else {
|
||||
console.log('Validation failed:', errors);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800 dark:text-red-200">Error loading settings</h3>
|
||||
<p className="mt-1 text-sm text-red-700 dark:text-red-300">
|
||||
{error.response?.data?.error || 'Failed to load settings'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="mb-8">
|
||||
<p className="text-secondary-600 dark:text-secondary-300">
|
||||
Configure your PatchMon server settings. These settings will be used in installation scripts and agent communications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{errors.general && (
|
||||
<div className="mb-6 bg-red-50 dark:bg-red-900 border border-red-200 dark:border-red-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-5 w-5 text-red-400 dark:text-red-300" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{errors.general}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="bg-white dark:bg-secondary-800 shadow rounded-lg">
|
||||
<div className="border-b border-secondary-200 dark:border-secondary-600">
|
||||
<nav className="-mb-px flex space-x-8 px-6">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`py-4 px-1 border-b-2 font-medium text-sm flex items-center gap-2 ${
|
||||
activeTab === tab.id
|
||||
? 'border-primary-500 text-primary-600 dark:text-primary-400'
|
||||
: 'border-transparent text-secondary-500 dark:text-secondary-400 hover:text-secondary-700 dark:hover:text-secondary-300 hover:border-secondary-300 dark:hover:border-secondary-500'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{tab.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{/* Server Configuration Tab */}
|
||||
{activeTab === 'server' && (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<Server className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">Server Configuration</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||
Protocol
|
||||
</label>
|
||||
<select
|
||||
value={formData.serverProtocol}
|
||||
onChange={(e) => handleInputChange('serverProtocol', e.target.value)}
|
||||
className="w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||
Host *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.serverHost}
|
||||
onChange={(e) => handleInputChange('serverHost', e.target.value)}
|
||||
className={`w-full border rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white ${
|
||||
errors.serverHost ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
|
||||
}`}
|
||||
placeholder="example.com"
|
||||
/>
|
||||
{errors.serverHost && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.serverHost}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||
Port *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.serverPort}
|
||||
onChange={(e) => handleInputChange('serverPort', parseInt(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.serverPort ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
|
||||
}`}
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
{errors.serverPort && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.serverPort}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 p-4 bg-secondary-50 dark:bg-secondary-700 rounded-md">
|
||||
<h4 className="text-sm font-medium text-secondary-900 dark:text-white mb-2">Server URL</h4>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 font-mono">
|
||||
{formData.serverProtocol}://{formData.serverHost}:{formData.serverPort}
|
||||
</p>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">
|
||||
This URL will be used in installation scripts and agent communications.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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"
|
||||
/>
|
||||
{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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto-Update Setting */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.autoUpdate}
|
||||
onChange={(e) => handleInputChange('autoUpdate', e.target.checked)}
|
||||
className="rounded border-secondary-300 text-primary-600 shadow-sm focus:border-primary-300 focus:ring focus:ring-primary-200 focus:ring-opacity-50"
|
||||
/>
|
||||
Enable Automatic Agent Updates
|
||||
</div>
|
||||
</label>
|
||||
<p className="mt-1 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
When enabled, agents will automatically update themselves when a newer version is available during their regular update cycle.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Security Notice */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<Shield className="h-5 w-5 text-blue-400 dark:text-blue-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800 dark:text-blue-200">Security Notice</h3>
|
||||
<p className="mt-1 text-sm text-blue-700 dark:text-blue-300">
|
||||
Changing these settings will affect all installation scripts and agent communications.
|
||||
Make sure the server URL is accessible from your client networks.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || updateSettingsMutation.isPending}
|
||||
className={`inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white ${
|
||||
!isDirty || updateSettingsMutation.isPending
|
||||
? 'bg-secondary-400 cursor-not-allowed'
|
||||
: 'bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500'
|
||||
}`}
|
||||
>
|
||||
{updateSettingsMutation.isPending ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save Settings
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{updateSettingsMutation.isSuccess && (
|
||||
<div className="bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-700 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<CheckCircle className="h-5 w-5 text-green-400 dark:text-green-300" />
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-green-700 dark:text-green-300">Settings saved successfully!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<div className="flex items-center mb-2">
|
||||
<SettingsIcon className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">Agent Version Management</h2>
|
||||
</div>
|
||||
<p className="text-sm text-secondary-500 dark:text-secondary-300">
|
||||
Manage different versions of the PatchMon agent script
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowAgentVersionModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Version
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version Summary */}
|
||||
{agentVersions && agentVersions.length > 0 && (
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-4 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Current Version:</span>
|
||||
<span className="text-sm text-secondary-900 dark:text-white font-mono">
|
||||
{agentVersions.find(v => v.isCurrent)?.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'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agentVersionsLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
) : agentVersionsError ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-red-600 dark:text-red-400">Error loading agent versions: {agentVersionsError.message}</p>
|
||||
</div>
|
||||
) : !agentVersions || agentVersions.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-secondary-500 dark:text-secondary-400">No agent versions found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{agentVersions.map((version) => (
|
||||
<div key={version.id} className="border border-secondary-200 dark:border-secondary-600 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Code className="h-5 w-5 text-secondary-400 dark:text-secondary-500" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">
|
||||
Version {version.version}
|
||||
</h3>
|
||||
{version.isDefault && (
|
||||
<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 && (
|
||||
<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 && (
|
||||
<div className="text-sm text-secondary-500 dark:text-secondary-300 mt-1">
|
||||
<p className="line-clamp-3 whitespace-pre-line">
|
||||
{version.releaseNotes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-secondary-400 dark:text-secondary-400 mt-1">
|
||||
Created: {new Date(version.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
const downloadUrl = `/api/v1/hosts/agent/download?version=${version.version}`;
|
||||
window.open(downloadUrl, '_blank');
|
||||
}}
|
||||
className="btn-outline text-xs flex items-center gap-1"
|
||||
>
|
||||
<Download className="h-3 w-3" />
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentAgentVersionMutation.mutate(version.id)}
|
||||
disabled={version.isCurrent || setCurrentAgentVersionMutation.isPending}
|
||||
className="btn-outline text-xs flex items-center gap-1"
|
||||
>
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Set Current
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDefaultAgentVersionMutation.mutate(version.id)}
|
||||
disabled={version.isDefault || setDefaultAgentVersionMutation.isPending}
|
||||
className="btn-outline text-xs flex items-center gap-1"
|
||||
>
|
||||
<Star className="h-3 w-3" />
|
||||
Set Default
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteAgentVersionMutation.mutate(version.id)}
|
||||
disabled={version.isDefault || version.isCurrent || deleteAgentVersionMutation.isPending}
|
||||
className="btn-danger text-xs flex items-center gap-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{agentVersions?.length === 0 && (
|
||||
<div className="text-center py-8">
|
||||
<Code className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">No agent versions found</p>
|
||||
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
||||
Add your first agent version to get started
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Version Modal */}
|
||||
{showAgentVersionModal && (
|
||||
<AgentVersionModal
|
||||
isOpen={showAgentVersionModal}
|
||||
onClose={() => {
|
||||
setShowAgentVersionModal(false);
|
||||
setAgentVersionForm({ version: '', releaseNotes: '', scriptContent: '', isDefault: false });
|
||||
}}
|
||||
onSubmit={createAgentVersionMutation.mutate}
|
||||
isLoading={createAgentVersionMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Agent Version Modal Component
|
||||
const AgentVersionModal = ({ isOpen, onClose, onSubmit, isLoading }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
version: '',
|
||||
releaseNotes: '',
|
||||
scriptContent: '',
|
||||
isDefault: false
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const handleFileUpload = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
setFormData(prev => ({ ...prev, scriptContent: event.target.result }));
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-6 py-4 border-b border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Add Agent Version</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="px-6 py-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Version *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.version}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, version: e.target.value }))}
|
||||
className={`block 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.version ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
|
||||
}`}
|
||||
placeholder="e.g., 1.0.1"
|
||||
/>
|
||||
{errors.version && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.version}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Release Notes
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.releaseNotes}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, releaseNotes: e.target.value }))}
|
||||
rows={3}
|
||||
className="block w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
placeholder="Describe what's new in this version..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Script Content *
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="file"
|
||||
accept=".sh"
|
||||
onChange={handleFileUpload}
|
||||
className="block w-full text-sm text-secondary-500 dark:text-secondary-400 file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-medium file:bg-primary-50 file:text-primary-700 hover:file:bg-primary-100 dark:file:bg-primary-900 dark:file:text-primary-200"
|
||||
/>
|
||||
<textarea
|
||||
value={formData.scriptContent}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, scriptContent: e.target.value }))}
|
||||
rows={10}
|
||||
className={`block 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 font-mono text-sm ${
|
||||
errors.scriptContent ? 'border-red-300 dark:border-red-500' : 'border-secondary-300 dark:border-secondary-600'
|
||||
}`}
|
||||
placeholder="Paste the agent script content here..."
|
||||
/>
|
||||
{errors.scriptContent && (
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-400">{errors.scriptContent}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isDefault"
|
||||
checked={formData.isDefault}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, isDefault: e.target.checked }))}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-secondary-300 dark:border-secondary-600 rounded"
|
||||
/>
|
||||
<label htmlFor="isDefault" className="ml-2 block text-sm text-secondary-700 dark:text-secondary-200">
|
||||
Set as default version for new installations
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="btn-outline"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="btn-primary"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create Version'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Settings;
|
490
frontend/src/pages/Users.jsx
Normal file
490
frontend/src/pages/Users.jsx
Normal file
@@ -0,0 +1,490 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Trash2, Edit, User, Mail, Shield, Calendar, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { adminUsersAPI, permissionsAPI } from '../utils/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const Users = () => {
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState(null)
|
||||
const queryClient = useQueryClient()
|
||||
const { user: currentUser } = useAuth()
|
||||
|
||||
// Fetch users
|
||||
const { data: users, isLoading, error } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => adminUsersAPI.list().then(res => res.data)
|
||||
})
|
||||
|
||||
// Fetch available roles
|
||||
const { data: roles } = useQuery({
|
||||
queryKey: ['rolePermissions'],
|
||||
queryFn: () => permissionsAPI.getRoles().then(res => res.data)
|
||||
})
|
||||
|
||||
// Delete user mutation
|
||||
const deleteUserMutation = useMutation({
|
||||
mutationFn: adminUsersAPI.delete,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['users'])
|
||||
}
|
||||
})
|
||||
|
||||
// Update user mutation
|
||||
const updateUserMutation = useMutation({
|
||||
mutationFn: ({ id, data }) => adminUsersAPI.update(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['users'])
|
||||
setEditingUser(null)
|
||||
}
|
||||
})
|
||||
|
||||
const handleDeleteUser = async (userId, username) => {
|
||||
if (window.confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
|
||||
try {
|
||||
await deleteUserMutation.mutateAsync(userId)
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleUserCreated = () => {
|
||||
queryClient.invalidateQueries(['users'])
|
||||
setShowAddModal(false)
|
||||
}
|
||||
|
||||
const handleEditUser = (user) => {
|
||||
setEditingUser(user)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<XCircle className="h-5 w-5 text-danger-400" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-danger-800">Error loading users</h3>
|
||||
<p className="mt-1 text-sm text-danger-700">{error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex justify-end items-center">
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Users Table */}
|
||||
<div className="bg-white dark:bg-secondary-800 shadow overflow-hidden sm:rounded-md">
|
||||
<ul className="divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{users && Array.isArray(users) && users.length > 0 ? (
|
||||
users.map((user) => (
|
||||
<li key={user.id}>
|
||||
<div className="px-4 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-10 w-10 rounded-full bg-primary-100 flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-primary-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="flex items-center">
|
||||
<p className="text-sm font-medium text-secondary-900 dark:text-white">{user.username}</p>
|
||||
{user.id === currentUser?.id && (
|
||||
<span className="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
You
|
||||
</span>
|
||||
)}
|
||||
<span className={`ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.role === 'admin'
|
||||
? 'bg-primary-100 text-primary-800'
|
||||
: user.role === 'host_manager'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: user.role === 'readonly'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-secondary-100 text-secondary-800'
|
||||
}`}>
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
{user.role.charAt(0).toUpperCase() + user.role.slice(1).replace('_', ' ')}
|
||||
</span>
|
||||
{user.isActive ? (
|
||||
<CheckCircle className="ml-2 h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="ml-2 h-4 w-4 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center mt-1 text-sm text-secondary-500 dark:text-secondary-300">
|
||||
<Mail className="h-4 w-4 mr-1" />
|
||||
{user.email}
|
||||
</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 && (
|
||||
<>
|
||||
<span className="mx-2">•</span>
|
||||
Last login: {new Date(user.lastLogin).toLocaleDateString()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditUser(user)}
|
||||
className="text-secondary-400 hover:text-secondary-600 dark:text-secondary-500 dark:hover:text-secondary-300"
|
||||
title="Edit user"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteUser(user.id, user.username)}
|
||||
className="text-danger-400 hover:text-danger-600 dark:text-danger-500 dark:hover:text-danger-400 disabled:text-gray-300 disabled:cursor-not-allowed"
|
||||
title={
|
||||
user.id === currentUser?.id
|
||||
? "Cannot delete your own account"
|
||||
: user.role === 'admin' && users.filter(u => u.role === 'admin').length === 1
|
||||
? "Cannot delete the last admin user"
|
||||
: "Delete user"
|
||||
}
|
||||
disabled={
|
||||
user.id === currentUser?.id ||
|
||||
(user.role === 'admin' && users.filter(u => u.role === 'admin').length === 1)
|
||||
}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))
|
||||
) : (
|
||||
<li>
|
||||
<div className="px-4 py-8 text-center">
|
||||
<User className="h-12 w-12 text-secondary-400 mx-auto mb-4" />
|
||||
<p className="text-secondary-500 dark:text-secondary-300">No users found</p>
|
||||
<p className="text-sm text-secondary-400 dark:text-secondary-400 mt-2">
|
||||
Click "Add User" to create the first user
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Add User Modal */}
|
||||
<AddUserModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onUserCreated={handleUserCreated}
|
||||
roles={roles}
|
||||
/>
|
||||
|
||||
{/* Edit User Modal */}
|
||||
{editingUser && (
|
||||
<EditUserModal
|
||||
user={editingUser}
|
||||
isOpen={!!editingUser}
|
||||
onClose={() => setEditingUser(null)}
|
||||
onUserUpdated={() => updateUserMutation.mutate()}
|
||||
roles={roles}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Add User Modal Component
|
||||
const AddUserModal = ({ isOpen, onClose, onUserCreated, roles }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 'user'
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const response = await adminUsersAPI.create(formData)
|
||||
onUserCreated()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to create user')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
setFormData({
|
||||
...formData,
|
||||
[e.target.name]: e.target.value
|
||||
})
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Add New User</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
required
|
||||
minLength={6}
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">Minimum 6 characters</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
name="role"
|
||||
value={formData.role}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
>
|
||||
{roles && Array.isArray(roles) ? (
|
||||
roles.map((role) => (
|
||||
<option key={role.role} value={role.role}>
|
||||
{role.role.charAt(0).toUpperCase() + role.role.slice(1).replace('_', ' ')}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
|
||||
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Create User'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Edit User Modal Component
|
||||
const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
username: user?.username || '',
|
||||
email: user?.email || '',
|
||||
role: user?.role || 'user',
|
||||
isActive: user?.isActive ?? true
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
await adminUsersAPI.update(user.id, formData)
|
||||
onUserUpdated()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to update user')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value, type, checked } = e.target
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
})
|
||||
}
|
||||
|
||||
if (!isOpen || !user) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Edit User</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Role
|
||||
</label>
|
||||
<select
|
||||
name="role"
|
||||
value={formData.role}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
>
|
||||
{roles && Array.isArray(roles) ? (
|
||||
roles.map((role) => (
|
||||
<option key={role.role} value={role.role}>
|
||||
{role.role.charAt(0).toUpperCase() + role.role.slice(1).replace('_', ' ')}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="isActive"
|
||||
checked={formData.isActive}
|
||||
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 dark:text-secondary-200">
|
||||
Active user
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
|
||||
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update User'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Users
|
194
frontend/src/utils/api.js
Normal file
194
frontend/src/utils/api.js
Normal file
@@ -0,0 +1,194 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
|
||||
// Create axios instance with default config
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Request interceptor
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
// Add auth token if available
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Response interceptor
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Handle unauthorized
|
||||
localStorage.removeItem('token')
|
||||
window.location.href = '/login'
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// Dashboard API
|
||||
export const dashboardAPI = {
|
||||
getStats: () => api.get('/dashboard/stats'),
|
||||
getHosts: () => api.get('/dashboard/hosts'),
|
||||
getPackages: () => api.get('/dashboard/packages'),
|
||||
getHostDetail: (hostId) => api.get(`/dashboard/hosts/${hostId}`),
|
||||
}
|
||||
|
||||
// Admin Hosts API (for management interface)
|
||||
export const adminHostsAPI = {
|
||||
create: (data) => api.post('/hosts/create', data),
|
||||
list: () => api.get('/hosts/admin/list'),
|
||||
delete: (hostId) => api.delete(`/hosts/${hostId}`),
|
||||
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 })
|
||||
}
|
||||
|
||||
// Host Groups API
|
||||
export const hostGroupsAPI = {
|
||||
list: () => api.get('/host-groups'),
|
||||
get: (id) => api.get(`/host-groups/${id}`),
|
||||
create: (data) => api.post('/host-groups', data),
|
||||
update: (id, data) => api.put(`/host-groups/${id}`, data),
|
||||
delete: (id) => api.delete(`/host-groups/${id}`),
|
||||
getHosts: (id) => api.get(`/host-groups/${id}/hosts`),
|
||||
}
|
||||
|
||||
// Admin Users API (for user management)
|
||||
export const adminUsersAPI = {
|
||||
list: () => api.get('/auth/admin/users'),
|
||||
create: (userData) => api.post('/auth/admin/users', userData),
|
||||
update: (userId, userData) => api.put(`/auth/admin/users/${userId}`, userData),
|
||||
delete: (userId) => api.delete(`/auth/admin/users/${userId}`)
|
||||
}
|
||||
|
||||
// Permissions API (for role management)
|
||||
export const permissionsAPI = {
|
||||
getRoles: () => api.get('/permissions/roles'),
|
||||
getRole: (role) => api.get(`/permissions/roles/${role}`),
|
||||
updateRole: (role, permissions) => api.put(`/permissions/roles/${role}`, permissions),
|
||||
deleteRole: (role) => api.delete(`/permissions/roles/${role}`),
|
||||
getUserPermissions: () => api.get('/permissions/user-permissions')
|
||||
}
|
||||
|
||||
// Settings API
|
||||
export const settingsAPI = {
|
||||
get: () => api.get('/settings'),
|
||||
update: (settings) => api.put('/settings', settings),
|
||||
getServerUrl: () => api.get('/settings/server-url')
|
||||
}
|
||||
|
||||
// Agent Version API
|
||||
export const agentVersionAPI = {
|
||||
list: () => api.get('/hosts/agent/versions'),
|
||||
create: (data) => api.post('/hosts/agent/versions', data),
|
||||
update: (id, data) => api.put(`/hosts/agent/versions/${id}`, data),
|
||||
delete: (id) => api.delete(`/hosts/agent/versions/${id}`),
|
||||
setCurrent: (id) => api.patch(`/hosts/agent/versions/${id}/current`),
|
||||
setDefault: (id) => api.patch(`/hosts/agent/versions/${id}/default`),
|
||||
download: (version) => api.get(`/hosts/agent/download${version ? `?version=${version}` : ''}`, { responseType: 'blob' })
|
||||
}
|
||||
|
||||
// Repository API
|
||||
export const repositoryAPI = {
|
||||
list: () => api.get('/repositories'),
|
||||
getById: (repositoryId) => api.get(`/repositories/${repositoryId}`),
|
||||
getByHost: (hostId) => api.get(`/repositories/host/${hostId}`),
|
||||
update: (repositoryId, data) => api.put(`/repositories/${repositoryId}`, data),
|
||||
toggleHostRepository: (hostId, repositoryId, isEnabled) =>
|
||||
api.patch(`/repositories/host/${hostId}/repository/${repositoryId}`, { isEnabled }),
|
||||
getStats: () => api.get('/repositories/stats/summary'),
|
||||
cleanupOrphaned: () => api.delete('/repositories/cleanup/orphaned')
|
||||
}
|
||||
|
||||
// Dashboard Preferences API
|
||||
export const dashboardPreferencesAPI = {
|
||||
get: () => api.get('/dashboard-preferences'),
|
||||
update: (preferences) => api.put('/dashboard-preferences', { preferences }),
|
||||
getDefaults: () => api.get('/dashboard-preferences/defaults')
|
||||
}
|
||||
|
||||
// Hosts API (for agent communication - kept for compatibility)
|
||||
export const hostsAPI = {
|
||||
// Legacy register endpoint (now deprecated)
|
||||
register: (data) => api.post('/hosts/register', data),
|
||||
|
||||
// Updated to use API credentials
|
||||
update: (apiId, apiKey, data) => api.post('/hosts/update', data, {
|
||||
headers: {
|
||||
'X-API-ID': apiId,
|
||||
'X-API-KEY': apiKey
|
||||
}
|
||||
}),
|
||||
getInfo: (apiId, apiKey) => api.get('/hosts/info', {
|
||||
headers: {
|
||||
'X-API-ID': apiId,
|
||||
'X-API-KEY': apiKey
|
||||
}
|
||||
}),
|
||||
ping: (apiId, apiKey) => api.post('/hosts/ping', {}, {
|
||||
headers: {
|
||||
'X-API-ID': apiId,
|
||||
'X-API-KEY': apiKey
|
||||
}
|
||||
}),
|
||||
toggleAutoUpdate: (id, autoUpdate) => api.patch(`/hosts/${id}/auto-update`, { autoUpdate })
|
||||
}
|
||||
|
||||
// Packages API
|
||||
export const packagesAPI = {
|
||||
getAll: (params = {}) => api.get('/packages', { params }),
|
||||
getById: (packageId) => api.get(`/packages/${packageId}`),
|
||||
getCategories: () => api.get('/packages/categories/list'),
|
||||
getHosts: (packageId, params = {}) => api.get(`/packages/${packageId}/hosts`, { params }),
|
||||
update: (packageId, data) => api.put(`/packages/${packageId}`, data),
|
||||
search: (query, params = {}) => api.get(`/packages/search/${query}`, { params }),
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
export const formatError = (error) => {
|
||||
if (error.response?.data?.message) {
|
||||
return error.response.data.message
|
||||
}
|
||||
if (error.response?.data?.error) {
|
||||
return error.response.data.error
|
||||
}
|
||||
if (error.message) {
|
||||
return error.message
|
||||
}
|
||||
return 'An unexpected error occurred'
|
||||
}
|
||||
|
||||
export const formatDate = (date) => {
|
||||
return new Date(date).toLocaleString()
|
||||
}
|
||||
|
||||
export const formatRelativeTime = (date) => {
|
||||
const now = new Date()
|
||||
const diff = now - new Date(date)
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`
|
||||
if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`
|
||||
if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`
|
||||
return `${seconds} second${seconds > 1 ? 's' : ''} ago`
|
||||
}
|
||||
|
||||
export default api
|
85
frontend/tailwind.config.js
Normal file
85
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
secondary: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
|
||||
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
|
||||
},
|
||||
boxShadow: {
|
||||
'card': '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06)',
|
||||
'card-hover': '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
||||
'card-dark': '0 1px 3px 0 rgba(255, 255, 255, 0.1), 0 1px 2px 0 rgba(255, 255, 255, 0.06)',
|
||||
'card-hover-dark': '0 4px 6px -1px rgba(255, 255, 255, 0.15), 0 2px 4px -1px rgba(255, 255, 255, 0.1)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
35
frontend/vite.config.js
Normal file
35
frontend/vite.config.js
Normal file
@@ -0,0 +1,35 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
strictPort: true, // Exit if port is already in use
|
||||
allowedHosts: ['localhost'],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
configure: process.env.VITE_ENABLE_LOGGING === 'true' ? (proxy, options) => {
|
||||
proxy.on('error', (err, req, res) => {
|
||||
console.log('proxy error', err);
|
||||
});
|
||||
proxy.on('proxyReq', (proxyReq, req, res) => {
|
||||
console.log('Sending Request to the Target:', req.method, req.url);
|
||||
});
|
||||
proxy.on('proxyRes', (proxyRes, req, res) => {
|
||||
console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
|
||||
});
|
||||
} : undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: process.env.NODE_ENV !== 'production',
|
||||
target: 'es2018',
|
||||
},
|
||||
})
|
7815
package-lock.json
generated
Normal file
7815
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "patchmon",
|
||||
"version": "1.0.0",
|
||||
"description": "Linux Patch Monitoring System",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"backend",
|
||||
"frontend"
|
||||
],
|
||||
"scripts": {
|
||||
"install:all": "npm install && npm run install:backend && npm run install:frontend",
|
||||
"install:backend": "cd backend && npm install",
|
||||
"install:frontend": "cd frontend && npm install",
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
"dev:frontend": "cd frontend && npm run dev",
|
||||
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\"",
|
||||
"build:backend": "cd backend && npm run build",
|
||||
"build:frontend": "cd frontend && npm run build",
|
||||
"build": "npm run build:backend && npm run build:frontend"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
108
setup-admin-user.js
Normal file
108
setup-admin-user.js
Normal file
@@ -0,0 +1,108 @@
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const readline = require('readline');
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
const question = (query) => new Promise((resolve) => rl.question(query, resolve));
|
||||
|
||||
async function setupAdminUser() {
|
||||
try {
|
||||
console.log('🔐 Setting up PatchMon Admin User');
|
||||
console.log('=====================================\n');
|
||||
|
||||
// Check if any users exist
|
||||
const existingUsers = await prisma.user.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): ');
|
||||
if (overwrite.toLowerCase() !== 'y' && overwrite.toLowerCase() !== 'yes') {
|
||||
console.log('❌ Setup cancelled.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get user input
|
||||
const username = await question('Enter admin username: ');
|
||||
if (!username.trim()) {
|
||||
console.log('❌ Username is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const email = await question('Enter admin email: ');
|
||||
if (!email.trim()) {
|
||||
console.log('❌ Email is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const password = await question('Enter admin password (min 6 characters): ');
|
||||
if (password.length < 6) {
|
||||
console.log('❌ Password must be at least 6 characters.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if username or email already exists
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: username.trim() },
|
||||
{ email: email.trim() }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
console.log('❌ Username or email already exists.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Hash password
|
||||
console.log('\n🔄 Creating admin user...');
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
// Create admin user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: username.trim(),
|
||||
email: email.trim(),
|
||||
passwordHash,
|
||||
role: 'admin',
|
||||
isActive: true
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true
|
||||
}
|
||||
});
|
||||
|
||||
console.log('✅ Admin user created successfully!');
|
||||
console.log('\n📋 User Details:');
|
||||
console.log(` Username: ${user.username}`);
|
||||
console.log(` Email: ${user.email}`);
|
||||
console.log(` Role: ${user.role}`);
|
||||
console.log(` Created: ${user.createdAt.toISOString()}`);
|
||||
|
||||
console.log('\n🎉 Setup complete!');
|
||||
console.log('\nNext steps:');
|
||||
console.log('1. Start the backend server: cd backend && npm start');
|
||||
console.log('2. Start the frontend: cd frontend && npm run dev');
|
||||
console.log('3. Visit http://localhost:3000 and login with your credentials');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error setting up admin user:', error);
|
||||
} finally {
|
||||
rl.close();
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
// Run the setup
|
||||
setupAdminUser();
|
Reference in New Issue
Block a user