Add Raspberry Pi health monitoring
Co-authored-by: Copilot <[email protected]>
This commit is contained in:
@@ -981,13 +981,33 @@ high-water mark plus the latest trip rather than loading the unbounded full hist
|
|||||||
markers, DTCs, and video data untouched. The deployed motion-aware logger then proved two
|
markers, DTCs, and video data untouched. The deployed motion-aware logger then proved two
|
||||||
consecutive stationary heartbeats were 60.02 seconds apart rather than five seconds.
|
consecutive stationary heartbeats were 60.02 seconds apart rather than five seconds.
|
||||||
|
|
||||||
### Phase 19: Pi health monitoring card — 🚧 PLANNED
|
### Phase 19: Pi health monitoring card — ✅ DONE
|
||||||
**New phase, small and standalone - no dependencies.** CPU temperature, free disk space, uptime,
|
Added a dependency-free `/api/health` endpoint and responsive dashboard card reporting CPU
|
||||||
and RAM usage are all cheap to read (`vcgencmd measure_temp`, `psutil`/`/proc` stats) and cheap to
|
temperature, usable disk pressure/free bytes, RAM usage, human-readable uptime, and Raspberry Pi
|
||||||
display, but currently invisible - the only way to notice a problem (thermal throttling in a hot
|
firmware power/throttling flags. Data comes from thermal sysfs, `/proc`, `shutil.disk_usage`, and
|
||||||
car, an SD card filling up) today is to SSH in and check manually. Add a small backend endpoint
|
`vcgencmd get_throttled`; individual unavailable sources are reported explicitly without hiding
|
||||||
and a dashboard card (built on the Phase 15 component set) surfacing these at a glance. Also
|
the remaining readings. The endpoint exposes structured current and historical flags for Phase
|
||||||
feeds directly into Phase 20's alerting.
|
20 alerts, plus warning/degraded/ok status. The browser refreshes every 30 seconds.
|
||||||
|
|
||||||
|
**Implemented and verified 2026-08-09:**
|
||||||
|
- Five focused tests cover memory parsing, missing memory fields, current/historical throttling
|
||||||
|
bits, malformed firmware output, and ext4 reserved-block-aware disk pressure. Local non-Pi
|
||||||
|
validation correctly reports thermal/firmware sources as unavailable while retaining disk,
|
||||||
|
memory, and uptime instead of failing the endpoint.
|
||||||
|
- Removed the old browser-triggered thread that emitted uptime once per second indefinitely; the
|
||||||
|
health poll now provides uptime cheaply with the rest of the system snapshot.
|
||||||
|
- Deployed uncommitted code to the real Pi. The API returned complete data with no source errors:
|
||||||
|
about 47-49°C, 19-20% RAM, 59% usable disk pressure with 22.8GB free, and roughly two hours
|
||||||
|
uptime during testing. SQLite/app service behavior remained active.
|
||||||
|
- Real hardware monitoring immediately found an actionable condition: `vcgencmd get_throttled`
|
||||||
|
alternated between `0x50005` (current undervoltage and CPU throttling, both previously occurred)
|
||||||
|
and `0x50000` (historical occurrences after the current condition cleared). The dashboard
|
||||||
|
prominently distinguishes current warnings from prior events. The Pi power source/cable should
|
||||||
|
be corrected before sustained in-car use.
|
||||||
|
- Browser-tested the real dashboard through `http://yaris.local/` at 390x844 and 1280x900:
|
||||||
|
all four metrics, warning state, timestamp, responsive columns, and 30-second data path rendered
|
||||||
|
without overflow or new console errors. Also fixed disconnected OBD speed showing `undefined`
|
||||||
|
and deferred offline map tiles until a real GPS fix, avoiding irrelevant placeholder requests.
|
||||||
|
|
||||||
### Phase 20: Alerts/notifications — 🚧 PLANNED
|
### Phase 20: Alerts/notifications — 🚧 PLANNED
|
||||||
**New phase - depends on Phases 13/17/19 existing first**, since it needs real data sources to
|
**New phase - depends on Phases 13/17/19 existing first**, since it needs real data sources to
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from flask import Flask, render_template, make_response, json, jsonify, request, send_file
|
from flask import Flask, render_template, make_response, json, jsonify, request, send_file
|
||||||
from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload, app_video
|
from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload, app_video, app_health
|
||||||
import os, time
|
import os, time
|
||||||
from gevent.pywsgi import WSGIServer
|
from gevent.pywsgi import WSGIServer
|
||||||
|
|
||||||
@@ -140,6 +140,10 @@ def local_video_delete():
|
|||||||
def upload_status():
|
def upload_status():
|
||||||
return jsonify(app_upload.get_status())
|
return jsonify(app_upload.get_status())
|
||||||
|
|
||||||
|
@app.route('/api/health')
|
||||||
|
def health():
|
||||||
|
return jsonify(app_health.collect_health())
|
||||||
|
|
||||||
@app.route('/api/upload/dashcam_toggle', methods=['POST'])
|
@app.route('/api/upload/dashcam_toggle', methods=['POST'])
|
||||||
def upload_dashcam_toggle():
|
def upload_dashcam_toggle():
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True) or {}
|
||||||
|
|||||||
@@ -1,44 +1,10 @@
|
|||||||
from flask_socketio import SocketIO
|
from flask_socketio import SocketIO
|
||||||
from threading import Thread
|
|
||||||
from modules import app_obd
|
from modules import app_obd
|
||||||
import time, threading
|
|
||||||
|
|
||||||
socketio = SocketIO()
|
socketio = SocketIO()
|
||||||
uptime_lock = threading.Lock()
|
|
||||||
thread_uptime = None
|
|
||||||
|
|
||||||
@socketio.on('connect')
|
@socketio.on('connect')
|
||||||
def connect_event():
|
def connect_event():
|
||||||
print('Client connected')
|
print('Client connected')
|
||||||
print('Getting current OBD status...')
|
print('Getting current OBD status...')
|
||||||
app_obd.obd_connection()
|
app_obd.obd_connection()
|
||||||
uptime()
|
|
||||||
|
|
||||||
def uptime():
|
|
||||||
global thread_uptime
|
|
||||||
|
|
||||||
if thread_uptime is None or not thread_uptime.is_alive():
|
|
||||||
thread_uptime = Thread(target=loop_uptime)
|
|
||||||
thread_uptime.daemon = True
|
|
||||||
thread_uptime.start()
|
|
||||||
print(f"Uptime thread started, alive: {thread_uptime.is_alive()}")
|
|
||||||
else:
|
|
||||||
print(f"Uptime thread already running, alive: {thread_uptime.is_alive()}")
|
|
||||||
|
|
||||||
def loop_uptime():
|
|
||||||
uptime_lock.acquire()
|
|
||||||
try:
|
|
||||||
loop = True
|
|
||||||
while loop == True:
|
|
||||||
boottime = time.clock_gettime(time.CLOCK_BOOTTIME)
|
|
||||||
# Convert to hours, minutes, and seconds
|
|
||||||
seconds = int(boottime)
|
|
||||||
minutes, seconds = divmod(seconds, 60)
|
|
||||||
hours, minutes = divmod(minutes, 60)
|
|
||||||
time.sleep(1)
|
|
||||||
uptime = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
||||||
socketio.emit("rpi_uptime", uptime)
|
|
||||||
finally:
|
|
||||||
uptime_lock.release()
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
|
_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
THERMAL_PATH = "/sys/class/thermal/thermal_zone0/temp"
|
||||||
|
MEMINFO_PATH = "/proc/meminfo"
|
||||||
|
UPTIME_PATH = "/proc/uptime"
|
||||||
|
|
||||||
|
THROTTLE_FLAGS = {
|
||||||
|
0: "under_voltage",
|
||||||
|
1: "frequency_capped",
|
||||||
|
2: "throttled",
|
||||||
|
3: "soft_temperature_limit",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_text(path):
|
||||||
|
with open(path, encoding="ascii") as source:
|
||||||
|
return source.read().strip()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_meminfo(text):
|
||||||
|
values = {}
|
||||||
|
for line in text.splitlines():
|
||||||
|
name, separator, remainder = line.partition(":")
|
||||||
|
if separator and name in {"MemTotal", "MemAvailable"}:
|
||||||
|
values[name] = int(remainder.strip().split()[0]) * 1024
|
||||||
|
if set(values) != {"MemTotal", "MemAvailable"}:
|
||||||
|
raise ValueError("MemTotal or MemAvailable missing from /proc/meminfo")
|
||||||
|
total = values["MemTotal"]
|
||||||
|
available = values["MemAvailable"]
|
||||||
|
return {
|
||||||
|
"total_bytes": total,
|
||||||
|
"used_bytes": total - available,
|
||||||
|
"available_bytes": available,
|
||||||
|
"used_percent": round((total - available) / total * 100, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_throttled(value):
|
||||||
|
prefix, separator, raw_value = value.strip().partition("=")
|
||||||
|
if prefix != "throttled" or not separator:
|
||||||
|
raise ValueError(f"Unexpected vcgencmd response: {value.strip()}")
|
||||||
|
bits = int(raw_value, 16)
|
||||||
|
current = [
|
||||||
|
label for bit, label in THROTTLE_FLAGS.items() if bits & (1 << bit)
|
||||||
|
]
|
||||||
|
occurred = [
|
||||||
|
label for bit, label in THROTTLE_FLAGS.items() if bits & (1 << (bit + 16))
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"raw": f"0x{bits:x}",
|
||||||
|
"current": current,
|
||||||
|
"occurred": occurred,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _temperature():
|
||||||
|
return round(int(_read_text(THERMAL_PATH)) / 1000, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _memory():
|
||||||
|
return parse_meminfo(_read_text(MEMINFO_PATH))
|
||||||
|
|
||||||
|
|
||||||
|
def _uptime():
|
||||||
|
return int(float(_read_text(UPTIME_PATH).split()[0]))
|
||||||
|
|
||||||
|
|
||||||
|
def _disk():
|
||||||
|
usage = shutil.disk_usage(_BASE_DIR)
|
||||||
|
return {
|
||||||
|
"total_bytes": usage.total,
|
||||||
|
"used_bytes": usage.used,
|
||||||
|
"free_bytes": usage.free,
|
||||||
|
"used_percent": round(usage.used / (usage.used + usage.free) * 100, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _throttling():
|
||||||
|
result = subprocess.run(
|
||||||
|
["vcgencmd", "get_throttled"],
|
||||||
|
check=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=2,
|
||||||
|
)
|
||||||
|
return parse_throttled(result.stdout)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_health():
|
||||||
|
readers = {
|
||||||
|
"temperature_c": _temperature,
|
||||||
|
"memory": _memory,
|
||||||
|
"disk": _disk,
|
||||||
|
"uptime_seconds": _uptime,
|
||||||
|
"throttling": _throttling,
|
||||||
|
}
|
||||||
|
health = {}
|
||||||
|
errors = {}
|
||||||
|
for name, reader in readers.items():
|
||||||
|
try:
|
||||||
|
health[name] = reader()
|
||||||
|
except (OSError, ValueError, subprocess.SubprocessError) as error:
|
||||||
|
health[name] = None
|
||||||
|
errors[name] = str(error)
|
||||||
|
|
||||||
|
warnings = []
|
||||||
|
historical_warnings = []
|
||||||
|
throttling = health.get("throttling")
|
||||||
|
if throttling:
|
||||||
|
warnings.extend(throttling["current"])
|
||||||
|
historical_warnings.extend(
|
||||||
|
warning
|
||||||
|
for warning in throttling["occurred"]
|
||||||
|
if warning not in throttling["current"]
|
||||||
|
)
|
||||||
|
if health.get("temperature_c") is not None and health["temperature_c"] >= 80:
|
||||||
|
warnings.append("high_temperature")
|
||||||
|
if health.get("disk") and health["disk"]["used_percent"] >= 90:
|
||||||
|
warnings.append("disk_nearly_full")
|
||||||
|
if health.get("memory") and health["memory"]["used_percent"] >= 90:
|
||||||
|
warnings.append("memory_pressure")
|
||||||
|
|
||||||
|
health["status"] = (
|
||||||
|
"warning"
|
||||||
|
if warnings
|
||||||
|
else "degraded"
|
||||||
|
if errors
|
||||||
|
else "ok"
|
||||||
|
)
|
||||||
|
health["warnings"] = warnings
|
||||||
|
health["historical_warnings"] = historical_warnings
|
||||||
|
health["errors"] = errors
|
||||||
|
return health
|
||||||
@@ -57,6 +57,8 @@ i have a dream
|
|||||||
WiFi/Ethernet is unavailable (dashcam video stays off cellular)
|
WiFi/Ethernet is unavailable (dashcam video stays off cellular)
|
||||||
- 🗄️ **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
|
||||||
|
undervoltage/throttling warnings from the Raspberry Pi firmware
|
||||||
- 🛣️ **Trip history and route replay** — detects motion from OBD wheel speed with sustained GPS
|
- 🛣️ **Trip history and route replay** — detects motion from OBD wheel speed with sustained GPS
|
||||||
displacement as a fallback, summarizes distance and speed in metric/imperial units, and replays
|
displacement as a fallback, summarizes distance and speed in metric/imperial units, and replays
|
||||||
each route as a line on the offline map with a timestamped play/pause timeline and moving marker
|
each route as a line on the offline map with a timestamped play/pause timeline and moving marker
|
||||||
@@ -96,7 +98,7 @@ For the remote server (receives trip logs + dashcam uploads), see [server/README
|
|||||||
|
|
||||||
## Project status
|
## Project status
|
||||||
|
|
||||||
This project was revived after a couple of years on the shelf. Phases 1-13 and 15-18 are complete:
|
This project was revived after a couple of years on the shelf. Phases 1-13 and 15-19 are complete:
|
||||||
real GNSS, hardened OBD support, offline maps/UI, automatic WiFi hotspot switching, local logging,
|
real GNSS, hardened OBD support, offline maps/UI, automatic WiFi hotspot switching, local logging,
|
||||||
dashcam import, resumable remote upload, trip history/replay, deployment, and the shared UI
|
dashcam import, resumable remote upload, trip history/replay, deployment, and the shared UI
|
||||||
foundation are implemented.
|
foundation are implemented.
|
||||||
@@ -109,6 +111,6 @@ partition, and clean shutdown still needs delayed-power or ignition-sense hardwa
|
|||||||
|
|
||||||
The browser-based phone relay was removed after real-world testing confirmed that mobile browsers
|
The browser-based phone relay was removed after real-world testing confirmed that mobile browsers
|
||||||
cannot reliably use the Pi's WiFi and cellular data simultaneously. A native Android companion is
|
cannot reliably use the Pi's WiFi and cellular data simultaneously. A native Android companion is
|
||||||
planned later instead. The next roadmap work is Pi health, followed by alerts, settings, and a
|
planned later instead. The next roadmap work is alerts, followed by settings and a final UI polish
|
||||||
final UI polish pass. Full phase history, verification notes, and current scope live in
|
pass. Full phase history, verification notes, and current scope live in
|
||||||
[PLAN.md](./PLAN.md).
|
[PLAN.md](./PLAN.md).
|
||||||
|
|||||||
+104
-8
@@ -7,11 +7,11 @@ const socket = io();
|
|||||||
// Generic placeholder center (not a real location) - re-centers automatically
|
// Generic placeholder center (not a real location) - re-centers automatically
|
||||||
// to the real live GPS fix once one comes in (see gps_location handler below).
|
// to the real live GPS fix once one comes in (see gps_location handler below).
|
||||||
var map = L.map('map', { minZoom: 8, maxZoom: 15 }).setView([48.505, -0.09], 12);
|
var map = L.map('map', { minZoom: 8, maxZoom: 15 }).setView([48.505, -0.09], 12);
|
||||||
L.tileLayer('/static/vendor/tiles/{z}/{x}/{y}.png', {
|
const mapTiles = L.tileLayer('/static/vendor/tiles/{z}/{x}/{y}.png', {
|
||||||
attribution: '© <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> © <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors',
|
attribution: '© <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> © <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors',
|
||||||
minZoom: 8,
|
minZoom: 8,
|
||||||
maxZoom: 15
|
maxZoom: 15
|
||||||
}).addTo(map);
|
});
|
||||||
var carMarker = L.marker(map.getCenter()).addTo(map)
|
var carMarker = L.marker(map.getCenter()).addTo(map)
|
||||||
.bindPopup('your car... soon(tm)');
|
.bindPopup('your car... soon(tm)');
|
||||||
|
|
||||||
@@ -60,12 +60,16 @@ socket.on('obd_status', function(data) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on('obd_speed', function(data) {
|
socket.on('obd_speed', function(data) {
|
||||||
|
const status = document.getElementById('obd-speed');
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
status.textContent = data;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const time = data[0]; // Assuming data[0] is the time value
|
const time = data[0]; // Assuming data[0] is the time value
|
||||||
const speed = data[1]; // Assuming data[1] is the speed value
|
const speed = data[1]; // Assuming data[1] is the speed value
|
||||||
const maxDataPoints = 8; // Adjust this value as needed
|
const maxDataPoints = 8; // Adjust this value as needed
|
||||||
|
|
||||||
const status = document.getElementById('obd-speed');
|
|
||||||
status.innerHTML = speed;
|
status.innerHTML = speed;
|
||||||
|
|
||||||
// Get a reference to the chart's data
|
// Get a reference to the chart's data
|
||||||
@@ -98,11 +102,6 @@ socket.on('obd_error', function(data) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('rpi_uptime', function(data) {
|
|
||||||
const status = document.getElementById('rpi-uptime');
|
|
||||||
status.innerHTML = data;
|
|
||||||
});
|
|
||||||
|
|
||||||
let mapCentered = false;
|
let mapCentered = false;
|
||||||
|
|
||||||
socket.on('gps_location', function(data) {
|
socket.on('gps_location', function(data) {
|
||||||
@@ -115,6 +114,9 @@ socket.on('gps_location', function(data) {
|
|||||||
const lat = data[0];
|
const lat = data[0];
|
||||||
const lon = data[1];
|
const lon = data[1];
|
||||||
if (lat != null && lon != null) {
|
if (lat != null && lon != null) {
|
||||||
|
if (!map.hasLayer(mapTiles)) {
|
||||||
|
mapTiles.addTo(map);
|
||||||
|
}
|
||||||
carMarker.setLatLng([lat, lon]);
|
carMarker.setLatLng([lat, lon]);
|
||||||
if (!mapCentered) {
|
if (!mapCentered) {
|
||||||
map.setView([lat, lon], 15);
|
map.setView([lat, lon], 15);
|
||||||
@@ -122,3 +124,97 @@ socket.on('gps_location', function(data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function formatUptime(seconds) {
|
||||||
|
const days = Math.floor(seconds / 86400);
|
||||||
|
const hours = Math.floor((seconds % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
return days ? `${days}d ${hours}h` : `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHealthValue(id, value) {
|
||||||
|
document.getElementById(id).textContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function healthWarningLabel(warning) {
|
||||||
|
return {
|
||||||
|
under_voltage: "Power supply undervoltage",
|
||||||
|
frequency_capped: "CPU frequency capped",
|
||||||
|
throttled: "CPU currently throttled",
|
||||||
|
soft_temperature_limit: "CPU temperature limit active",
|
||||||
|
high_temperature: "CPU temperature is high",
|
||||||
|
disk_nearly_full: "Disk is nearly full",
|
||||||
|
memory_pressure: "Memory usage is high",
|
||||||
|
}[warning] || warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
function historicalHealthWarningLabel(warning) {
|
||||||
|
return {
|
||||||
|
under_voltage: "Power supply undervoltage",
|
||||||
|
frequency_capped: "CPU frequency capping",
|
||||||
|
throttled: "CPU throttling",
|
||||||
|
soft_temperature_limit: "CPU temperature limiting",
|
||||||
|
}[warning] || warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHealth() {
|
||||||
|
const response = await fetch("/api/health");
|
||||||
|
const health = await response.json();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(health.error || "Could not load Pi health.");
|
||||||
|
}
|
||||||
|
|
||||||
|
setHealthValue(
|
||||||
|
"pi-temperature",
|
||||||
|
health.temperature_c == null ? "Unavailable" : `${health.temperature_c.toFixed(1)} °C`
|
||||||
|
);
|
||||||
|
setHealthValue(
|
||||||
|
"pi-memory",
|
||||||
|
health.memory == null ? "Unavailable" : `${health.memory.used_percent.toFixed(1)}%`
|
||||||
|
);
|
||||||
|
setHealthValue(
|
||||||
|
"pi-disk",
|
||||||
|
health.disk == null
|
||||||
|
? "Unavailable"
|
||||||
|
: `${health.disk.used_percent.toFixed(1)}% · ${YarisUI.formatBytes(health.disk.free_bytes)} free`
|
||||||
|
);
|
||||||
|
setHealthValue(
|
||||||
|
"pi-uptime",
|
||||||
|
health.uptime_seconds == null ? "Unavailable" : formatUptime(health.uptime_seconds)
|
||||||
|
);
|
||||||
|
|
||||||
|
const warning = document.getElementById("pi-health-warning");
|
||||||
|
const currentWarnings = health.warnings.map(healthWarningLabel);
|
||||||
|
const historicalWarnings = health.historical_warnings.map(
|
||||||
|
historicalHealthWarningLabel
|
||||||
|
);
|
||||||
|
const warnings = currentWarnings.concat(
|
||||||
|
historicalWarnings.length
|
||||||
|
? [`Previously detected: ${historicalWarnings.join(", ")}`]
|
||||||
|
: []
|
||||||
|
);
|
||||||
|
warning.hidden = warnings.length === 0;
|
||||||
|
warning.textContent = warnings.join(" · ");
|
||||||
|
|
||||||
|
const error = document.getElementById("pi-health-error");
|
||||||
|
const unavailable = Object.keys(health.errors);
|
||||||
|
error.hidden = unavailable.length === 0;
|
||||||
|
error.textContent = unavailable.length
|
||||||
|
? `Unavailable health sources: ${unavailable.join(", ")}`
|
||||||
|
: "";
|
||||||
|
document.getElementById("pi-health-updated").textContent =
|
||||||
|
`Updated ${new Date().toLocaleTimeString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshHealth().catch(function (error) {
|
||||||
|
const message = document.getElementById("pi-health-error");
|
||||||
|
message.textContent = error.message;
|
||||||
|
message.hidden = false;
|
||||||
|
});
|
||||||
|
window.setInterval(function () {
|
||||||
|
refreshHealth().catch(function (error) {
|
||||||
|
const message = document.getElementById("pi-health-error");
|
||||||
|
message.textContent = error.message;
|
||||||
|
message.hidden = false;
|
||||||
|
});
|
||||||
|
}, 30000);
|
||||||
|
|||||||
+28
-17
@@ -11,7 +11,7 @@
|
|||||||
<h3 class="text-dark mb-0">Dashboard</h3><a class="btn btn-primary btn-sm d-none d-sm-inline-block" role="button" href="#"><i class="fas fa-download fa-sm text-white-50"></i> Placeholder button</a>
|
<h3 class="text-dark mb-0">Dashboard</h3><a class="btn btn-primary btn-sm d-none d-sm-inline-block" role="button" href="#"><i class="fas fa-download fa-sm text-white-50"></i> Placeholder button</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 col-xl-3 mb-4">
|
<div class="col-md-6 col-xl-4 mb-4">
|
||||||
<div class="card shadow border-start-primary py-2">
|
<div class="card shadow border-start-primary py-2">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row align-items-center no-gutters">
|
<div class="row align-items-center no-gutters">
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 col-xl-3 mb-4">
|
<div class="col-md-6 col-xl-4 mb-4">
|
||||||
<div class="card shadow border-start-success py-2">
|
<div class="card shadow border-start-success py-2">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row align-items-center no-gutters">
|
<div class="row align-items-center no-gutters">
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 col-xl-3 mb-4">
|
<div class="col-md-6 col-xl-4 mb-4">
|
||||||
<div class="card shadow border-start-info py-2">
|
<div class="card shadow border-start-info py-2">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row align-items-center no-gutters">
|
<div class="row align-items-center no-gutters">
|
||||||
@@ -50,20 +50,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 col-xl-3 mb-4">
|
</div>
|
||||||
<div class="card shadow border-start-warning py-2">
|
<div class="card shadow mb-4" id="pi-health-card">
|
||||||
<div class="card-body">
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
<div class="row align-items-center no-gutters">
|
<h6 class="text-primary fw-bold m-0">Pi health</h6>
|
||||||
<div class="col me-2">
|
<span id="pi-health-updated" class="small text-muted">Loading...</span>
|
||||||
<div class="text-uppercase text-info fw-bold text-xs mb-1"><span>UPTIME</span></div>
|
</div>
|
||||||
<div class="row g-0 align-items-center">
|
<div class="card-body">
|
||||||
<div class="col-auto">
|
<div id="pi-health-warning" class="alert alert-warning" role="alert" hidden></div>
|
||||||
<div id="rpi-uptime" class="text-dark fw-bold h5 mb-0 me-3"><span>unknown</span></div>
|
<div id="pi-health-error" class="alert alert-danger" role="alert" hidden></div>
|
||||||
</div>
|
<div class="row g-4">
|
||||||
</div>
|
<div class="col-6 col-lg-3">
|
||||||
</div>
|
<div class="text-uppercase text-muted fw-bold text-xs mb-1">CPU temperature</div>
|
||||||
<div class="col-auto"><i class="fas fa-clipboard-list fa-2x text-gray-300"></i></div>
|
<div id="pi-temperature" class="h5 text-dark fw-bold mb-0">--</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="text-uppercase text-muted fw-bold text-xs mb-1">Memory used</div>
|
||||||
|
<div id="pi-memory" class="h5 text-dark fw-bold mb-0">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="text-uppercase text-muted fw-bold text-xs mb-1">Disk used</div>
|
||||||
|
<div id="pi-disk" class="h5 text-dark fw-bold mb-0">--</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-3">
|
||||||
|
<div class="text-uppercase text-muted fw-bold text-xs mb-1">Uptime</div>
|
||||||
|
<div id="pi-uptime" class="h5 text-dark fw-bold mb-0">--</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import unittest
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from modules import app_health
|
||||||
|
|
||||||
|
|
||||||
|
class HealthParserTest(unittest.TestCase):
|
||||||
|
def test_parses_memory_usage(self):
|
||||||
|
memory = app_health.parse_meminfo(
|
||||||
|
"MemTotal: 1000 kB\nMemAvailable: 250 kB\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(memory["total_bytes"], 1024000)
|
||||||
|
self.assertEqual(memory["used_bytes"], 768000)
|
||||||
|
self.assertEqual(memory["used_percent"], 75.0)
|
||||||
|
|
||||||
|
def test_rejects_incomplete_memory_data(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
app_health.parse_meminfo("MemTotal: 1000 kB\n")
|
||||||
|
|
||||||
|
def test_parses_current_and_historical_throttling(self):
|
||||||
|
throttling = app_health.parse_throttled("throttled=0x50005\n")
|
||||||
|
|
||||||
|
self.assertEqual(throttling["raw"], "0x50005")
|
||||||
|
self.assertEqual(
|
||||||
|
throttling["current"], ["under_voltage", "throttled"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
throttling["occurred"], ["under_voltage", "throttled"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rejects_unexpected_throttling_response(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
app_health.parse_throttled("unknown")
|
||||||
|
|
||||||
|
@patch("modules.app_health.shutil.disk_usage")
|
||||||
|
def test_disk_percent_accounts_for_reserved_blocks(self, disk_usage):
|
||||||
|
disk_usage.return_value = SimpleNamespace(
|
||||||
|
total=100, used=88, free=9
|
||||||
|
)
|
||||||
|
|
||||||
|
disk = app_health._disk()
|
||||||
|
|
||||||
|
self.assertEqual(disk["used_percent"], 90.7)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user