FEATURE: Settings page for configuring PBS backups via web UI PROBLEM: - Users had no way to configure PBS settings from the web dashboard - Required manually setting environment variables or restarting containers - Blank stats because no configuration existed SOLUTION: Added complete configuration management system: API ENDPOINTS: - GET /config - Retrieve current configuration - POST /config - Save new configuration to /config/settings.json DASHBOARD ENHANCEMENTS: - New "⚙ Settings" button in Actions section - Modal form with all configuration options: * PBS Repository (user@pam!token@host:port:datastore) * PBS Password/Token Secret * Backup Hostname * Backup Paths * Exclude Patterns (multi-line textarea) * Backup Schedule (cron expression) * Timezone - Form validation and user-friendly placeholders - Persistent storage to /config/settings.json - Auto-refresh status after saving DESIGN: - Retro terminal aesthetic matching dashboard theme - Blue/purple neon modal with dark background overlay - Smooth fade-in animation - Mobile-responsive form layout TECHNICAL: - Configuration persists across container restarts - Environment variables can be overridden by config file - JavaScript fetch API for async save/load - Proper error handling and user feedback Now users can configure everything from the web UI! 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
296 lines
10 KiB
Python
296 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Simple HTTP API server for PBS Client dashboard and monitoring
|
|
Unix philosophy: Simple, focused, does one thing well
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import json
|
|
import logging
|
|
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
# Configuration
|
|
PORT = int(os.getenv('API_PORT', '8080'))
|
|
LOG_DIR = Path('/logs')
|
|
CONFIG_DIR = Path('/config')
|
|
DASHBOARD_PATH = Path('/usr/local/share/dashboard.html')
|
|
CONFIG_FILE = CONFIG_DIR / 'settings.json'
|
|
|
|
# Setup logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format='[API] [%(asctime)s] %(message)s',
|
|
datefmt='%Y-%m-%d %H:%M:%S'
|
|
)
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class PBSAPIHandler(BaseHTTPRequestHandler):
|
|
"""Handler for PBS Client API requests"""
|
|
|
|
def log_message(self, format, *args):
|
|
"""Override to use our logging format"""
|
|
log.info(f"{self.command} {self.path} - {format % args}")
|
|
|
|
def send_json(self, data, status=200):
|
|
"""Send JSON response"""
|
|
json_data = json.dumps(data, indent=2)
|
|
self.send_response(status)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Content-Length', len(json_data))
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.send_header('Connection', 'close')
|
|
self.end_headers()
|
|
self.wfile.write(json_data.encode())
|
|
|
|
def send_html(self, html_content, status=200):
|
|
"""Send HTML response"""
|
|
self.send_response(status)
|
|
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
|
self.send_header('Content-Length', len(html_content))
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.send_header('Connection', 'close')
|
|
self.end_headers()
|
|
self.wfile.write(html_content.encode())
|
|
|
|
def send_text(self, text_content, status=200):
|
|
"""Send plain text response"""
|
|
self.send_response(status)
|
|
self.send_header('Content-Type', 'text/plain; charset=utf-8')
|
|
self.send_header('Content-Length', len(text_content))
|
|
self.send_header('Access-Control-Allow-Origin', '*')
|
|
self.send_header('Connection', 'close')
|
|
self.end_headers()
|
|
self.wfile.write(text_content.encode())
|
|
|
|
def do_GET(self):
|
|
"""Handle GET requests"""
|
|
path = self.path.split('?')[0] # Strip query params
|
|
|
|
# Dashboard (root or /dashboard.html)
|
|
if path in ('/', '/dashboard.html'):
|
|
if DASHBOARD_PATH.exists():
|
|
html = DASHBOARD_PATH.read_text()
|
|
self.send_html(html)
|
|
else:
|
|
self.send_json({
|
|
'error': 'Dashboard not found',
|
|
'path': str(DASHBOARD_PATH)
|
|
}, status=404)
|
|
return
|
|
|
|
# Status endpoint
|
|
if path == '/status':
|
|
status_file = LOG_DIR / 'status.json'
|
|
if status_file.exists():
|
|
try:
|
|
status_data = json.loads(status_file.read_text())
|
|
self.send_json(status_data)
|
|
except json.JSONDecodeError as e:
|
|
self.send_json({
|
|
'error': 'Invalid status file',
|
|
'details': str(e)
|
|
}, status=500)
|
|
else:
|
|
# Default status if file doesn't exist yet
|
|
self.send_json({
|
|
'status': 'idle',
|
|
'last_backup': 'never',
|
|
'last_result': 'unknown',
|
|
'next_scheduled': 'unknown',
|
|
'repository': os.getenv('PBS_REPOSITORY', 'not configured'),
|
|
'hostname': os.getenv('BACKUP_HOSTNAME', os.uname().nodename)
|
|
})
|
|
return
|
|
|
|
# Health check
|
|
if path == '/health':
|
|
self.send_json({
|
|
'status': 'healthy',
|
|
'uptime': self._get_uptime(),
|
|
'timestamp': datetime.now().isoformat()
|
|
})
|
|
return
|
|
|
|
# Logs endpoint
|
|
if path == '/logs':
|
|
log_file = LOG_DIR / 'backup.log'
|
|
if log_file.exists():
|
|
# Return last 100 lines
|
|
lines = log_file.read_text().splitlines()
|
|
recent_lines = lines[-100:] if len(lines) > 100 else lines
|
|
self.send_text('\n'.join(recent_lines))
|
|
else:
|
|
self.send_text('No logs available yet')
|
|
return
|
|
|
|
# Config endpoint
|
|
if path == '/config':
|
|
config = self._load_config()
|
|
self.send_json(config)
|
|
return
|
|
|
|
# Not found - show available endpoints
|
|
self.send_json({
|
|
'error': 'Not found',
|
|
'path': path,
|
|
'available_endpoints': [
|
|
'/ - Dashboard web UI',
|
|
'/status - Current backup status (JSON)',
|
|
'/health - Health check (JSON)',
|
|
'/logs - Recent backup logs (text)',
|
|
'/config - Get/Set configuration (GET/POST)',
|
|
'/backup - Trigger backup (POST)'
|
|
]
|
|
}, status=404)
|
|
|
|
def do_POST(self):
|
|
"""Handle POST requests"""
|
|
path = self.path.split('?')[0]
|
|
|
|
# Trigger backup
|
|
if path == '/backup':
|
|
# Read request body if present
|
|
content_length = int(self.headers.get('Content-Length', 0))
|
|
if content_length > 0:
|
|
body = self.rfile.read(content_length)
|
|
try:
|
|
params = json.loads(body)
|
|
except json.JSONDecodeError:
|
|
params = {}
|
|
else:
|
|
params = {}
|
|
|
|
# Trigger backup script
|
|
backup_script = '/usr/local/bin/pbs-backup'
|
|
if Path(backup_script).exists():
|
|
log.info('Triggering backup via API...')
|
|
os.system(f'{backup_script} > /logs/backup.log 2>&1 &')
|
|
self.send_json({
|
|
'status': 'triggered',
|
|
'message': 'Backup started in background',
|
|
'timestamp': datetime.now().isoformat()
|
|
})
|
|
else:
|
|
self.send_json({
|
|
'error': 'Backup script not found',
|
|
'path': backup_script
|
|
}, status=500)
|
|
return
|
|
|
|
# Save config
|
|
if path == '/config':
|
|
content_length = int(self.headers.get('Content-Length', 0))
|
|
if content_length > 0:
|
|
body = self.rfile.read(content_length)
|
|
try:
|
|
new_config = json.loads(body)
|
|
self._save_config(new_config)
|
|
self.send_json({
|
|
'status': 'success',
|
|
'message': 'Configuration saved',
|
|
'config': new_config
|
|
})
|
|
except json.JSONDecodeError as e:
|
|
self.send_json({
|
|
'error': 'Invalid JSON',
|
|
'details': str(e)
|
|
}, status=400)
|
|
except Exception as e:
|
|
self.send_json({
|
|
'error': 'Failed to save config',
|
|
'details': str(e)
|
|
}, status=500)
|
|
else:
|
|
self.send_json({
|
|
'error': 'No config data provided'
|
|
}, status=400)
|
|
return
|
|
|
|
# Not found
|
|
self.send_json({
|
|
'error': 'Not found',
|
|
'path': path,
|
|
'available_endpoints': [
|
|
'/backup - Trigger backup (POST)',
|
|
'/config - Save configuration (POST)'
|
|
]
|
|
}, status=404)
|
|
|
|
def _load_config(self):
|
|
"""Load configuration from file or environment"""
|
|
config = {
|
|
'pbs_repository': os.getenv('PBS_REPOSITORY', ''),
|
|
'pbs_password': os.getenv('PBS_PASSWORD', ''),
|
|
'backup_hostname': os.getenv('BACKUP_HOSTNAME', os.uname().nodename),
|
|
'backup_paths': os.getenv('BACKUP_PATHS', '/host-data'),
|
|
'exclude_patterns': os.getenv('EXCLUDE_PATTERNS', ''),
|
|
'backup_schedule': os.getenv('BACKUP_SCHEDULE', '0 2 * * *'),
|
|
'timezone': os.getenv('TIMEZONE', 'UTC')
|
|
}
|
|
|
|
# Override with file config if exists
|
|
if CONFIG_FILE.exists():
|
|
try:
|
|
file_config = json.loads(CONFIG_FILE.read_text())
|
|
config.update(file_config)
|
|
except:
|
|
pass
|
|
|
|
return config
|
|
|
|
def _save_config(self, config):
|
|
"""Save configuration to file"""
|
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
CONFIG_FILE.write_text(json.dumps(config, indent=2))
|
|
log.info('Configuration saved to /config/settings.json')
|
|
|
|
# Also update environment for current process
|
|
if 'pbs_repository' in config:
|
|
os.environ['PBS_REPOSITORY'] = config['pbs_repository']
|
|
if 'pbs_password' in config:
|
|
os.environ['PBS_PASSWORD'] = config['pbs_password']
|
|
if 'backup_hostname' in config:
|
|
os.environ['BACKUP_HOSTNAME'] = config['backup_hostname']
|
|
if 'backup_paths' in config:
|
|
os.environ['BACKUP_PATHS'] = config['backup_paths']
|
|
if 'exclude_patterns' in config:
|
|
os.environ['EXCLUDE_PATTERNS'] = config['exclude_patterns']
|
|
|
|
def _get_uptime(self):
|
|
"""Get container uptime"""
|
|
try:
|
|
with open('/proc/uptime', 'r') as f:
|
|
uptime_seconds = float(f.read().split()[0])
|
|
hours = int(uptime_seconds // 3600)
|
|
minutes = int((uptime_seconds % 3600) // 60)
|
|
return f'{hours}h {minutes}m'
|
|
except:
|
|
return 'unknown'
|
|
|
|
|
|
def main():
|
|
"""Start the HTTP server"""
|
|
server_address = ('', PORT)
|
|
httpd = HTTPServer(server_address, PBSAPIHandler)
|
|
|
|
log.info(f'Starting API server on port {PORT}')
|
|
log.info(f'Dashboard: http://localhost:{PORT}/')
|
|
log.info(f'API endpoints: /status /health /logs /backup')
|
|
|
|
try:
|
|
httpd.serve_forever()
|
|
except KeyboardInterrupt:
|
|
log.info('Shutting down API server')
|
|
httpd.shutdown()
|
|
return 0
|
|
except Exception as e:
|
|
log.error(f'Server error: {e}')
|
|
return 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|