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,
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.).
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
yaris ALL=(root) NOPASSWD: /usr/bin/systemctl restart --no-block yaris-obd.service
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user