Add status page quick actions

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 18:40:22 -04:00
co-authored by Copilot
parent ef38ec92b4
commit 8907c51016
10 changed files with 202 additions and 2 deletions
+3
View File
@@ -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, 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 GPS thread alive with live fix, SIM868 responding on UART, OBD adapter present, and the local
services reporting expected active/inactive states. 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.
--- ---
+7
View File
@@ -306,6 +306,13 @@ def health():
def api_status(): def api_status():
return jsonify(app_status.collect_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') @app.route('/api/alerts/status')
def alert_status(): def alert_status():
return jsonify(app_alerts.get_status()) return jsonify(app_alerts.get_status())
+3
View File
@@ -7,6 +7,7 @@ set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SERVICE_FILE="$REPO_DIR/deploy/yaris-obd.service" SERVICE_FILE="$REPO_DIR/deploy/yaris-obd.service"
SERVICE_NAME="yaris-obd.service" SERVICE_NAME="yaris-obd.service"
STATUS_SUDOERS_FILE="$REPO_DIR/deploy/yaris-status-actions.sudoers"
if [ ! -d "$REPO_DIR/venv" ]; then if [ ! -d "$REPO_DIR/venv" ]; then
echo "No venv found at $REPO_DIR/venv - creating one and installing requirements..." echo "No venv found at $REPO_DIR/venv - creating one and installing requirements..."
@@ -16,6 +17,8 @@ if [ ! -d "$REPO_DIR/venv" ]; then
fi fi
sudo cp "$SERVICE_FILE" "/etc/systemd/system/$SERVICE_NAME" 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 daemon-reload
sudo systemctl enable "$SERVICE_NAME" sudo systemctl enable "$SERVICE_NAME"
sudo systemctl restart "$SERVICE_NAME" sudo systemctl restart "$SERVICE_NAME"
+4
View File
@@ -235,6 +235,10 @@ cd /home/yaris/app/yaris-obd
git pull --ff-only git pull --ff-only
bash deploy/install_service.sh bash deploy/install_service.sh
sudo systemctl restart yaris-obd.service 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.). If deploy files changed, rerun the corresponding installer script(s) (`install_netwatch.sh`, `install_port_redirect.sh`, etc.).
+1
View File
@@ -0,0 +1 @@
yaris ALL=(root) NOPASSWD: /usr/bin/systemctl restart --no-block yaris-obd.service
+48
View File
@@ -1,6 +1,7 @@
import os import os
import subprocess import subprocess
import time import time
from threading import Thread
from urllib.parse import urlparse from urllib.parse import urlparse
import requests import requests
@@ -22,6 +23,14 @@ SERVICE_UNITS = (
("Hotspot watcher", "yaris-netwatch.timer"), ("Hotspot watcher", "yaris-netwatch.timer"),
("Phone uplink wait", "yaris-uplink-wait.service"), ("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): 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): def _systemd_unit_status(label, unit):
try: try:
result = subprocess.run( result = subprocess.run(
+2
View File
@@ -67,6 +67,8 @@ Dashboard — mobile (390 px) and desktop (1280 px):
data attributes. Accessible at `https://your-cloudflare-tunnel-url/` data attributes. Accessible at `https://your-cloudflare-tunnel-url/`
- 📡 **Optional cellular fallback** through the SIM868 for small trip-log uploads when normal - 📡 **Optional cellular fallback** through the SIM868 for small trip-log uploads when normal
WiFi/Ethernet is unavailable (dashcam video stays off cellular) 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 - 🗄️ **Motion-aware local-first SQLite logging** — five-second motion checks without five-second
parked writes, plus retry-safe remote sync and automatic backoff parked writes, plus retry-safe remote sync and automatic backoff
- 🌡️ **Pi health monitoring** — dashboard temperature, RAM, disk space, uptime, and explicit - 🌡️ **Pi health monitoring** — dashboard temperature, RAM, disk space, uptime, and explicit
+95
View File
@@ -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) { function formatUptime(seconds) {
if (seconds == null) return 'Unavailable'; if (seconds == null) return 'Unavailable';
const days = Math.floor(seconds / 86400); const days = Math.floor(seconds / 86400);
@@ -292,6 +313,80 @@ function showStatusError(error) {
banner.hidden = false; 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); refreshStatus().catch(showStatusError);
window.setInterval(function () { window.setInterval(function () {
refreshStatus().catch(showStatusError); refreshStatus().catch(showStatusError);
+7
View File
@@ -5,10 +5,17 @@
{% block content %} {% block content %}
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3"> <div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-3">
<h3 class="text-dark fw-bold m-0">Status</h3> <h3 class="text-dark fw-bold m-0">Status</h3>
<div class="d-flex flex-wrap align-items-center gap-2 justify-content-end">
<button id="status-action-test-server" class="btn btn-outline-primary btn-sm" type="button">Test server now</button>
<button id="status-action-sync-now" class="btn btn-outline-primary btn-sm" type="button">Sync now</button>
<button id="status-action-reconnect-uplink" class="btn btn-outline-secondary btn-sm" type="button">Reconnect uplink</button>
<button id="status-action-restart-app" class="btn btn-outline-danger btn-sm" type="button">Restart app service</button>
<span id="status-updated" class="small text-muted">Loading…</span> <span id="status-updated" class="small text-muted">Loading…</span>
</div> </div>
</div>
<div id="status-error" class="alert alert-danger py-2" role="alert" hidden></div> <div id="status-error" class="alert alert-danger py-2" role="alert" hidden></div>
<div id="status-action-message" class="alert py-2" role="alert" hidden></div>
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-xl-4"> <div class="col-xl-4">
+31 -1
View File
@@ -1,5 +1,6 @@
import unittest import unittest
from unittest.mock import patch import subprocess
from unittest.mock import MagicMock, patch
from modules import app_status from modules import app_status
@@ -104,6 +105,35 @@ class AppStatusTest(unittest.TestCase):
self.assertFalse(status["uplink"]["ok"]) self.assertFalse(status["uplink"]["ok"])
self.assertIn("nmcli failed", status["uplink"]["error"]) 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__": if __name__ == "__main__":
unittest.main() unittest.main()