mirror of
				https://github.com/9technologygroup/patchmon.net.git
				synced 2025-11-04 05:53:27 +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