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>
This commit is contained in:
zaphod-black 2025-11-02 23:34:36 -06:00
parent 0746428435
commit efba1948f6
2 changed files with 312 additions and 2 deletions

View file

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

View file

@ -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 -->
<div class="box">
<div class="box-title">► ACTIONS</div>
<button class="button" onclick="openSettings()">⚙ Settings</button>
<button class="button" onclick="runBackup()">▶ Run Backup Now</button>
<button class="button" onclick="refreshStatus()">↻ Refresh Status</button>
<button class="button" onclick="downloadEncryptionKey()">🔑 Download Encryption Key</button>
@ -406,6 +514,70 @@
</div>
</div>
<!-- Settings Modal -->
<div id="settings-modal" class="modal">
<div class="modal-content">
<button class="close-button" onclick="closeSettings()">&times;</button>
<div class="modal-title">⚙ Configuration</div>
<form id="config-form" onsubmit="saveSettings(event)">
<div class="form-group">
<label class="form-label">PBS Repository</label>
<input type="text" id="pbs_repository" class="form-input"
placeholder="user@pam!token@host:port:datastore" required>
<div class="form-help">Example: backup@pam!mytoken@192.168.1.100:8007:backups</div>
</div>
<div class="form-group">
<label class="form-label">PBS Password/Token Secret</label>
<input type="password" id="pbs_password" class="form-input"
placeholder="API token secret or password" required>
<div class="form-help">API token secret (recommended) or user password</div>
</div>
<div class="form-group">
<label class="form-label">Backup Hostname</label>
<input type="text" id="backup_hostname" class="form-input"
placeholder="my-server">
<div class="form-help">Hostname for backups (default: container hostname)</div>
</div>
<div class="form-group">
<label class="form-label">Backup Paths</label>
<input type="text" id="backup_paths" class="form-input"
placeholder="/host-data">
<div class="form-help">Space-separated paths to backup</div>
</div>
<div class="form-group">
<label class="form-label">Exclude Patterns</label>
<textarea id="exclude_patterns" class="form-textarea"
placeholder="/host-data/tmp&#10;/host-data/*.log"></textarea>
<div class="form-help">One pattern per line (optional)</div>
</div>
<div class="form-group">
<label class="form-label">Backup Schedule (Cron)</label>
<input type="text" id="backup_schedule" class="form-input"
placeholder="0 2 * * *">
<div class="form-help">Cron expression (e.g., "0 2 * * *" for 2 AM daily)</div>
</div>
<div class="form-group">
<label class="form-label">Timezone</label>
<input type="text" id="timezone" class="form-input"
placeholder="UTC">
<div class="form-help">e.g., America/New_York, Europe/London</div>
</div>
<div class="modal-buttons">
<button type="submit" class="button">💾 Save Configuration</button>
<button type="button" class="button" onclick="closeSettings()">✖ Cancel</button>
</div>
</form>
</div>
</div>
<script>
let backupRunning = false;
let logsVisible = false;
@ -587,6 +759,65 @@
alert('To download encryption key:\n\ndocker cp pbs-backup-client:/config/encryption-key.json ./\n\nRun this command in your terminal.');
}
// Settings modal functions
function openSettings() {
fetch('/config')
.then(r => r.json())
.then(config => {
document.getElementById('pbs_repository').value = config.pbs_repository || '';
document.getElementById('pbs_password').value = config.pbs_password || '';
document.getElementById('backup_hostname').value = config.backup_hostname || '';
document.getElementById('backup_paths').value = config.backup_paths || '/host-data';
document.getElementById('exclude_patterns').value = (config.exclude_patterns || '').replace(/ /g, '\n');
document.getElementById('backup_schedule').value = config.backup_schedule || '0 2 * * *';
document.getElementById('timezone').value = config.timezone || 'UTC';
document.getElementById('settings-modal').classList.add('active');
})
.catch(err => {
console.error('Failed to load config:', err);
alert('Failed to load configuration');
});
}
function closeSettings() {
document.getElementById('settings-modal').classList.remove('active');
}
function saveSettings(event) {
event.preventDefault();
const config = {
pbs_repository: document.getElementById('pbs_repository').value,
pbs_password: document.getElementById('pbs_password').value,
backup_hostname: document.getElementById('backup_hostname').value,
backup_paths: document.getElementById('backup_paths').value,
exclude_patterns: document.getElementById('exclude_patterns').value.split('\n').join(' ').trim(),
backup_schedule: document.getElementById('backup_schedule').value,
timezone: document.getElementById('timezone').value
};
fetch('/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
})
.then(r => r.json())
.then(result => {
if (result.status === 'success') {
alert('✓ Configuration saved successfully!\n\nNote: Restart container for cron schedule changes to take effect.');
closeSettings();
refreshStatus();
} else {
alert('Failed to save configuration: ' + (result.error || 'Unknown error'));
}
})
.catch(err => {
console.error('Failed to save config:', err);
alert('Failed to save configuration: ' + err.message);
});
}
// Helper functions
function escapeHtml(text) {
const div = document.createElement('div');