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:
Muhammad Ibrahim
2025-09-17 22:07:30 +01:00
parent 9714be788b
commit f42c6cc185
16 changed files with 2442 additions and 617 deletions

3
.gitignore vendored
View File

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

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "settings" ADD COLUMN "github_repo_url" TEXT NOT NULL DEFAULT 'git@github.com:9technologygroup/patchmon.net.git';

View File

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

View File

@@ -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'),

View File

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

View File

@@ -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'
}
});
}

View File

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

View File

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

View File

@@ -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: () => {

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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