diff --git a/PLAN.md b/PLAN.md index da3412f..cfc70e8 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1776,6 +1776,9 @@ updates its display-facing values without a manual restart once sync lands. page/API served live values: server reachable+authenticated, uplink on Wi-Fi with internet, GPS thread alive with live fix, SIM868 responding on UART, OBD adapter present, and the local services reporting expected active/inactive states. + - **Next status-page polish 2026-08-10**: add quick action buttons directly on `/status` for + server test, sync-now, uplink reconnect, and app-service restart so the Pi can be diagnosed and + nudged from one screen instead of bouncing back to Settings/SSH. --- diff --git a/app.py b/app.py index 0932b8f..55a8e9c 100644 --- a/app.py +++ b/app.py @@ -306,6 +306,13 @@ def health(): def api_status(): return jsonify(app_status.collect_status()) +@app.route('/api/status/restart-app', methods=['POST']) +def api_status_restart_app(): + try: + return jsonify(app_status.request_app_restart()), 202 + except RuntimeError as error: + return jsonify(error=str(error)), 503 + @app.route('/api/alerts/status') def alert_status(): return jsonify(app_alerts.get_status()) diff --git a/deploy/install_service.sh b/deploy/install_service.sh index 770635e..42b6369 100755 --- a/deploy/install_service.sh +++ b/deploy/install_service.sh @@ -7,6 +7,7 @@ set -euo pipefail REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SERVICE_FILE="$REPO_DIR/deploy/yaris-obd.service" SERVICE_NAME="yaris-obd.service" +STATUS_SUDOERS_FILE="$REPO_DIR/deploy/yaris-status-actions.sudoers" if [ ! -d "$REPO_DIR/venv" ]; then echo "No venv found at $REPO_DIR/venv - creating one and installing requirements..." @@ -16,6 +17,8 @@ if [ ! -d "$REPO_DIR/venv" ]; then fi sudo cp "$SERVICE_FILE" "/etc/systemd/system/$SERVICE_NAME" +sudo install -m 440 "$STATUS_SUDOERS_FILE" /etc/sudoers.d/yaris-status-actions +sudo visudo -cf /etc/sudoers.d/yaris-status-actions sudo systemctl daemon-reload sudo systemctl enable "$SERVICE_NAME" sudo systemctl restart "$SERVICE_NAME" diff --git a/deploy/readme.md b/deploy/readme.md index eec7454..f36480e 100644 --- a/deploy/readme.md +++ b/deploy/readme.md @@ -235,6 +235,10 @@ cd /home/yaris/app/yaris-obd git pull --ff-only bash deploy/install_service.sh sudo systemctl restart yaris-obd.service + +# Status page quick actions (restart app service) rely on the sudoers file +# installed by install_service.sh. Re-run it after pulling if that action says +# permission is not installed yet. ``` If deploy files changed, rerun the corresponding installer script(s) (`install_netwatch.sh`, `install_port_redirect.sh`, etc.). diff --git a/deploy/yaris-status-actions.sudoers b/deploy/yaris-status-actions.sudoers new file mode 100644 index 0000000..204869e --- /dev/null +++ b/deploy/yaris-status-actions.sudoers @@ -0,0 +1 @@ +yaris ALL=(root) NOPASSWD: /usr/bin/systemctl restart --no-block yaris-obd.service diff --git a/modules/app_status.py b/modules/app_status.py index 6d9cbcf..24178b3 100644 --- a/modules/app_status.py +++ b/modules/app_status.py @@ -1,6 +1,7 @@ import os import subprocess import time +from threading import Thread from urllib.parse import urlparse import requests @@ -22,6 +23,14 @@ SERVICE_UNITS = ( ("Hotspot watcher", "yaris-netwatch.timer"), ("Phone uplink wait", "yaris-uplink-wait.service"), ) +RESTART_COMMAND = [ + "sudo", + "-n", + "/usr/bin/systemctl", + "restart", + "--no-block", + "yaris-obd.service", +] def parse_systemctl_show(text): @@ -41,6 +50,45 @@ def parse_systemctl_show(text): } +def _run_restart_command(): + subprocess.run( + RESTART_COMMAND, + capture_output=True, + text=True, + timeout=10, + check=True, + ) + + +def _delayed_restart(delay_seconds): + time.sleep(delay_seconds) + try: + _run_restart_command() + except (OSError, subprocess.SubprocessError) as error: + print(f"Could not restart yaris-obd.service from status page: {error}") + + +def request_app_restart(): + try: + subprocess.run( + ["sudo", "-n", "-l", *RESTART_COMMAND[2:]], + capture_output=True, + text=True, + timeout=5, + check=True, + ) + except (OSError, subprocess.SubprocessError) as error: + raise RuntimeError( + "Restart permission is not installed on this Pi yet. " + "Run deploy/install_service.sh once to install the status-action sudoers file." + ) from error + Thread(target=_delayed_restart, args=(1,), daemon=True).start() + return { + "status": "restart_scheduled", + "message": "App restart requested. The page may disconnect for a few seconds.", + } + + def _systemd_unit_status(label, unit): try: result = subprocess.run( diff --git a/readme.md b/readme.md index d3795fd..c4a9f84 100644 --- a/readme.md +++ b/readme.md @@ -67,6 +67,8 @@ Dashboard — mobile (390 px) and desktop (1280 px): data attributes. Accessible at `https://your-cloudflare-tunnel-url/` - 📡 **Optional cellular fallback** through the SIM868 for small trip-log uploads when normal WiFi/Ethernet is unavailable (dashcam video stays off cellular) +- 🩺 **Dedicated local status page** with live Pi health, service states, uplink/server/GPS/OBD + diagnostics, plus quick actions for server test, sync-now, uplink reconnect, and app restart - 🗄️ **Motion-aware local-first SQLite logging** — five-second motion checks without five-second parked writes, plus retry-safe remote sync and automatic backoff - 🌡️ **Pi health monitoring** — dashboard temperature, RAM, disk space, uptime, and explicit diff --git a/static/js/status.js b/static/js/status.js index f4408b3..6aaa2e9 100644 --- a/static/js/status.js +++ b/static/js/status.js @@ -12,6 +12,27 @@ function setText(id, value) { } } +function showActionMessage(text, kind) { + const banner = document.getElementById('status-action-message'); + banner.textContent = text; + banner.className = `alert alert-${kind} py-2`; + banner.hidden = false; +} + +function setActionButtonsDisabled(disabled) { + [ + 'status-action-test-server', + 'status-action-sync-now', + 'status-action-reconnect-uplink', + 'status-action-restart-app', + ].forEach((id) => { + const button = document.getElementById(id); + if (button) { + button.disabled = disabled; + } + }); +} + function formatUptime(seconds) { if (seconds == null) return 'Unavailable'; const days = Math.floor(seconds / 86400); @@ -292,6 +313,80 @@ function showStatusError(error) { banner.hidden = false; } +async function runStatusAction(buttonId, requestFactory, successMessage, options = {}) { + const button = document.getElementById(buttonId); + const actionBanner = document.getElementById('status-action-message'); + actionBanner.hidden = true; + button.disabled = true; + try { + const response = await requestFactory(); + const result = await response.json(); + if (!response.ok) { + throw new Error(result.error || result.message || `HTTP ${response.status}`); + } + showActionMessage(result.message || successMessage, options.successKind || 'success'); + if (options.disableAllOnSuccess) { + setActionButtonsDisabled(true); + } + if (options.refreshAfter !== false) { + await refreshStatus(); + } + } catch (error) { + showActionMessage(error.message, 'danger'); + } finally { + if (!options.keepDisabled) { + button.disabled = false; + } + } +} + +document.getElementById('status-action-test-server').addEventListener('click', function () { + runStatusAction( + 'status-action-test-server', + function () { + return fetch('/api/remote-config/test', { method: 'POST' }); + }, + 'Remote server connection looks good.' + ); +}); + +document.getElementById('status-action-sync-now').addEventListener('click', function () { + runStatusAction( + 'status-action-sync-now', + function () { + return fetch('/api/remote-config/sync-now', { method: 'POST' }); + }, + 'Sync completed.' + ); +}); + +document.getElementById('status-action-reconnect-uplink').addEventListener('click', function () { + runStatusAction( + 'status-action-reconnect-uplink', + function () { + return fetch('/api/uplink/connect', { method: 'POST' }); + }, + 'Waiting for the saved hotspot. This page may disconnect while networks switch.', + { successKind: 'info' } + ); +}); + +document.getElementById('status-action-restart-app').addEventListener('click', function () { + runStatusAction( + 'status-action-restart-app', + function () { + return fetch('/api/status/restart-app', { method: 'POST' }); + }, + 'App restart requested. The page may disconnect for a few seconds.', + { + successKind: 'warning', + refreshAfter: false, + keepDisabled: true, + disableAllOnSuccess: true, + } + ); +}); + refreshStatus().catch(showStatusError); window.setInterval(function () { refreshStatus().catch(showStatusError); diff --git a/templates/status.html b/templates/status.html index 78dcef7..9a62278 100644 --- a/templates/status.html +++ b/templates/status.html @@ -5,10 +5,17 @@ {% block content %}

Status

- Loading… +
+ + + + + Loading… +
+
diff --git a/tests/test_app_status.py b/tests/test_app_status.py index b2e705f..c66783e 100644 --- a/tests/test_app_status.py +++ b/tests/test_app_status.py @@ -1,5 +1,6 @@ import unittest -from unittest.mock import patch +import subprocess +from unittest.mock import MagicMock, patch from modules import app_status @@ -104,6 +105,35 @@ class AppStatusTest(unittest.TestCase): self.assertFalse(status["uplink"]["ok"]) self.assertIn("nmcli failed", status["uplink"]["error"]) + @patch("modules.app_status.Thread") + @patch("modules.app_status.subprocess.run") + def test_request_app_restart_schedules_background_restart(self, run, thread): + run.return_value = MagicMock(returncode=0, stdout="", stderr="") + thread_instance = MagicMock() + thread.return_value = thread_instance + + result = app_status.request_app_restart() + + self.assertEqual(result["status"], "restart_scheduled") + run.assert_called_once_with( + ["sudo", "-n", "-l", "/usr/bin/systemctl", "restart", "--no-block", "yaris-obd.service"], + capture_output=True, + text=True, + timeout=5, + check=True, + ) + thread.assert_called_once_with( + target=app_status._delayed_restart, + args=(1,), + daemon=True, + ) + thread_instance.start.assert_called_once_with() + + @patch("modules.app_status.subprocess.run", side_effect=subprocess.CalledProcessError(1, "sudo")) + def test_request_app_restart_requires_installed_permission(self, _run): + with self.assertRaises(RuntimeError): + app_status.request_app_restart() + if __name__ == "__main__": unittest.main()