From efba1948f63581f773224581f97f8dd5e38d6055 Mon Sep 17 00:00:00 2001 From: zaphod-black Date: Sun, 2 Nov 2025 23:34:36 -0600 Subject: [PATCH] Add web-based configuration interface to Docker dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docker/scripts/api-server.py | 83 +++++++++++- docker/scripts/dashboard.html | 231 ++++++++++++++++++++++++++++++++++ 2 files changed, 312 insertions(+), 2 deletions(-) diff --git a/docker/scripts/api-server.py b/docker/scripts/api-server.py index 0df8e25..2448406 100644 --- a/docker/scripts/api-server.py +++ b/docker/scripts/api-server.py @@ -15,7 +15,9 @@ 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( @@ -125,6 +127,12 @@ class PBSAPIHandler(BaseHTTPRequestHandler): 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', @@ -134,6 +142,7 @@ class PBSAPIHandler(BaseHTTPRequestHandler): '/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) @@ -156,7 +165,7 @@ class PBSAPIHandler(BaseHTTPRequestHandler): params = {} # Trigger backup script - backup_script = '/usr/local/bin/backup' + 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 &') @@ -172,15 +181,85 @@ class PBSAPIHandler(BaseHTTPRequestHandler): }, 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)' + '/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: diff --git a/docker/scripts/dashboard.html b/docker/scripts/dashboard.html index 9a15dc0..3413e82 100644 --- a/docker/scripts/dashboard.html +++ b/docker/scripts/dashboard.html @@ -125,6 +125,113 @@ cursor: not-allowed; } + /* Modal styles */ + .modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background: rgba(10, 10, 20, 0.95); + animation: fadeIn 0.3s; + } + + .modal.active { + display: flex; + align-items: center; + justify-content: center; + } + + .modal-content { + background: #0f0f1a; + border: 3px solid #aa00ff; + padding: 30px; + max-width: 600px; + width: 90%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 0 50px rgba(170, 0, 255, 0.5); + position: relative; + } + + .modal-title { + color: #aa00ff; + font-size: 22px; + margin-bottom: 20px; + text-transform: uppercase; + letter-spacing: 3px; + } + + .form-group { + margin-bottom: 20px; + } + + .form-label { + display: block; + color: #00d4ff; + font-size: 14px; + margin-bottom: 8px; + text-transform: uppercase; + letter-spacing: 1px; + } + + .form-input, .form-textarea { + width: 100%; + background: #05050a; + border: 2px solid #aa00ff; + color: #00d4ff; + padding: 12px; + font-family: 'JetBrains Mono', 'Courier New', monospace; + font-size: 15px; + box-sizing: border-box; + } + + .form-input:focus, .form-textarea:focus { + outline: none; + border-color: #00d4ff; + box-shadow: 0 0 15px rgba(0, 212, 255, 0.3); + } + + .form-textarea { + min-height: 80px; + resize: vertical; + } + + .form-help { + color: #888; + font-size: 12px; + margin-top: 5px; + } + + .modal-buttons { + display: flex; + gap: 15px; + margin-top: 25px; + } + + .close-button { + position: absolute; + top: 15px; + right: 20px; + background: none; + border: none; + color: #ff3366; + font-size: 30px; + cursor: pointer; + line-height: 1; + } + + .close-button:hover { + color: #ff6699; + } + + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + .log-viewer { background: #000; padding: 18px; @@ -369,6 +476,7 @@
► ACTIONS
+ @@ -406,6 +514,70 @@
+ + +