Add status page quick actions
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.).
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
yaris ALL=(root) NOPASSWD: /usr/bin/systemctl restart --no-block yaris-obd.service
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
<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">
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user