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,
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.
---
+7
View File
@@ -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())
+3
View File
@@ -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"
+4
View File
@@ -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.).
+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 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(
+2
View File
@@ -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
+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) {
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);
+8 -1
View File
@@ -5,10 +5,17 @@
{% block content %}
<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>
<span id="status-updated" class="small text-muted">Loading…</span>
<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>
</div>
</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="col-xl-4">
+31 -1
View File
@@ -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()