ProxmoxBackupClientPBSClien.../docker/scripts/api-server.py
zaphod-black efba1948f6 Add web-based configuration interface to Docker dashboard
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>
2025-11-02 23:34:36 -06:00

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())