mirror of
https://github.com/9technologygroup/patchmon.net.git
synced 2025-10-22 23:32:03 +00:00
feat: Add Server Version management with GitHub integration
- Add Server Version tab in settings - Add githubRepoUrl field to Settings model - Add database migration for github_repo_url - Update settings API to handle GitHub repo URL - Add version checking UI with current/latest version display - Default GitHub repo: git@github.com:9technologygroup/patchmon.net.git
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -139,3 +139,6 @@ test-results.xml
|
||||
# Deployment scripts (production deployment tools)
|
||||
deploy-patchmon.sh
|
||||
manage-instances.sh
|
||||
setup-installer-site.sh
|
||||
install-server.*
|
||||
notify-clients-upgrade.sh
|
||||
|
434
README.md
434
README.md
@@ -1,434 +0,0 @@
|
||||
# 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!
|
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "settings" ADD COLUMN "github_repo_url" TEXT NOT NULL DEFAULT 'git@github.com:9technologygroup/patchmon.net.git';
|
@@ -179,6 +179,7 @@ model Settings {
|
||||
frontendUrl String @map("frontend_url") @default("http://localhost:3000")
|
||||
updateInterval Int @map("update_interval") @default(60) // Update interval in minutes
|
||||
autoUpdate Boolean @map("auto_update") @default(false) // Enable automatic agent updates
|
||||
githubRepoUrl String @map("github_repo_url") @default("git@github.com:9technologygroup/patchmon.net.git") // GitHub repository URL for version checking
|
||||
createdAt DateTime @map("created_at") @default(now())
|
||||
updatedAt DateTime @map("updated_at") @updatedAt
|
||||
|
||||
|
@@ -262,6 +262,67 @@ router.delete('/admin/users/:userId', authenticateToken, requireManageUsers, asy
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint to reset user password
|
||||
router.post('/admin/users/:userId/reset-password', authenticateToken, requireManageUsers, [
|
||||
body('newPassword').isLength({ min: 6 }).withMessage('New password must be at least 6 characters')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.params;
|
||||
const errors = validationResult(req);
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { newPassword } = req.body;
|
||||
|
||||
// Check if user exists
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
role: true,
|
||||
isActive: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
// Prevent resetting password of inactive users
|
||||
if (!user.isActive) {
|
||||
return res.status(400).json({ error: 'Cannot reset password for inactive user' });
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
|
||||
// Update user password
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { passwordHash }
|
||||
});
|
||||
|
||||
// Log the password reset action (you might want to add an audit log table)
|
||||
console.log(`Password reset for user ${user.username} (${user.email}) by admin ${req.user.username}`);
|
||||
|
||||
res.json({
|
||||
message: 'Password reset successfully',
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Password reset error:', error);
|
||||
res.status(500).json({ error: 'Failed to reset password' });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/login', [
|
||||
body('username').notEmpty().withMessage('Username is required'),
|
||||
|
@@ -15,7 +15,15 @@ const prisma = new PrismaClient();
|
||||
router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) => {
|
||||
try {
|
||||
const now = new Date();
|
||||
const twentyFourHoursAgo = moment(now).subtract(24, 'hours').toDate();
|
||||
|
||||
// Get the agent update interval setting
|
||||
const settings = await prisma.settings.findFirst();
|
||||
const updateIntervalMinutes = settings?.updateInterval || 60; // Default to 60 minutes if no setting
|
||||
|
||||
// Calculate the threshold based on the actual update interval
|
||||
// Use 2x the update interval as the threshold for "errored" hosts
|
||||
const thresholdMinutes = updateIntervalMinutes * 2;
|
||||
const thresholdTime = moment(now).subtract(thresholdMinutes, 'minutes').toDate();
|
||||
|
||||
// Get all statistics in parallel for better performance
|
||||
const [
|
||||
@@ -49,12 +57,12 @@ router.get('/stats', authenticateToken, requireViewDashboard, async (req, res) =
|
||||
where: { needsUpdate: true }
|
||||
}),
|
||||
|
||||
// Errored hosts (not updated in 24 hours)
|
||||
// Errored hosts (not updated within threshold based on update interval)
|
||||
prisma.host.count({
|
||||
where: {
|
||||
status: 'active',
|
||||
lastUpdate: {
|
||||
lt: twentyFourHoursAgo
|
||||
lt: thresholdTime
|
||||
}
|
||||
}
|
||||
}),
|
||||
@@ -180,10 +188,25 @@ router.get('/hosts', authenticateToken, requireViewHosts, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get the agent update interval setting for stale calculation
|
||||
const settings = await prisma.settings.findFirst();
|
||||
const updateIntervalMinutes = settings?.updateInterval || 60;
|
||||
const thresholdMinutes = updateIntervalMinutes * 2;
|
||||
|
||||
// Calculate effective status based on reporting interval
|
||||
const isStale = moment(host.lastUpdate).isBefore(moment().subtract(thresholdMinutes, 'minutes'));
|
||||
let effectiveStatus = host.status;
|
||||
|
||||
// Override status if host hasn't reported within threshold
|
||||
if (isStale && host.status === 'active') {
|
||||
effectiveStatus = 'inactive';
|
||||
}
|
||||
|
||||
return {
|
||||
...host,
|
||||
updatesCount,
|
||||
isStale: moment(host.lastUpdate).isBefore(moment().subtract(24, 'hours'))
|
||||
isStale,
|
||||
effectiveStatus
|
||||
};
|
||||
})
|
||||
);
|
||||
|
@@ -122,7 +122,8 @@ router.put('/', authenticateToken, requireManageSettings, [
|
||||
body('serverPort').isInt({ min: 1, max: 65535 }).withMessage('Port must be between 1 and 65535'),
|
||||
body('frontendUrl').isLength({ min: 1 }).withMessage('Frontend URL is required'),
|
||||
body('updateInterval').isInt({ min: 5, max: 1440 }).withMessage('Update interval must be between 5 and 1440 minutes'),
|
||||
body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean')
|
||||
body('autoUpdate').isBoolean().withMessage('Auto update must be a boolean'),
|
||||
body('githubRepoUrl').optional().isLength({ min: 1 }).withMessage('GitHub repo URL must be a non-empty string')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
console.log('Settings update request body:', req.body);
|
||||
@@ -132,8 +133,8 @@ router.put('/', authenticateToken, requireManageSettings, [
|
||||
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 });
|
||||
const { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl } = req.body;
|
||||
console.log('Extracted values:', { serverProtocol, serverHost, serverPort, frontendUrl, updateInterval, autoUpdate, githubRepoUrl });
|
||||
|
||||
// Construct server URL from components
|
||||
const serverUrl = `${serverProtocol}://${serverHost}:${serverPort}`;
|
||||
@@ -149,7 +150,8 @@ router.put('/', authenticateToken, requireManageSettings, [
|
||||
serverPort,
|
||||
frontendUrl,
|
||||
updateInterval: updateInterval || 60,
|
||||
autoUpdate: autoUpdate || false
|
||||
autoUpdate: autoUpdate || false,
|
||||
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
|
||||
});
|
||||
const oldUpdateInterval = settings.updateInterval;
|
||||
|
||||
@@ -162,7 +164,8 @@ router.put('/', authenticateToken, requireManageSettings, [
|
||||
serverPort,
|
||||
frontendUrl,
|
||||
updateInterval: updateInterval || 60,
|
||||
autoUpdate: autoUpdate || false
|
||||
autoUpdate: autoUpdate || false,
|
||||
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
|
||||
}
|
||||
});
|
||||
console.log('Settings updated successfully:', settings);
|
||||
@@ -182,7 +185,8 @@ router.put('/', authenticateToken, requireManageSettings, [
|
||||
serverPort,
|
||||
frontendUrl,
|
||||
updateInterval: updateInterval || 60,
|
||||
autoUpdate: autoUpdate || false
|
||||
autoUpdate: autoUpdate || false,
|
||||
githubRepoUrl: githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@@ -18,7 +18,8 @@ import {
|
||||
Clock,
|
||||
RefreshCw,
|
||||
GitBranch,
|
||||
Wrench
|
||||
Wrench,
|
||||
Plus
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
@@ -59,7 +60,7 @@ const Layout = ({ children }) => {
|
||||
]
|
||||
},
|
||||
{
|
||||
section: 'Users',
|
||||
section: 'PatchMon Users',
|
||||
items: [
|
||||
...(canViewUsers() ? [{ name: 'Users', href: '/users', icon: Users }] : []),
|
||||
...(canManageSettings() ? [{ name: 'Permissions', href: '/permissions', icon: Shield }] : []),
|
||||
@@ -68,7 +69,7 @@ const Layout = ({ children }) => {
|
||||
{
|
||||
section: 'Settings',
|
||||
items: [
|
||||
...(canManageSettings() ? [{ name: 'Settings', href: '/settings', icon: Settings }] : []),
|
||||
...(canManageSettings() ? [{ name: 'Server Config', href: '/settings', icon: Settings }] : []),
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -100,6 +101,26 @@ const Layout = ({ children }) => {
|
||||
setUserMenuOpen(false)
|
||||
}
|
||||
|
||||
const handleAddHost = () => {
|
||||
// Navigate to hosts page with add modal parameter
|
||||
window.location.href = '/hosts?action=add'
|
||||
}
|
||||
|
||||
// Short format for navigation area
|
||||
const formatRelativeTimeShort = (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}d ago`
|
||||
if (hours > 0) return `${hours}h ago`
|
||||
if (minutes > 0) return `${minutes}m ago`
|
||||
return `${seconds}s ago`
|
||||
}
|
||||
|
||||
// Save sidebar collapsed state to localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebarCollapsed', JSON.stringify(sidebarCollapsed))
|
||||
@@ -168,26 +189,57 @@ const Layout = ({ children }) => {
|
||||
</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
|
||||
<div key={subItem.name}>
|
||||
{subItem.name === 'Hosts' && canManageHosts() ? (
|
||||
// Special handling for Hosts item with integrated + button (mobile)
|
||||
<Link
|
||||
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'
|
||||
}`}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<subItem.icon className="mr-3 h-5 w-5" />
|
||||
<span className="flex items-center gap-2 flex-1">
|
||||
{subItem.name}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
setSidebarOpen(false)
|
||||
handleAddHost()
|
||||
}}
|
||||
className="ml-auto flex items-center justify-center w-5 h-5 rounded-full border-2 border-current opacity-60 hover:opacity-100 transition-all duration-200 self-center"
|
||||
title="Add Host"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
</Link>
|
||||
) : (
|
||||
// Standard navigation item (mobile)
|
||||
<Link
|
||||
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>
|
||||
</div>
|
||||
@@ -268,30 +320,65 @@ const Layout = ({ children }) => {
|
||||
<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
|
||||
{subItem.name === 'Hosts' && canManageHosts() ? (
|
||||
// Special handling for Hosts item with integrated + button
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
to={subItem.href}
|
||||
className={`group flex gap-x-3 rounded-md text-sm leading-6 font-medium transition-all duration-200 flex-1 ${
|
||||
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'}`}
|
||||
title={sidebarCollapsed ? subItem.name : ''}
|
||||
>
|
||||
<subItem.icon className={`h-5 w-5 shrink-0 ${sidebarCollapsed ? 'mx-auto' : ''}`} />
|
||||
{!sidebarCollapsed && (
|
||||
<span className="truncate flex items-center gap-2 flex-1">
|
||||
{subItem.name}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
{!sidebarCollapsed && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
handleAddHost()
|
||||
}}
|
||||
className="ml-auto flex items-center justify-center w-5 h-5 rounded-full border-2 border-current opacity-60 hover:opacity-100 transition-all duration-200 self-center"
|
||||
title="Add Host"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
// Standard navigation item
|
||||
<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>
|
||||
@@ -304,39 +391,41 @@ const Layout = ({ children }) => {
|
||||
</nav>
|
||||
|
||||
{/* Profile Section - Bottom of Sidebar */}
|
||||
<div className="border-t border-secondary-200">
|
||||
<div className="border-t border-secondary-200 dark:border-secondary-600">
|
||||
{!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>
|
||||
{/* User Info with Sign Out - Username is clickable */}
|
||||
<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
|
||||
<Link
|
||||
to="/profile"
|
||||
className={`flex-1 min-w-0 rounded-md p-2 transition-all duration-200 ${
|
||||
isActive('/profile')
|
||||
? 'bg-primary-50 dark:bg-primary-600'
|
||||
: 'hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-x-3">
|
||||
<UserCircle className={`h-5 w-5 shrink-0 ${
|
||||
isActive('/profile')
|
||||
? 'text-primary-700 dark:text-white'
|
||||
: 'text-secondary-500 dark:text-secondary-400'
|
||||
}`} />
|
||||
<div className="flex items-center gap-x-2">
|
||||
<span className={`text-sm leading-6 font-semibold truncate ${
|
||||
isActive('/profile')
|
||||
? 'text-primary-700 dark:text-white'
|
||||
: 'text-secondary-700 dark:text-secondary-200'
|
||||
}`}>
|
||||
{user?.username}
|
||||
</span>
|
||||
)}
|
||||
{user?.role === 'admin' && (
|
||||
<span className="inline-flex items-center rounded-full bg-primary-100 px-1.5 py-0.5 text-xs font-medium text-primary-800">
|
||||
Admin
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-secondary-500 dark:text-secondary-300 truncate">
|
||||
{user?.email}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="ml-2 p-1.5 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 rounded transition-colors"
|
||||
@@ -345,23 +434,55 @@ const Layout = ({ children }) => {
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Updated info */}
|
||||
{stats && (
|
||||
<div className="px-3 py-1 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<div className="flex items-center gap-x-2 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>Updated: {formatRelativeTimeShort(stats.lastUpdated)}</span>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
|
||||
title="Refresh data"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</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"
|
||||
className={`flex items-center justify-center p-2 rounded-md transition-colors ${
|
||||
isActive('/profile')
|
||||
? 'bg-primary-50 dark:bg-primary-600 text-primary-700 dark:text-white'
|
||||
: 'text-secondary-700 dark:text-secondary-200 hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
}`}
|
||||
title={`My Profile (${user?.username})`}
|
||||
>
|
||||
<UserCircle className="h-5 w-5 text-white" />
|
||||
<UserCircle className="h-5 w-5" />
|
||||
</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"
|
||||
className="flex items-center justify-center w-full p-2 text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded-md transition-colors"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="h-5 w-5 text-white" />
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
{/* Updated info for collapsed sidebar */}
|
||||
{stats && (
|
||||
<div className="flex justify-center py-1 border-t border-secondary-200 dark:border-secondary-700">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-1 hover:bg-secondary-100 dark:hover:bg-secondary-700 rounded"
|
||||
title={`Refresh data - Updated: ${formatRelativeTimeShort(stats.lastUpdated)}`}
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -369,7 +490,7 @@ const Layout = ({ children }) => {
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className={`transition-all duration-300 ${
|
||||
<div className={`flex flex-col min-h-screen transition-all duration-300 ${
|
||||
sidebarCollapsed ? 'lg:pl-16' : 'lg:pl-64'
|
||||
}`}>
|
||||
{/* Top bar */}
|
||||
@@ -392,22 +513,6 @@ const Layout = ({ children }) => {
|
||||
</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
|
||||
@@ -426,7 +531,7 @@ const Layout = ({ children }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="py-6 bg-secondary-50 dark:bg-secondary-800 min-h-screen">
|
||||
<main className="flex-1 py-6 bg-secondary-50 dark:bg-secondary-800">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
{children}
|
||||
</div>
|
||||
|
@@ -12,7 +12,7 @@ import {
|
||||
} 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 { dashboardAPI, dashboardPreferencesAPI, settingsAPI, formatRelativeTime } from '../utils/api'
|
||||
import DashboardSettingsModal from '../components/DashboardSettingsModal'
|
||||
import { useTheme } from '../contexts/ThemeContext'
|
||||
|
||||
@@ -42,6 +42,48 @@ const Dashboard = () => {
|
||||
navigate('/packages?filter=security')
|
||||
}
|
||||
|
||||
const handleErroredHostsClick = () => {
|
||||
navigate('/hosts?filter=inactive')
|
||||
}
|
||||
|
||||
const handleOSDistributionClick = () => {
|
||||
navigate('/hosts')
|
||||
}
|
||||
|
||||
const handleUpdateStatusClick = () => {
|
||||
navigate('/hosts')
|
||||
}
|
||||
|
||||
const handlePackagePriorityClick = () => {
|
||||
navigate('/packages?filter=security')
|
||||
}
|
||||
|
||||
// Helper function to format the update interval threshold
|
||||
const formatUpdateIntervalThreshold = () => {
|
||||
if (!settings?.updateInterval) return '24 hours'
|
||||
|
||||
const intervalMinutes = settings.updateInterval
|
||||
const thresholdMinutes = intervalMinutes * 2 // 2x the update interval
|
||||
|
||||
if (thresholdMinutes < 60) {
|
||||
return `${thresholdMinutes} minutes`
|
||||
} else if (thresholdMinutes < 1440) {
|
||||
const hours = Math.floor(thresholdMinutes / 60)
|
||||
const minutes = thresholdMinutes % 60
|
||||
if (minutes === 0) {
|
||||
return `${hours} hour${hours > 1 ? 's' : ''}`
|
||||
}
|
||||
return `${hours}h ${minutes}m`
|
||||
} else {
|
||||
const days = Math.floor(thresholdMinutes / 1440)
|
||||
const hours = Math.floor((thresholdMinutes % 1440) / 60)
|
||||
if (hours === 0) {
|
||||
return `${days} day${days > 1 ? 's' : ''}`
|
||||
}
|
||||
return `${days}d ${hours}h`
|
||||
}
|
||||
}
|
||||
|
||||
const { data: stats, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['dashboardStats'],
|
||||
queryFn: () => dashboardAPI.getStats().then(res => res.data),
|
||||
@@ -49,6 +91,12 @@ const Dashboard = () => {
|
||||
staleTime: 30000, // Consider data stale after 30 seconds
|
||||
})
|
||||
|
||||
// Fetch settings to get the agent update interval
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => settingsAPI.get().then(res => res.data),
|
||||
})
|
||||
|
||||
// Fetch user's dashboard preferences
|
||||
const { data: preferences, refetch: refetchPreferences } = useQuery({
|
||||
queryKey: ['dashboardPreferences'],
|
||||
@@ -210,11 +258,14 @@ const Dashboard = () => {
|
||||
|
||||
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={`border rounded-lg p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200 ${
|
||||
stats.cards.erroredHosts > 0
|
||||
? 'bg-danger-50 border-danger-200'
|
||||
: 'bg-success-50 border-success-200'
|
||||
}`}
|
||||
onClick={handleErroredHostsClick}
|
||||
>
|
||||
<div className="flex">
|
||||
<AlertTriangle className={`h-5 w-5 ${
|
||||
stats.cards.erroredHosts > 0 ? 'text-danger-400' : 'text-success-400'
|
||||
@@ -223,7 +274,7 @@ const Dashboard = () => {
|
||||
{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
|
||||
{stats.cards.erroredHosts} host{stats.cards.erroredHosts > 1 ? 's' : ''} haven't reported in {formatUpdateIntervalThreshold()}+
|
||||
</h3>
|
||||
<p className="text-sm text-danger-700 mt-1">
|
||||
These hosts may be offline or experiencing connectivity issues.
|
||||
@@ -235,7 +286,7 @@ const Dashboard = () => {
|
||||
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.
|
||||
No hosts have failed to report in the last {formatUpdateIntervalThreshold()}.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@@ -246,7 +297,10 @@ const Dashboard = () => {
|
||||
|
||||
case 'osDistribution':
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div
|
||||
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleOSDistributionClick}
|
||||
>
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">OS Distribution</h3>
|
||||
<div className="h-64">
|
||||
<Pie data={osChartData} options={chartOptions} />
|
||||
@@ -256,7 +310,10 @@ const Dashboard = () => {
|
||||
|
||||
case 'updateStatus':
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div
|
||||
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleUpdateStatusClick}
|
||||
>
|
||||
<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} />
|
||||
@@ -266,7 +323,10 @@ const Dashboard = () => {
|
||||
|
||||
case 'packagePriority':
|
||||
return (
|
||||
<div className="card p-6">
|
||||
<div
|
||||
className="card p-6 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handlePackagePriorityClick}
|
||||
>
|
||||
<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} />
|
||||
|
@@ -21,7 +21,9 @@ import {
|
||||
Code,
|
||||
EyeOff,
|
||||
ToggleLeft,
|
||||
ToggleRight
|
||||
ToggleRight,
|
||||
Edit,
|
||||
Check
|
||||
} from 'lucide-react'
|
||||
import { dashboardAPI, adminHostsAPI, settingsAPI, formatRelativeTime, formatDate } from '../utils/api'
|
||||
|
||||
@@ -31,6 +33,8 @@ const HostDetail = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [showCredentialsModal, setShowCredentialsModal] = useState(false)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [isEditingHostname, setIsEditingHostname] = useState(false)
|
||||
const [editedHostname, setEditedHostname] = useState('')
|
||||
|
||||
const { data: host, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['host', hostId],
|
||||
@@ -39,6 +43,13 @@ const HostDetail = () => {
|
||||
staleTime: 30000,
|
||||
})
|
||||
|
||||
// Auto-show credentials modal for new/pending hosts
|
||||
React.useEffect(() => {
|
||||
if (host && host.status === 'pending') {
|
||||
setShowCredentialsModal(true)
|
||||
}
|
||||
}, [host])
|
||||
|
||||
const deleteHostMutation = useMutation({
|
||||
mutationFn: (hostId) => adminHostsAPI.delete(hostId),
|
||||
onSuccess: () => {
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import { Link, useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Server,
|
||||
AlertTriangle,
|
||||
@@ -88,67 +88,105 @@ const AddHostModal = ({ isOpen, onClose, onSuccess }) => {
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-secondary-900">Add New Host</h3>
|
||||
<button onClick={onClose} className="text-secondary-400 hover:text-secondary-600">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white">Add New Host</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>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700">Hostname *</label>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">Hostname *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.hostname}
|
||||
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
|
||||
className="mt-1 block w-full border-secondary-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
className="block w-full px-3 py-2.5 text-base border-2 border-secondary-300 dark:border-secondary-600 rounded-lg shadow-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white transition-all duration-200"
|
||||
placeholder="server.example.com"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-secondary-500">
|
||||
<p className="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
System information (OS, IP, architecture) will be automatically detected when the agent connects.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700">Host Group</label>
|
||||
<select
|
||||
value={formData.hostGroupId}
|
||||
onChange={(e) => setFormData({ ...formData, hostGroupId: e.target.value })}
|
||||
className="mt-1 block w-full border-secondary-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
<option value="">No group (ungrouped)</option>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-3">Host Group</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{/* No Group Option */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, hostGroupId: '' })}
|
||||
className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
|
||||
formData.hostGroupId === ''
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
|
||||
: 'border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500'
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs font-medium">No Group</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400 mt-1">Ungrouped</div>
|
||||
{formData.hostGroupId === '' && (
|
||||
<div className="absolute top-2 right-2 w-3 h-3 rounded-full bg-primary-500 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white"></div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Host Group Options */}
|
||||
{hostGroups?.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, hostGroupId: group.id })}
|
||||
className={`flex flex-col items-center justify-center px-2 py-3 text-center border-2 rounded-lg transition-all duration-200 relative min-h-[80px] ${
|
||||
formData.hostGroupId === group.id
|
||||
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
|
||||
: 'border-secondary-300 dark:border-secondary-600 bg-white dark:bg-secondary-700 text-secondary-700 dark:text-secondary-200 hover:border-secondary-400 dark:hover:border-secondary-500'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 mb-1 w-full justify-center">
|
||||
{group.color && (
|
||||
<div
|
||||
className="w-3 h-3 rounded-full border border-secondary-300 dark:border-secondary-500 flex-shrink-0"
|
||||
style={{ backgroundColor: group.color }}
|
||||
></div>
|
||||
)}
|
||||
<div className="text-xs font-medium truncate max-w-full">{group.name}</div>
|
||||
</div>
|
||||
<div className="text-xs text-secondary-500 dark:text-secondary-400">Group</div>
|
||||
{formData.hostGroupId === group.id && (
|
||||
<div className="absolute top-2 right-2 w-3 h-3 rounded-full bg-primary-500 flex items-center justify-center">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-white"></div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-sm text-secondary-500">
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-secondary-500 dark:text-secondary-400">
|
||||
Optional: Assign this host to a group for better organization.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 border border-danger-200 rounded-md p-3">
|
||||
<p className="text-sm text-danger-700">{error}</p>
|
||||
<div 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">
|
||||
<div className="flex justify-end space-x-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-secondary-700 bg-white border border-secondary-300 rounded-md hover:bg-secondary-50"
|
||||
className="px-6 py-3 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg hover:bg-secondary-50 dark:hover:bg-secondary-600 transition-all duration-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
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"
|
||||
className="px-6 py-3 text-sm font-medium text-white bg-primary-600 border-2 border-transparent rounded-lg hover:bg-primary-700 disabled:opacity-50 transition-all duration-200"
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Host'}
|
||||
</button>
|
||||
@@ -564,6 +602,7 @@ const Hosts = () => {
|
||||
const [selectedHosts, setSelectedHosts] = useState([])
|
||||
const [showBulkAssignModal, setShowBulkAssignModal] = useState(false)
|
||||
const [searchParams] = useSearchParams()
|
||||
const navigate = useNavigate()
|
||||
|
||||
// Table state
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
@@ -575,15 +614,35 @@ const Hosts = () => {
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [groupBy, setGroupBy] = useState('none')
|
||||
const [showColumnSettings, setShowColumnSettings] = useState(false)
|
||||
const [hideStale, setHideStale] = useState(false)
|
||||
|
||||
// Handle URL filter parameters
|
||||
useEffect(() => {
|
||||
const filter = searchParams.get('filter')
|
||||
if (filter === 'needsUpdates') {
|
||||
setShowFilters(true)
|
||||
setStatusFilter('all')
|
||||
// We'll filter hosts with updates > 0 in the filtering logic
|
||||
} else if (filter === 'inactive') {
|
||||
setShowFilters(true)
|
||||
setStatusFilter('inactive')
|
||||
// We'll filter hosts with inactive status in the filtering logic
|
||||
} else if (filter === 'upToDate') {
|
||||
setShowFilters(true)
|
||||
setStatusFilter('active')
|
||||
// We'll filter hosts that are up to date in the filtering logic
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// Handle add host action from navigation
|
||||
const action = searchParams.get('action')
|
||||
if (action === 'add') {
|
||||
setShowAddModal(true)
|
||||
// Remove the action parameter from URL without triggering a page reload
|
||||
const newSearchParams = new URLSearchParams(searchParams)
|
||||
newSearchParams.delete('action')
|
||||
navigate(`/hosts${newSearchParams.toString() ? `?${newSearchParams.toString()}` : ''}`, { replace: true })
|
||||
}
|
||||
}, [searchParams, navigate])
|
||||
|
||||
// Column configuration
|
||||
const [columnConfig, setColumnConfig] = useState(() => {
|
||||
@@ -726,16 +785,22 @@ const Hosts = () => {
|
||||
(groupFilter !== 'ungrouped' && host.hostGroup?.id === groupFilter)
|
||||
|
||||
// Status filter
|
||||
const matchesStatus = statusFilter === 'all' || host.status === statusFilter
|
||||
const matchesStatus = statusFilter === 'all' || (host.effectiveStatus || host.status) === statusFilter
|
||||
|
||||
// OS filter
|
||||
const matchesOs = osFilter === 'all' || host.osType?.toLowerCase() === osFilter.toLowerCase()
|
||||
|
||||
// URL filter for hosts needing updates
|
||||
// URL filter for hosts needing updates, inactive hosts, or up-to-date hosts
|
||||
const filter = searchParams.get('filter')
|
||||
const matchesUrlFilter = filter !== 'needsUpdates' || (host.updatesCount && host.updatesCount > 0)
|
||||
const matchesUrlFilter =
|
||||
(filter !== 'needsUpdates' || (host.updatesCount && host.updatesCount > 0)) &&
|
||||
(filter !== 'inactive' || (host.effectiveStatus || host.status) === 'inactive') &&
|
||||
(filter !== 'upToDate' || (!host.isStale && host.updatesCount === 0))
|
||||
|
||||
return matchesSearch && matchesGroup && matchesStatus && matchesOs && matchesUrlFilter
|
||||
// Hide stale filter
|
||||
const matchesHideStale = !hideStale || !host.isStale
|
||||
|
||||
return matchesSearch && matchesGroup && matchesStatus && matchesOs && matchesUrlFilter && matchesHideStale
|
||||
})
|
||||
|
||||
// Sorting
|
||||
@@ -768,8 +833,8 @@ const Hosts = () => {
|
||||
bValue = b.agentVersion?.toLowerCase() || 'zzz_no_version'
|
||||
break
|
||||
case 'status':
|
||||
aValue = a.status
|
||||
bValue = b.status
|
||||
aValue = a.effectiveStatus || a.status
|
||||
bValue = b.effectiveStatus || b.status
|
||||
break
|
||||
case 'updates':
|
||||
aValue = a.updatesCount || 0
|
||||
@@ -806,7 +871,7 @@ const Hosts = () => {
|
||||
groupKey = host.hostGroup?.name || 'Ungrouped'
|
||||
break
|
||||
case 'status':
|
||||
groupKey = host.status.charAt(0).toUpperCase() + host.status.slice(1)
|
||||
groupKey = (host.effectiveStatus || host.status).charAt(0).toUpperCase() + (host.effectiveStatus || host.status).slice(1)
|
||||
break
|
||||
case 'os':
|
||||
groupKey = host.osType || 'Unknown'
|
||||
@@ -957,7 +1022,7 @@ const Hosts = () => {
|
||||
case 'status':
|
||||
return (
|
||||
<div className="text-sm text-secondary-900 dark:text-white">
|
||||
{host.status.charAt(0).toUpperCase() + host.status.slice(1)}
|
||||
{(host.effectiveStatus || host.status).charAt(0).toUpperCase() + (host.effectiveStatus || host.status).slice(1)}
|
||||
</div>
|
||||
)
|
||||
case 'updates':
|
||||
@@ -989,7 +1054,50 @@ const Hosts = () => {
|
||||
|
||||
const handleHostCreated = (newHost) => {
|
||||
queryClient.invalidateQueries(['hosts'])
|
||||
// Host created successfully - user can view details for setup instructions
|
||||
// Navigate to host detail page to show credentials and setup instructions
|
||||
navigate(`/hosts/${newHost.hostId}`)
|
||||
}
|
||||
|
||||
// Stats card click handlers
|
||||
const handleTotalHostsClick = () => {
|
||||
// Clear all filters to show all hosts
|
||||
setSearchTerm('')
|
||||
setGroupFilter('all')
|
||||
setStatusFilter('all')
|
||||
setOsFilter('all')
|
||||
setGroupBy('none')
|
||||
setHideStale(false)
|
||||
setShowFilters(false)
|
||||
}
|
||||
|
||||
const handleUpToDateClick = () => {
|
||||
// Filter to show only up-to-date hosts
|
||||
setStatusFilter('active')
|
||||
setShowFilters(true)
|
||||
// Use the upToDate URL filter
|
||||
const newSearchParams = new URLSearchParams(window.location.search)
|
||||
newSearchParams.set('filter', 'upToDate')
|
||||
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true })
|
||||
}
|
||||
|
||||
const handleNeedsUpdatesClick = () => {
|
||||
// Filter to show hosts needing updates (regardless of status)
|
||||
setStatusFilter('all')
|
||||
setShowFilters(true)
|
||||
// We'll use the existing needsUpdates URL filter logic
|
||||
const newSearchParams = new URLSearchParams(window.location.search)
|
||||
newSearchParams.set('filter', 'needsUpdates')
|
||||
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true })
|
||||
}
|
||||
|
||||
const handleStaleClick = () => {
|
||||
// Filter to show stale/inactive hosts
|
||||
setStatusFilter('inactive')
|
||||
setShowFilters(true)
|
||||
// We'll use the existing inactive URL filter logic
|
||||
const newSearchParams = new URLSearchParams(window.location.search)
|
||||
newSearchParams.set('filter', 'inactive')
|
||||
navigate(`/hosts?${newSearchParams.toString()}`, { replace: true })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
@@ -1045,7 +1153,10 @@ const Hosts = () => {
|
||||
|
||||
{/* Stats Summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4">
|
||||
<div className="card p-4">
|
||||
<div
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleTotalHostsClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Server className="h-5 w-5 text-primary-600 mr-2" />
|
||||
<div>
|
||||
@@ -1054,7 +1165,10 @@ const Hosts = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleUpToDateClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<CheckCircle className="h-5 w-5 text-success-600 mr-2" />
|
||||
<div>
|
||||
@@ -1065,18 +1179,24 @@ const Hosts = () => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleNeedsUpdatesClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Clock className="h-5 w-5 text-warning-600 mr-2" />
|
||||
<div>
|
||||
<p className="text-sm text-secondary-500 dark:text-white">Needs Updates</p>
|
||||
<p className="text-xl font-semibold text-secondary-900 dark:text-white">
|
||||
{hosts?.filter(h => !h.isStale && h.updatesCount > 0).length || 0}
|
||||
{hosts?.filter(h => h.updatesCount > 0).length || 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-4">
|
||||
<div
|
||||
className="card p-4 cursor-pointer hover:shadow-card-hover dark:hover:shadow-card-hover-dark transition-shadow duration-200"
|
||||
onClick={handleStaleClick}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="h-5 w-5 text-danger-600 mr-2" />
|
||||
<div>
|
||||
@@ -1150,15 +1270,22 @@ const Hosts = () => {
|
||||
<select
|
||||
value={groupBy}
|
||||
onChange={(e) => setGroupBy(e.target.value)}
|
||||
className="appearance-none bg-white dark:bg-secondary-800 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg px-3 py-2 pr-8 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-secondary-900 dark:text-white hover:border-secondary-400 dark:hover:border-secondary-500 transition-colors"
|
||||
className="appearance-none bg-white dark:bg-secondary-800 border-2 border-secondary-300 dark:border-secondary-600 rounded-lg px-2 py-2 pr-6 text-sm focus:ring-2 focus:ring-primary-500 focus:border-primary-500 text-secondary-900 dark:text-white hover:border-secondary-400 dark:hover:border-secondary-500 transition-colors min-w-[120px]"
|
||||
>
|
||||
<option value="none">No Grouping</option>
|
||||
<option value="group">Group by Host Group</option>
|
||||
<option value="status">Group by Status</option>
|
||||
<option value="os">Group by OS</option>
|
||||
<option value="group">By Group</option>
|
||||
<option value="status">By Status</option>
|
||||
<option value="os">By OS</option>
|
||||
</select>
|
||||
<ChevronDown className="absolute right-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500 pointer-events-none" />
|
||||
<ChevronDown className="absolute right-1 top-1/2 transform -translate-y-1/2 h-4 w-4 text-secondary-400 dark:text-secondary-500 pointer-events-none" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setHideStale(!hideStale)}
|
||||
className={`btn-outline flex items-center gap-2 ${hideStale ? 'bg-primary-50 border-primary-300' : ''}`}
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
Hide Stale
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
@@ -1222,6 +1349,7 @@ const Hosts = () => {
|
||||
setStatusFilter('all')
|
||||
setOsFilter('all')
|
||||
setGroupBy('none')
|
||||
setHideStale(false)
|
||||
}}
|
||||
className="btn-outline w-full"
|
||||
>
|
||||
@@ -1356,15 +1484,28 @@ const Hosts = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-secondary-800 divide-y divide-secondary-200 dark:divide-secondary-600">
|
||||
{groupHosts.map((host) => (
|
||||
<tr key={host.id} className={`hover:bg-secondary-50 dark:hover:bg-secondary-700 ${selectedHosts.includes(host.id) ? 'bg-primary-50 dark:bg-primary-600' : ''}`}>
|
||||
{visibleColumns.map((column) => (
|
||||
<td key={column.id} className="px-4 py-2 whitespace-nowrap text-center">
|
||||
{renderCellContent(column, host)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
{groupHosts.map((host) => {
|
||||
const isInactive = (host.effectiveStatus || host.status) === 'inactive'
|
||||
const isSelected = selectedHosts.includes(host.id)
|
||||
|
||||
let rowClasses = 'hover:bg-secondary-50 dark:hover:bg-secondary-700'
|
||||
|
||||
if (isSelected) {
|
||||
rowClasses += ' bg-primary-50 dark:bg-primary-600'
|
||||
} else if (isInactive) {
|
||||
rowClasses += ' bg-red-50 dark:bg-red-900/20'
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={host.id} className={rowClasses}>
|
||||
{visibleColumns.map((column) => (
|
||||
<td key={column.id} className="px-4 py-2 whitespace-nowrap text-center">
|
||||
{renderCellContent(column, host)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
@@ -10,7 +10,8 @@ const Settings = () => {
|
||||
serverPort: 3001,
|
||||
frontendUrl: 'http://localhost:3000',
|
||||
updateInterval: 60,
|
||||
autoUpdate: false
|
||||
autoUpdate: false,
|
||||
githubRepoUrl: 'git@github.com:9technologygroup/patchmon.net.git'
|
||||
});
|
||||
const [errors, setErrors] = useState({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
@@ -22,7 +23,8 @@ const Settings = () => {
|
||||
const tabs = [
|
||||
{ id: 'server', name: 'Server Configuration', icon: Server },
|
||||
{ id: 'frontend', name: 'Frontend Configuration', icon: Globe },
|
||||
{ id: 'agent', name: 'Agent Management', icon: SettingsIcon }
|
||||
{ id: 'agent', name: 'Agent Management', icon: SettingsIcon },
|
||||
{ id: 'version', name: 'Server Version', icon: Code }
|
||||
];
|
||||
|
||||
// Agent version management state
|
||||
@@ -54,7 +56,8 @@ const Settings = () => {
|
||||
serverPort: settings.serverPort || 3001,
|
||||
frontendUrl: settings.frontendUrl || 'http://localhost:3000',
|
||||
updateInterval: settings.updateInterval || 60,
|
||||
autoUpdate: settings.autoUpdate || false
|
||||
autoUpdate: settings.autoUpdate || false,
|
||||
githubRepoUrl: settings.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'
|
||||
};
|
||||
console.log('Setting form data to:', newFormData);
|
||||
setFormData(newFormData);
|
||||
@@ -653,6 +656,100 @@ const Settings = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Server Version Tab */}
|
||||
{activeTab === 'version' && (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center mb-6">
|
||||
<Code className="h-6 w-6 text-primary-600 mr-3" />
|
||||
<h2 className="text-xl font-semibold text-secondary-900 dark:text-white">Server Version Management</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-secondary-50 dark:bg-secondary-700 rounded-lg p-6">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">Version Check Configuration</h3>
|
||||
<p className="text-sm text-secondary-600 dark:text-secondary-300 mb-6">
|
||||
Configure automatic version checking against your GitHub repository to notify users of available updates.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-2">
|
||||
GitHub Repository URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.githubRepoUrl || 'git@github.com:9technologygroup/patchmon.net.git'}
|
||||
onChange={(e) => handleInputChange('githubRepoUrl', e.target.value)}
|
||||
className="w-full border border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white font-mono text-sm"
|
||||
placeholder="git@github.com:username/repository.git"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-secondary-500 dark:text-secondary-400">
|
||||
SSH or HTTPS URL to your GitHub repository
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Current Version</span>
|
||||
</div>
|
||||
<span className="text-lg font-mono text-secondary-900 dark:text-white">1.2.3</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-4 border border-secondary-200 dark:border-secondary-600">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Download className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
<span className="text-sm font-medium text-secondary-700 dark:text-secondary-300">Latest Version</span>
|
||||
</div>
|
||||
<span className="text-lg font-mono text-secondary-900 dark:text-white">Checking...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement version check
|
||||
console.log('Checking for updates...');
|
||||
}}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Check for Updates
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Implement update notification
|
||||
console.log('Enable update notifications');
|
||||
}}
|
||||
className="btn-outline flex items-center gap-2"
|
||||
>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Enable Notifications
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-5 w-5 text-amber-400 dark:text-amber-300" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-amber-800 dark:text-amber-200">Setup Instructions</h3>
|
||||
<div className="mt-2 text-sm text-amber-700 dark:text-amber-300">
|
||||
<p className="mb-2">To enable version checking, you need to:</p>
|
||||
<ol className="list-decimal list-inside space-y-1 ml-4">
|
||||
<li>Create a version tag (e.g., v1.2.3) in your GitHub repository</li>
|
||||
<li>Ensure the repository is publicly accessible or configure access tokens</li>
|
||||
<li>Click "Check for Updates" to verify the connection</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@@ -1,12 +1,13 @@
|
||||
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 { Plus, Trash2, Edit, User, Mail, Shield, Calendar, CheckCircle, XCircle, Key } 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 [resetPasswordUser, setResetPasswordUser] = useState(null)
|
||||
const queryClient = useQueryClient()
|
||||
const { user: currentUser } = useAuth()
|
||||
|
||||
@@ -39,6 +40,15 @@ const Users = () => {
|
||||
}
|
||||
})
|
||||
|
||||
// Reset password mutation
|
||||
const resetPasswordMutation = useMutation({
|
||||
mutationFn: ({ userId, newPassword }) => adminUsersAPI.resetPassword(userId, newPassword),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['users'])
|
||||
setResetPasswordUser(null)
|
||||
}
|
||||
})
|
||||
|
||||
const handleDeleteUser = async (userId, username) => {
|
||||
if (window.confirm(`Are you sure you want to delete user "${username}"? This action cannot be undone.`)) {
|
||||
try {
|
||||
@@ -58,6 +68,10 @@ const Users = () => {
|
||||
setEditingUser(user)
|
||||
}
|
||||
|
||||
const handleResetPassword = (user) => {
|
||||
setResetPasswordUser(user)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
@@ -156,6 +170,18 @@ const Users = () => {
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleResetPassword(user)}
|
||||
className="text-blue-400 hover:text-blue-600 dark:text-blue-500 dark:hover:text-blue-300 disabled:text-gray-300 disabled:cursor-not-allowed"
|
||||
title={
|
||||
!user.isActive
|
||||
? "Cannot reset password for inactive user"
|
||||
: "Reset password"
|
||||
}
|
||||
disabled={!user.isActive}
|
||||
>
|
||||
<Key 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"
|
||||
@@ -209,6 +235,17 @@ const Users = () => {
|
||||
roles={roles}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reset Password Modal */}
|
||||
{resetPasswordUser && (
|
||||
<ResetPasswordModal
|
||||
user={resetPasswordUser}
|
||||
isOpen={!!resetPasswordUser}
|
||||
onClose={() => setResetPasswordUser(null)}
|
||||
onPasswordReset={resetPasswordMutation.mutate}
|
||||
isLoading={resetPasswordMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -487,4 +524,126 @@ const EditUserModal = ({ user, isOpen, onClose, onUserUpdated, roles }) => {
|
||||
)
|
||||
}
|
||||
|
||||
// Reset Password Modal Component
|
||||
const ResetPasswordModal = ({ user, isOpen, onClose, onPasswordReset, isLoading }) => {
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
// Validate passwords
|
||||
if (newPassword.length < 6) {
|
||||
setError('Password must be at least 6 characters long')
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError('Passwords do not match')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await onPasswordReset({ userId: user.id, newPassword })
|
||||
// Reset form on success
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.error || 'Failed to reset password')
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setNewPassword('')
|
||||
setConfirmPassword('')
|
||||
setError('')
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-white dark:bg-secondary-800 rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-medium text-secondary-900 dark:text-white mb-4">
|
||||
Reset Password for {user.username}
|
||||
</h3>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
New Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={6}
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
placeholder="Enter new password (min 6 characters)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 dark:text-secondary-200 mb-1">
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="block w-full border-secondary-300 dark:border-secondary-600 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 bg-white dark:bg-secondary-700 text-secondary-900 dark:text-white"
|
||||
placeholder="Confirm new password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900 border border-yellow-200 dark:border-yellow-700 rounded-md p-3">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<Key className="h-5 w-5 text-yellow-400" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-yellow-800 dark:text-yellow-200">
|
||||
Password Reset Warning
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-yellow-700 dark:text-yellow-300">
|
||||
<p>This will immediately change the user's password. The user will need to use the new password to login.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-danger-50 dark:bg-danger-900 border border-danger-200 dark:border-danger-700 rounded-md p-3">
|
||||
<p className="text-sm text-danger-700 dark:text-danger-300">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-sm font-medium text-secondary-700 dark:text-secondary-200 bg-white dark:bg-secondary-700 border border-secondary-300 dark:border-secondary-600 rounded-md hover:bg-secondary-50 dark:hover:bg-secondary-600"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 disabled:opacity-50 flex items-center"
|
||||
>
|
||||
{isLoading && <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>}
|
||||
{isLoading ? 'Resetting...' : 'Reset Password'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Users
|
||||
|
@@ -73,7 +73,8 @@ 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}`)
|
||||
delete: (userId) => api.delete(`/auth/admin/users/${userId}`),
|
||||
resetPassword: (userId, newPassword) => api.post(`/auth/admin/users/${userId}/reset-password`, { newPassword })
|
||||
}
|
||||
|
||||
// Permissions API (for role management)
|
||||
|
1591
manage-patchmon.sh
Executable file
1591
manage-patchmon.sh
Executable file
File diff suppressed because it is too large
Load Diff
@@ -70,9 +70,8 @@ async function setupAdminUser() {
|
||||
data: {
|
||||
username: username.trim(),
|
||||
email: email.trim(),
|
||||
passwordHash,
|
||||
role: 'admin',
|
||||
isActive: true
|
||||
passwordHash: passwordHash,
|
||||
role: 'admin'
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -92,9 +91,10 @@ async function setupAdminUser() {
|
||||
|
||||
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');
|
||||
console.log('1. The backend server is already running as a systemd service');
|
||||
console.log('2. The frontend is already built and served by Nginx');
|
||||
console.log('3. Visit https://' + process.env.FQDN + ' and login with your credentials');
|
||||
console.log('4. Use the management script: ./manage.sh {status|restart|logs|update|backup|credentials|reset-admin}');
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error setting up admin user:', error);
|
||||
|
Reference in New Issue
Block a user