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 @@
+
+
+
+
+
+
+
+
+ Loading...
+
+
Checking for updates...
+
+
+
+
+
+
+
+
+
diff --git a/scripts/updater/install.sh b/scripts/updater/install.sh
new file mode 100755
index 0000000..28967c4
--- /dev/null
+++ b/scripts/updater/install.sh
@@ -0,0 +1,137 @@
+#!/bin/bash
+#
+# mc-webui Update Webhook Installer
+#
+# This script installs the update webhook service that allows
+# remote updates from the mc-webui GUI.
+#
+# Usage: sudo ./install.sh [--uninstall]
+#
+
+set -e
+
+# Colors
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m'
+
+info() { echo -e "${GREEN}[INFO]${NC} $1"; }
+warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
+error() { echo -e "${RED}[ERROR]${NC} $1"; exit 1; }
+
+# Check if running as root
+if [ "$EUID" -ne 0 ]; then
+ error "Please run as root: sudo $0"
+fi
+
+# Get the directory where this script is located
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+MCWEBUI_DIR="$(dirname "$(dirname "$SCRIPT_DIR")")"
+
+# Detect the user who owns mc-webui directory
+MCWEBUI_USER=$(stat -c '%U' "$MCWEBUI_DIR")
+MCWEBUI_GROUP=$(stat -c '%G' "$MCWEBUI_DIR")
+
+SERVICE_NAME="mc-webui-updater"
+SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
+
+# Uninstall
+if [ "$1" == "--uninstall" ]; then
+ info "Uninstalling ${SERVICE_NAME}..."
+
+ if systemctl is-active --quiet "$SERVICE_NAME"; then
+ systemctl stop "$SERVICE_NAME"
+ info "Service stopped"
+ fi
+
+ if systemctl is-enabled --quiet "$SERVICE_NAME" 2>/dev/null; then
+ systemctl disable "$SERVICE_NAME"
+ info "Service disabled"
+ fi
+
+ if [ -f "$SERVICE_FILE" ]; then
+ rm "$SERVICE_FILE"
+ systemctl daemon-reload
+ info "Service file removed"
+ fi
+
+ echo -e "${GREEN}Uninstallation complete!${NC}"
+ exit 0
+fi
+
+# Install
+info "Installing ${SERVICE_NAME}..."
+info " mc-webui directory: $MCWEBUI_DIR"
+info " mc-webui user: $MCWEBUI_USER"
+
+# Check if updater.py exists
+if [ ! -f "$SCRIPT_DIR/updater.py" ]; then
+ error "updater.py not found in $SCRIPT_DIR"
+fi
+
+# Check if update.sh exists
+if [ ! -f "$MCWEBUI_DIR/scripts/update.sh" ]; then
+ error "update.sh not found in $MCWEBUI_DIR/scripts/"
+fi
+
+# Create service file with correct paths
+info "Creating systemd service file..."
+cat > "$SERVICE_FILE" << EOF
+[Unit]
+Description=mc-webui Update Webhook Server
+Documentation=https://github.com/MarekWo/mc-webui
+After=network.target docker.service
+
+[Service]
+Type=simple
+User=root
+Environment=MCWEBUI_DIR=${MCWEBUI_DIR}
+Environment=UPDATER_TOKEN=
+ExecStart=/usr/bin/python3 ${SCRIPT_DIR}/updater.py
+Restart=always
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
+EOF
+
+info "Reloading systemd..."
+systemctl daemon-reload
+
+info "Enabling service..."
+systemctl enable "$SERVICE_NAME"
+
+info "Starting service..."
+systemctl start "$SERVICE_NAME"
+
+# Wait a moment for service to start
+sleep 2
+
+# Check if service is running
+if systemctl is-active --quiet "$SERVICE_NAME"; then
+ info "Service is running!"
+
+ # Test health endpoint
+ if command -v curl &> /dev/null; then
+ HEALTH=$(curl -s http://127.0.0.1:5050/health 2>/dev/null || echo "")
+ if echo "$HEALTH" | grep -q '"status":"ok"'; then
+ info "Health check passed!"
+ else
+ warn "Health check failed - service may still be starting"
+ fi
+ fi
+else
+ error "Service failed to start. Check: journalctl -u $SERVICE_NAME"
+fi
+
+echo ""
+echo -e "${GREEN}Installation complete!${NC}"
+echo ""
+echo "The update webhook is now running on port 5050."
+echo "You can now use the 'Update' button in mc-webui GUI."
+echo ""
+echo "Useful commands:"
+echo " systemctl status $SERVICE_NAME # Check status"
+echo " journalctl -u $SERVICE_NAME -f # View logs"
+echo " sudo $0 --uninstall # Uninstall"
diff --git a/scripts/updater/mc-webui-updater.service b/scripts/updater/mc-webui-updater.service
new file mode 100644
index 0000000..d8f6475
--- /dev/null
+++ b/scripts/updater/mc-webui-updater.service
@@ -0,0 +1,21 @@
+[Unit]
+Description=mc-webui Update Webhook Server
+Documentation=https://github.com/MarekWo/mc-webui
+After=network.target docker.service
+
+[Service]
+Type=simple
+User=root
+Environment=MCWEBUI_DIR=/home/marek/mc-webui
+Environment=UPDATER_TOKEN=
+ExecStart=/usr/bin/python3 /home/marek/mc-webui/scripts/updater/updater.py
+Restart=always
+RestartSec=5
+
+# Security hardening
+NoNewPrivileges=false
+ProtectSystem=false
+ProtectHome=false
+
+[Install]
+WantedBy=multi-user.target
diff --git a/scripts/updater/updater.py b/scripts/updater/updater.py
new file mode 100755
index 0000000..eff7b35
--- /dev/null
+++ b/scripts/updater/updater.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+"""
+mc-webui Update Webhook Server
+
+A simple HTTP server that listens for update requests and executes
+the update script. Designed to run as a systemd service on the host.
+
+Security:
+- Listens only on localhost (127.0.0.1)
+- Simple token-based authentication (optional)
+
+Endpoints:
+- GET /health - Check if webhook is running
+- POST /update - Trigger update (returns immediately, runs in background)
+- GET /status - Check if update is in progress
+"""
+
+import os
+import sys
+import json
+import subprocess
+import threading
+import time
+from http.server import HTTPServer, BaseHTTPRequestHandler
+from urllib.parse import urlparse, parse_qs
+
+# Configuration
+HOST = '0.0.0.0' # Listen on all interfaces (Docker needs this)
+PORT = 5050
+MCWEBUI_DIR = os.environ.get('MCWEBUI_DIR', os.path.expanduser('~/mc-webui'))
+UPDATE_SCRIPT = os.path.join(MCWEBUI_DIR, 'scripts', 'update.sh')
+AUTH_TOKEN = os.environ.get('UPDATER_TOKEN', '') # Optional token
+
+# Global state
+update_in_progress = False
+last_update_result = None
+last_update_time = None
+
+
+class UpdateHandler(BaseHTTPRequestHandler):
+ """HTTP request handler for update webhook."""
+
+ def log_message(self, format, *args):
+ """Override to use custom logging format."""
+ print(f"[{self.log_date_time_string()}] {args[0]}")
+
+ def send_json(self, data, status=200):
+ """Send JSON response."""
+ self.send_response(status)
+ self.send_header('Content-Type', 'application/json')
+ self.send_header('Access-Control-Allow-Origin', '*')
+ self.end_headers()
+ self.wfile.write(json.dumps(data).encode())
+
+ def check_auth(self):
+ """Check authorization token if configured."""
+ if not AUTH_TOKEN:
+ return True
+
+ auth_header = self.headers.get('Authorization', '')
+ if auth_header.startswith('Bearer '):
+ token = auth_header[7:]
+ return token == AUTH_TOKEN
+
+ # Also check query parameter
+ parsed = urlparse(self.path)
+ params = parse_qs(parsed.query)
+ token = params.get('token', [''])[0]
+ return token == AUTH_TOKEN
+
+ def do_OPTIONS(self):
+ """Handle CORS preflight."""
+ self.send_response(200)
+ self.send_header('Access-Control-Allow-Origin', '*')
+ self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
+ self.send_header('Access-Control-Allow-Headers', 'Authorization, Content-Type')
+ self.end_headers()
+
+ def do_GET(self):
+ """Handle GET requests."""
+ parsed = urlparse(self.path)
+ path = parsed.path
+
+ if path == '/health':
+ self.handle_health()
+ elif path == '/status':
+ self.handle_status()
+ else:
+ self.send_json({'error': 'Not found'}, 404)
+
+ def do_POST(self):
+ """Handle POST requests."""
+ parsed = urlparse(self.path)
+ path = parsed.path
+
+ if path == '/update':
+ self.handle_update()
+ else:
+ self.send_json({'error': 'Not found'}, 404)
+
+ def handle_health(self):
+ """Health check endpoint."""
+ self.send_json({
+ 'status': 'ok',
+ 'service': 'mc-webui-updater',
+ 'update_in_progress': update_in_progress,
+ 'mcwebui_dir': MCWEBUI_DIR
+ })
+
+ def handle_status(self):
+ """Get update status."""
+ self.send_json({
+ 'update_in_progress': update_in_progress,
+ 'last_update_result': last_update_result,
+ 'last_update_time': last_update_time
+ })
+
+ def handle_update(self):
+ """Trigger update."""
+ global update_in_progress
+
+ if not self.check_auth():
+ self.send_json({'error': 'Unauthorized'}, 401)
+ return
+
+ if update_in_progress:
+ self.send_json({
+ 'success': False,
+ 'error': 'Update already in progress'
+ }, 409)
+ return
+
+ if not os.path.exists(UPDATE_SCRIPT):
+ self.send_json({
+ 'success': False,
+ 'error': f'Update script not found: {UPDATE_SCRIPT}'
+ }, 500)
+ return
+
+ # Start update in background thread
+ update_in_progress = True
+ thread = threading.Thread(target=run_update, daemon=True)
+ thread.start()
+
+ self.send_json({
+ 'success': True,
+ 'message': 'Update started',
+ 'note': 'Server will restart. Poll /health to detect completion.'
+ })
+
+
+def run_update():
+ """Run update script in background."""
+ global update_in_progress, last_update_result, last_update_time
+
+ try:
+ print(f"[UPDATE] Starting update from {UPDATE_SCRIPT}")
+
+ # Run the update script
+ result = subprocess.run(
+ ['/bin/bash', UPDATE_SCRIPT],
+ cwd=MCWEBUI_DIR,
+ capture_output=True,
+ text=True,
+ timeout=300 # 5 minute timeout
+ )
+
+ last_update_result = {
+ 'success': result.returncode == 0,
+ 'returncode': result.returncode,
+ 'stdout': result.stdout[-2000:] if result.stdout else '', # Last 2000 chars
+ 'stderr': result.stderr[-500:] if result.stderr else ''
+ }
+ last_update_time = time.strftime('%Y-%m-%d %H:%M:%S')
+
+ if result.returncode == 0:
+ print(f"[UPDATE] Update completed successfully")
+ else:
+ print(f"[UPDATE] Update failed with code {result.returncode}")
+ print(f"[UPDATE] stderr: {result.stderr}")
+
+ except subprocess.TimeoutExpired:
+ last_update_result = {
+ 'success': False,
+ 'error': 'Update timed out after 5 minutes'
+ }
+ last_update_time = time.strftime('%Y-%m-%d %H:%M:%S')
+ print("[UPDATE] Update timed out")
+
+ except Exception as e:
+ last_update_result = {
+ 'success': False,
+ 'error': str(e)
+ }
+ last_update_time = time.strftime('%Y-%m-%d %H:%M:%S')
+ print(f"[UPDATE] Update error: {e}")
+
+ finally:
+ update_in_progress = False
+
+
+def main():
+ """Main entry point."""
+ print(f"mc-webui Update Webhook Server")
+ print(f" Listening on: {HOST}:{PORT}")
+ print(f" mc-webui dir: {MCWEBUI_DIR}")
+ print(f" Update script: {UPDATE_SCRIPT}")
+ print(f" Auth token: {'configured' if AUTH_TOKEN else 'disabled'}")
+ print()
+
+ if not os.path.exists(MCWEBUI_DIR):
+ print(f"WARNING: mc-webui directory not found: {MCWEBUI_DIR}")
+
+ if not os.path.exists(UPDATE_SCRIPT):
+ print(f"WARNING: Update script not found: {UPDATE_SCRIPT}")
+
+ server = HTTPServer((HOST, PORT), UpdateHandler)
+
+ try:
+ print(f"Server started. Press Ctrl+C to stop.")
+ server.serve_forever()
+ except KeyboardInterrupt:
+ print("\nShutting down...")
+ server.shutdown()
+
+
+if __name__ == '__main__':
+ main()