From df852a1a807fef38a07ee765baad1b554454e431 Mon Sep 17 00:00:00 2001 From: MarekWo Date: Wed, 21 Jan 2026 16:58:05 +0100 Subject: [PATCH] feat: Add remote update capability from web GUI Adds webhook-based update system that allows triggering updates directly from the mc-webui menu. Includes: - Webhook server (updater.py) on port 5050 - Systemd service and install script - API proxy endpoints for container-to-host communication - Update modal with progress tracking and auto-reload Co-Authored-By: Claude Opus 4.5 --- README.md | 37 ++++ app/routes/api.py | 125 +++++++++++++ app/static/js/app.js | 143 +++++++++++++- app/templates/base.html | 35 ++++ scripts/updater/install.sh | 137 ++++++++++++++ scripts/updater/mc-webui-updater.service | 21 +++ scripts/updater/updater.py | 228 +++++++++++++++++++++++ 7 files changed, 724 insertions(+), 2 deletions(-) create mode 100755 scripts/updater/install.sh create mode 100644 scripts/updater/mc-webui-updater.service create mode 100755 scripts/updater/updater.py diff --git a/README.md b/README.md index cf9fbbe..4156ece 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,43 @@ git checkout main ./scripts/update.sh ``` +### Remote updates from web GUI (optional) + +You can enable one-click updates directly from the mc-webui menu. This requires installing a small webhook service on the host machine. + +**Install the updater service:** + +```bash +cd ~/mc-webui +sudo ./scripts/updater/install.sh +``` + +The installer will: +- Create a systemd service `mc-webui-updater` +- Start a webhook server on port 5050 (localhost only) +- Enable automatic startup on boot + +**Usage:** +1. Click the refresh button (↻) next to the version in the menu +2. If an update is available, an "Update" button appears +3. Click "Update" to trigger the update remotely +4. The app will automatically reload when the update completes + +**Useful commands:** + +```bash +# Check service status +systemctl status mc-webui-updater + +# View logs +journalctl -u mc-webui-updater -f + +# Uninstall +sudo ~/mc-webui/scripts/updater/install.sh --uninstall +``` + +**Security note:** The webhook listens only on localhost. The Docker container connects to it via the Docker bridge network. + --- ## Gallery diff --git a/app/routes/api.py b/app/routes/api.py index fb07835..f6f9f86 100644 --- a/app/routes/api.py +++ b/app/routes/api.py @@ -2125,6 +2125,131 @@ def check_update(): }), 500 +# ============================================================================= +# Remote Update (via host webhook) +# ============================================================================= + +# Updater webhook URL - tries multiple addresses for Docker compatibility +UPDATER_URLS = [ + 'http://host.docker.internal:5050', # Docker Desktop (Mac/Windows) + 'http://172.17.0.1:5050', # Docker default bridge gateway (Linux) + 'http://127.0.0.1:5050', # Localhost fallback +] + + +def get_updater_url(): + """Find working updater webhook URL.""" + for url in UPDATER_URLS: + try: + response = requests.get(f"{url}/health", timeout=2) + if response.status_code == 200: + return url + except requests.RequestException: + continue + return None + + +@api_bp.route('/updater/status', methods=['GET']) +def updater_status(): + """ + Check if the update webhook is available on the host. + + Returns: + JSON with updater status: + { + "success": true, + "available": true, + "url": "http://172.17.0.1:5050", + "update_in_progress": false + } + """ + try: + url = get_updater_url() + + if not url: + return jsonify({ + 'success': True, + 'available': False, + 'message': 'Update webhook not installed or not running' + }), 200 + + # Get detailed status from webhook + response = requests.get(f"{url}/health", timeout=5) + data = response.json() + + return jsonify({ + 'success': True, + 'available': True, + 'url': url, + 'update_in_progress': data.get('update_in_progress', False), + 'mcwebui_dir': data.get('mcwebui_dir', '') + }), 200 + + except Exception as e: + logger.error(f"Error checking updater status: {e}") + return jsonify({ + 'success': False, + 'available': False, + 'error': str(e) + }), 200 + + +@api_bp.route('/updater/trigger', methods=['POST']) +def updater_trigger(): + """ + Trigger remote update via host webhook. + + This will: + 1. Call the webhook to start update.sh + 2. The server will restart (containers rebuilt) + 3. Frontend should poll /api/version to detect completion + + Returns: + JSON with result: + { + "success": true, + "message": "Update started" + } + """ + try: + url = get_updater_url() + + if not url: + return jsonify({ + 'success': False, + 'error': 'Update webhook not available. Install it first.' + }), 503 + + # Trigger update + response = requests.post(f"{url}/update", timeout=10) + data = response.json() + + if response.status_code == 200 and data.get('success'): + return jsonify({ + 'success': True, + 'message': 'Update started. Server will restart shortly.' + }), 200 + else: + return jsonify({ + 'success': False, + 'error': data.get('error', 'Unknown error') + }), response.status_code + + except requests.Timeout: + # Timeout might mean the update started and server is restarting + return jsonify({ + 'success': True, + 'message': 'Update may have started (request timed out)' + }), 200 + + except Exception as e: + logger.error(f"Error triggering update: {e}") + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + + @api_bp.route('/read_status/mark_read', methods=['POST']) def mark_read_api(): """ diff --git a/app/static/js/app.js b/app/static/js/app.js index ddab805..845d118 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1349,8 +1349,16 @@ async function checkForAppUpdates() { if (data.success) { if (data.update_available) { - // Update available - show green with link - versionText.innerHTML = `${data.current_version} Update available`; + // Check if remote update is available + const updaterStatus = await fetch('/api/updater/status').then(r => r.json()).catch(() => ({ available: false })); + + if (updaterStatus.available) { + // Show "Update Now" link that opens modal + versionText.innerHTML = `${data.current_version} Update now`; + } else { + // Show link to GitHub (no remote update available) + versionText.innerHTML = `${data.current_version} Update available`; + } icon.className = 'bi bi-check-circle-fill text-success'; showNotification(`Update available: ${data.latest_date}+${data.latest_commit}`, 'success'); } else { @@ -1382,6 +1390,137 @@ async function checkForAppUpdates() { } } +// Store update info for modal +let pendingUpdateVersion = null; + +/** + * Open update modal and prepare for remote update + */ +function openUpdateModal(newVersion) { + pendingUpdateVersion = newVersion; + + // Close offcanvas menu + const offcanvas = bootstrap.Offcanvas.getInstance(document.getElementById('mainMenu')); + if (offcanvas) offcanvas.hide(); + + // Reset modal state + document.getElementById('updateStatus').classList.remove('d-none'); + document.getElementById('updateProgress').classList.add('d-none'); + document.getElementById('updateResult').classList.add('d-none'); + document.getElementById('updateCancelBtn').classList.remove('d-none'); + document.getElementById('updateConfirmBtn').classList.remove('d-none'); + document.getElementById('updateReloadBtn').classList.add('d-none'); + document.getElementById('updateMessage').textContent = `New version available: ${newVersion}`; + + // Hide spinner, show message + document.querySelector('#updateStatus .spinner-border').classList.add('d-none'); + + // Setup confirm button + document.getElementById('updateConfirmBtn').onclick = performRemoteUpdate; + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('updateModal')); + modal.show(); +} + +/** + * Perform remote update via webhook + */ +async function performRemoteUpdate() { + const currentVersion = document.getElementById('versionText')?.textContent?.split(' ')[0] || ''; + + // Show progress state + document.getElementById('updateStatus').classList.add('d-none'); + document.getElementById('updateProgress').classList.remove('d-none'); + document.getElementById('updateCancelBtn').classList.add('d-none'); + document.getElementById('updateConfirmBtn').classList.add('d-none'); + document.getElementById('updateProgressMessage').textContent = 'Starting update...'; + + try { + // Trigger update + const response = await fetch('/api/updater/trigger', { method: 'POST' }); + const data = await response.json(); + + if (!data.success) { + showUpdateResult(false, data.error || 'Failed to start update'); + return; + } + + document.getElementById('updateProgressMessage').textContent = 'Update started. Waiting for server to restart...'; + + // Poll for server to come back up with new version + let attempts = 0; + const maxAttempts = 60; // 2 minutes max + const pollInterval = 2000; // 2 seconds + + const pollForCompletion = async () => { + attempts++; + + try { + const versionResponse = await fetch('/api/version', { + cache: 'no-store', + headers: { 'Cache-Control': 'no-cache' } + }); + + if (versionResponse.ok) { + const versionData = await versionResponse.json(); + const newVersion = versionData.version; + + // Check if version changed + if (newVersion !== currentVersion) { + showUpdateResult(true, `Updated to ${newVersion}`); + return; + } + } + } catch (e) { + // Server not responding yet - this is expected during restart + document.getElementById('updateProgressMessage').textContent = + `Rebuilding containers... (${attempts}/${maxAttempts})`; + } + + if (attempts < maxAttempts) { + setTimeout(pollForCompletion, pollInterval); + } else { + showUpdateResult(false, 'Update timed out. Please check server manually.'); + } + }; + + // Start polling after a short delay + setTimeout(pollForCompletion, 3000); + + } catch (error) { + console.error('Update error:', error); + showUpdateResult(false, 'Network error during update'); + } +} + +/** + * Show update result in modal + */ +function showUpdateResult(success, message) { + document.getElementById('updateProgress').classList.add('d-none'); + document.getElementById('updateResult').classList.remove('d-none'); + + const icon = document.getElementById('updateResultIcon'); + const msg = document.getElementById('updateResultMessage'); + + if (success) { + icon.className = 'bi bi-check-circle-fill text-success fs-1 mb-3 d-block'; + msg.className = 'mb-0 text-success'; + document.getElementById('updateReloadBtn').classList.remove('d-none'); + } else { + icon.className = 'bi bi-x-circle-fill text-danger fs-1 mb-3 d-block'; + msg.className = 'mb-0 text-danger'; + document.getElementById('updateCancelBtn').classList.remove('d-none'); + document.getElementById('updateCancelBtn').textContent = 'Close'; + } + + msg.textContent = message; +} + +// Make openUpdateModal globally accessible +window.openUpdateModal = openUpdateModal; + /** * Scroll to bottom of messages */ diff --git a/app/templates/base.html b/app/templates/base.html index e88a066..47d6f17 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -330,6 +330,41 @@ + + +