first commit

This commit is contained in:
Muhammad Ibrahim
2025-09-16 15:36:42 +01:00
commit c5332ce6b0
61 changed files with 21858 additions and 0 deletions

137
.gitignore vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

156
agents/patchmon_install.sh Normal file
View 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
View 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
View 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"
}
}

View 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;

View File

@@ -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");

View File

@@ -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")
);

View File

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

View File

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

View File

@@ -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");

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "agent_versions" ADD COLUMN "is_default" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "script_content" TEXT;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "settings" ADD COLUMN "update_interval" INTEGER NOT NULL DEFAULT 60;

View File

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "hosts" ADD COLUMN "auto_update" BOOLEAN NOT NULL DEFAULT true;

View File

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

View 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"

View 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")
}

View 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
};

View 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
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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
View 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
View 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"
}
}

View File

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

117
frontend/src/App.jsx Normal file
View 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

View 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;

View 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

View 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

View 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>
)
}

View 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
View 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
View 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>,
)

View 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

View 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

View 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

File diff suppressed because it is too large Load Diff

View 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

View 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

View 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

View 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

View 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

View 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;

View 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;

View 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;

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

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

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View 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
View 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();