Fix Docker web dashboard with Python HTTP server + blue/purple neon theme

PROBLEM SOLVED:
The netcat-based HTTP server had a fundamental bidirectional pipe issue:
- `( read method path ) | nc -l` only connects stdout→stdin in one direction
- The `read` command couldn't receive HTTP request data from nc
- All requests returned "Not found" despite correct routing logic

SOLUTION:
- Created api-server.py: Proper Python HTTP server using http.server
- Handles dashboard (GET /), status, health, logs, and backup trigger (POST)
- Full request/response handling with proper HTTP headers
- Replaces broken netcat approach with robust solution

DASHBOARD ENHANCEMENTS:
- Changed from green terminal theme to blue/purple neon aesthetic
  - Primary: #00d4ff (cyan blue)
  - Accent: #aa00ff (purple)
  - Background: #0a0a14 (dark blue-black)
- Increased all font sizes 2-4px for better readability
- Enhanced button padding and spacing
- More visible, high-contrast interface

TECHNICAL DETAILS:
- Dockerfile: Replaced netcat-openbsd with python3 dependency
- api-server.sh: Now simple wrapper that exec's Python script
- api-server.py: Full-featured HTTP server with JSON/HTML responses
- dashboard.html: Updated color scheme and typography

TESTING:
 Dashboard loads at http://localhost:8080/
 /status endpoint returns JSON
 /health endpoint working
 /logs endpoint working
 Real-time backup monitoring ready

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
zaphod-black 2025-11-02 23:25:10 -06:00
parent ab1bc800af
commit 0746428435
5 changed files with 327 additions and 170 deletions

View file

@ -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:**

View file

@ -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

View file

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

View file

@ -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

View file

@ -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;
}
}
</style>
</head>