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:
parent
0746428435
commit
efba1948f6
2 changed files with 312 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()">×</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 /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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue