diff --git a/README.md b/README.md index e0ee954..40bb75c 100644 --- a/README.md +++ b/README.md @@ -220,10 +220,18 @@ cd docker The Docker solution provides: - **Cross-platform** - Works on Windows, macOS, and Linux +- **Web Dashboard** - Real-time monitoring with retro terminal UI (http://localhost:8080) - **REST API** - Remote management and monitoring - **File-level backups** - Daily automated backups - **Easy deployment** - Single container, simple configuration +**Web Dashboard Features:** +- Live backup status and progress +- System health monitoring +- Backup history and logs +- Manual backup trigger +- Retro blue/purple neon aesthetic + See [docker/README-DOCKER.md](docker/README-DOCKER.md) for complete documentation. **Platform Support Matrix:** diff --git a/docker/Dockerfile b/docker/Dockerfile index b5674dc..a605938 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -16,7 +16,7 @@ RUN apt-get update && \ cron \ curl \ jq \ - netcat-openbsd \ + python3 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* @@ -38,12 +38,14 @@ COPY scripts/entrypoint.sh /usr/local/bin/ COPY scripts/backup.sh /usr/local/bin/pbs-backup COPY scripts/healthcheck.sh /usr/local/bin/healthcheck COPY scripts/api-server.sh /usr/local/bin/api-server +COPY scripts/api-server.py /usr/local/bin/api-server.py COPY scripts/dashboard.html /usr/local/share/dashboard.html RUN chmod +x /usr/local/bin/entrypoint.sh \ /usr/local/bin/pbs-backup \ /usr/local/bin/healthcheck \ - /usr/local/bin/api-server + /usr/local/bin/api-server \ + /usr/local/bin/api-server.py # Expose API port (optional) EXPOSE 8080 diff --git a/docker/scripts/api-server.py b/docker/scripts/api-server.py new file mode 100644 index 0000000..0df8e25 --- /dev/null +++ b/docker/scripts/api-server.py @@ -0,0 +1,217 @@ +#!/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') +DASHBOARD_PATH = Path('/usr/local/share/dashboard.html') + +# 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 + + # 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)', + '/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/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 + + # Not found + self.send_json({ + 'error': 'Not found', + 'path': path, + 'available_endpoints': [ + '/backup - Trigger backup (POST)' + ] + }, status=404) + + 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()) diff --git a/docker/scripts/api-server.sh b/docker/scripts/api-server.sh index 2e97f7c..ee21b07 100755 --- a/docker/scripts/api-server.sh +++ b/docker/scripts/api-server.sh @@ -1,87 +1,8 @@ #!/bin/bash # Simple API server for PBS Client container management # Provides REST endpoints for status, manual backup, etc. +# +# Now uses Python's http.server for proper HTTP handling +# (netcat had bidirectional pipe issues preventing request parsing) -PORT=${API_PORT:-8080} - -log() { - echo "[API] [$(date +'%Y-%m-%d %H:%M:%S')] $1" -} - -# Simple HTTP server using nc -log "Starting API server on port $PORT" - -while true; do - ( - read -r method path protocol - - # Strip carriage returns and whitespace from path - path=$(echo "$path" | tr -d '\r' | xargs) - - # Debug logging - log "REQUEST: method=[$method] path=[$path] len=${#path}" - - # Read headers (discard) - while read -r line; do - [ -z "$line" ] || [ "$line" = $'\r' ] && break - done - - # Handle request directly (avoid subshell variable issues) - case "$path" in - /|/dashboard.html) - MIME_TYPE="text/html" - BODY=$(cat /usr/local/share/dashboard.html) - ;; - /status) - MIME_TYPE="application/json" - if [ -f /logs/status.json ]; then - BODY=$(cat /logs/status.json) - else - BODY='{"error":"No backup status available"}' - fi - ;; - /health) - MIME_TYPE="application/json" - if /usr/local/bin/healthcheck >/dev/null 2>&1; then - BODY='{"status":"healthy"}' - else - BODY='{"status":"unhealthy"}' - fi - ;; - /backup) - MIME_TYPE="application/json" - if [ "$method" = "POST" ]; then - BODY='{"status":"starting","message":"Backup triggered"}' - /usr/local/bin/pbs-backup & - else - BODY='{"error":"Use POST method to trigger backup"}' - fi - ;; - /logs) - MIME_TYPE="application/json" - if [ -f /logs/last-backup.log ]; then - BODY=$(tail -n 50 /logs/last-backup.log | jq -R -s -c 'split("\n") | {logs: .}') - else - BODY='{"logs":[]}' - fi - ;; - *) - MIME_TYPE="application/json" - BODY='{"error":"Not found","available_endpoints":["/","/status","/health","/backup","/logs"]}' - ;; - esac - - CONTENT_LENGTH=${#BODY} - - # Send HTTP response - cat << EOF -HTTP/1.1 200 OK -Content-Type: $MIME_TYPE -Content-Length: $CONTENT_LENGTH -Access-Control-Allow-Origin: * -Connection: close - -$BODY -EOF - ) | nc -l -p $PORT -q 1 -done +exec /usr/local/bin/api-server.py diff --git a/docker/scripts/dashboard.html b/docker/scripts/dashboard.html index ccc2a03..9a15dc0 100644 --- a/docker/scripts/dashboard.html +++ b/docker/scripts/dashboard.html @@ -12,11 +12,11 @@ } body { - background: #0a0a0a; - color: #00ff00; + background: #0a0a14; + color: #00d4ff; font-family: 'JetBrains Mono', 'Courier New', monospace; - font-size: 14px; - line-height: 1.4; + font-size: 16px; + line-height: 1.6; padding: 20px; overflow-x: hidden; } @@ -27,28 +27,29 @@ } .header { - color: #00ff41; - text-shadow: 0 0 10px #00ff41; - margin-bottom: 20px; + color: #aa00ff; + text-shadow: 0 0 15px #aa00ff; + margin-bottom: 25px; white-space: pre; - font-size: 10px; + font-size: 11px; line-height: 1; } .box { - border: 2px solid #00ff41; - padding: 15px; - margin-bottom: 15px; - background: #0f0f0f; - box-shadow: 0 0 20px rgba(0, 255, 65, 0.1); + border: 2px solid #aa00ff; + padding: 20px; + margin-bottom: 20px; + background: #0f0f1a; + box-shadow: 0 0 25px rgba(170, 0, 255, 0.2); } .box-title { - color: #ffaa00; - margin-bottom: 10px; + color: #00d4ff; + margin-bottom: 15px; font-weight: bold; text-transform: uppercase; - letter-spacing: 2px; + letter-spacing: 3px; + font-size: 18px; } .status-grid { @@ -59,28 +60,28 @@ } .status-item { - padding: 8px; - background: #050505; - border-left: 3px solid #00ff41; + padding: 12px; + background: #05050a; + border-left: 4px solid #aa00ff; } .status-label { color: #888; - font-size: 11px; + font-size: 13px; text-transform: uppercase; letter-spacing: 1px; } .status-value { - color: #00ff41; - font-size: 16px; + color: #00d4ff; + font-size: 20px; font-weight: bold; - margin-top: 3px; + margin-top: 5px; } .status-value.error { - color: #ff3333; - text-shadow: 0 0 5px #ff3333; + color: #ff3366; + text-shadow: 0 0 8px #ff3366; } .status-value.warning { @@ -88,29 +89,31 @@ } .status-value.success { - color: #00ff41; - text-shadow: 0 0 5px #00ff41; + color: #00ff88; + text-shadow: 0 0 8px #00ff88; } .button { - background: #003300; - border: 2px solid #00ff41; - color: #00ff41; - padding: 10px 20px; + background: #1a0033; + border: 2px solid #aa00ff; + color: #aa00ff; + padding: 14px 28px; cursor: pointer; font-family: inherit; - font-size: 14px; + font-size: 15px; text-transform: uppercase; - letter-spacing: 1px; - transition: all 0.2s; - margin-right: 10px; - margin-bottom: 10px; + letter-spacing: 2px; + transition: all 0.3s; + margin-right: 12px; + margin-bottom: 12px; + font-weight: 600; } .button:hover { - background: #00ff41; - color: #0a0a0a; - box-shadow: 0 0 20px #00ff41; + background: #aa00ff; + color: #0a0a14; + box-shadow: 0 0 25px #aa00ff; + transform: translateY(-2px); } .button:active { @@ -124,21 +127,22 @@ .log-viewer { background: #000; - padding: 15px; - height: 300px; + padding: 18px; + height: 350px; overflow-y: auto; - font-size: 12px; - border: 1px solid #00ff41; - margin-top: 10px; + font-size: 14px; + border: 2px solid #aa00ff; + margin-top: 12px; + line-height: 1.6; } .log-line { - margin-bottom: 2px; - color: #00ff41; + margin-bottom: 4px; + color: #00d4ff; } .log-line.error { - color: #ff3333; + color: #ff3366; } .log-line.warning { @@ -155,22 +159,23 @@ } .backup-entry { - padding: 8px; - margin-bottom: 5px; - background: #050505; - border-left: 3px solid #00ff41; + padding: 12px; + margin-bottom: 8px; + background: #05050a; + border-left: 4px solid #00d4ff; display: flex; justify-content: space-between; align-items: center; + font-size: 15px; } .backup-entry.failed { - border-left-color: #ff3333; + border-left-color: #ff3366; } .backup-time { color: #888; - font-size: 12px; + font-size: 14px; } .backup-status { @@ -178,29 +183,29 @@ } .backup-status.success { - color: #00ff41; + color: #00ff88; } .backup-status.failed { - color: #ff3333; + color: #ff3366; } .progress-bar { width: 100%; - height: 20px; - background: #050505; - border: 1px solid #00ff41; - margin-top: 10px; + height: 26px; + background: #05050a; + border: 2px solid #aa00ff; + margin-top: 12px; position: relative; overflow: hidden; } .progress-fill { height: 100%; - background: linear-gradient(90deg, #003300, #00ff41); + background: linear-gradient(90deg, #1a0033, #aa00ff, #00d4ff); width: 0%; transition: width 0.5s; - box-shadow: 0 0 10px #00ff41; + box-shadow: 0 0 15px #aa00ff; } .progress-text { @@ -208,10 +213,10 @@ top: 50%; left: 50%; transform: translate(-50%, -50%); - color: #00ff41; - font-size: 11px; + color: #00d4ff; + font-size: 13px; font-weight: bold; - text-shadow: 0 0 5px #000; + text-shadow: 0 0 8px #000; } .blink { @@ -241,65 +246,69 @@ } .ascii-border { - color: #00ff41; + color: #aa00ff; white-space: pre; - font-size: 10px; + font-size: 11px; line-height: 1; - margin: 10px 0; + margin: 12px 0; } ::-webkit-scrollbar { - width: 10px; + width: 12px; } ::-webkit-scrollbar-track { - background: #0a0a0a; + background: #0a0a14; } ::-webkit-scrollbar-thumb { - background: #00ff41; - border: 1px solid #0a0a0a; + background: #aa00ff; + border: 2px solid #0a0a14; } ::-webkit-scrollbar-thumb:hover { - background: #00ff88; + background: #00d4ff; } .metric { display: inline-block; - padding: 5px 10px; - background: #050505; - border: 1px solid #00ff41; - margin-right: 10px; - margin-bottom: 5px; + padding: 10px 16px; + background: #05050a; + border: 2px solid #aa00ff; + margin-right: 12px; + margin-bottom: 10px; } .metric-label { color: #888; - font-size: 10px; + font-size: 12px; } .metric-value { - color: #00ff41; + color: #00d4ff; font-weight: bold; - font-size: 14px; + font-size: 17px; } .timestamp { - color: #555; - font-size: 11px; + color: #666; + font-size: 14px; text-align: right; - margin-top: 20px; + margin-top: 25px; } @media (max-width: 768px) { body { - font-size: 12px; - padding: 10px; + font-size: 14px; + padding: 12px; } .status-grid { grid-template-columns: 1fr; } + .button { + font-size: 14px; + padding: 12px 20px; + } }