From aa042de324ba502eacf03b6e3880aa03ac662f83 Mon Sep 17 00:00:00 2001
From: AngusBell97 <124716116+AngusBell97@users.noreply.github.com>
Date: Thu, 10 Sep 2026 21:17:26 +0100
Subject: [PATCH] Add a live System Monitor to Galaxy
---
.../the_galaxy/assets/mobile/css/material.css | 22 +++
.../system/the_galaxy/assets/mobile/js/api.js | 5 +
.../mobile/js/components/SystemMonitor.js | 175 ++++++++++++++++++
.../the_galaxy/assets/mobile/js/views/Logs.js | 11 +-
starpilot/system/the_galaxy/system_monitor.py | 100 ++++++++++
.../tests/test_monitor_feature_labels.mjs | 30 +++
.../tests/test_monitor_refresh_deadlines.mjs | 25 +++
.../the_galaxy/tests/test_system_monitor.py | 103 +++++++++++
.../tests/test_system_monitor_api.py | 17 ++
starpilot/system/the_galaxy/the_galaxy.py | 17 ++
10 files changed, 503 insertions(+), 2 deletions(-)
create mode 100644 starpilot/system/the_galaxy/assets/mobile/js/components/SystemMonitor.js
create mode 100644 starpilot/system/the_galaxy/system_monitor.py
create mode 100644 starpilot/system/the_galaxy/tests/test_monitor_feature_labels.mjs
create mode 100644 starpilot/system/the_galaxy/tests/test_monitor_refresh_deadlines.mjs
create mode 100644 starpilot/system/the_galaxy/tests/test_system_monitor.py
create mode 100644 starpilot/system/the_galaxy/tests/test_system_monitor_api.py
diff --git a/starpilot/system/the_galaxy/assets/mobile/css/material.css b/starpilot/system/the_galaxy/assets/mobile/css/material.css
index 9b987df931..8592c4307e 100644
--- a/starpilot/system/the_galaxy/assets/mobile/css/material.css
+++ b/starpilot/system/the_galaxy/assets/mobile/css/material.css
@@ -1639,3 +1639,25 @@ input[type="color"].gx-color {
.gx-back-btn { display: none; }
.gx-content { padding-bottom: var(--sp-6); }
}
+
+/* Read-only task manager, shared by phone and desktop layouts. */
+.gx-monitor__toolbar, .gx-monitor__filters { display:flex; gap:12px; align-items:center; justify-content:space-between; margin-bottom:12px; flex-wrap:wrap; }
+.gx-monitor__summary { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
+.gx-monitor__metric { padding:16px; margin:0; display:flex; flex-direction:column; gap:6px; position:relative; overflow:hidden; }
+.gx-monitor__metric > span, .gx-monitor__metric small { color:var(--text-muted); }
+.gx-monitor__metric strong { font-size:26px; }
+.gx-monitor__graph { height:32px; width:100%; color:var(--primary); }
+.gx-monitor progress { width:100%; height:6px; accent-color:var(--primary); }
+.gx-monitor__cores { padding:14px; margin:12px 0; }
+.gx-monitor__cores summary { cursor:pointer; }
+.gx-monitor__cores > div { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:16px; padding-top:12px; }
+.gx-monitor__cores span { font-size:12px; display:flex; flex-wrap:wrap; justify-content:space-between; gap:6px; }
+.gx-monitor__filters input { flex:1; min-width:180px; }
+.gx-monitor__table { overflow:auto; max-height:65vh; }
+.gx-monitor table { width:100%; border-collapse:collapse; font-size:13px; font-variant-numeric:tabular-nums; }
+.gx-monitor th { position:sticky; top:0; background:var(--surface); z-index:1; text-align:left; }
+.gx-monitor th button { background:transparent; color:inherit; border:0; font:inherit; font-weight:600; cursor:pointer; padding:12px; white-space:nowrap; }
+.gx-monitor td { padding:10px 12px; border-top:1px solid var(--border-color,rgba(128,128,128,.15)); white-space:nowrap; }
+.gx-monitor td:first-child { min-width:180px; max-width:350px; white-space:normal; overflow-wrap:anywhere; }
+.gx-monitor tbody tr:hover { background:var(--surface); }
+@media(max-width:600px) { .gx-monitor__summary { grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } .gx-monitor__metric { padding:12px; } .gx-monitor__metric strong { font-size:22px; } }
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/api.js b/starpilot/system/the_galaxy/assets/mobile/js/api.js
index 7fdd7134d1..cda5587253 100644
--- a/starpilot/system/the_galaxy/assets/mobile/js/api.js
+++ b/starpilot/system/the_galaxy/assets/mobile/js/api.js
@@ -190,6 +190,11 @@ export const api = {
navigationFavorite(body) { return request("/api/navigation/favorite", { method: "POST", data: body }) },
deleteNavigationKey(type) { return request(`/api/navigation_key?type=${encodeURIComponent(type)}`, { method: "DELETE" }) },
+ async systemMonitor(signal) {
+ const response = await fetch("/api/system/monitor", { signal, cache: "no-store" })
+ if (!response.ok) throw new Error("System monitor unavailable")
+ return response.json()
+ },
async backupToggles() {
const res = await fetch("/api/toggles/backup", { method: "POST" })
if (!res.ok) {
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/components/SystemMonitor.js b/starpilot/system/the_galaxy/assets/mobile/js/components/SystemMonitor.js
new file mode 100644
index 0000000000..cc55bea3f2
--- /dev/null
+++ b/starpilot/system/the_galaxy/assets/mobile/js/components/SystemMonitor.js
@@ -0,0 +1,175 @@
+import { api } from "../api.js"
+
+// Exact launch-module/executable matches only. Generic Python, shell and kernel
+// processes cannot be attributed reliably from the monitor's sanitized names.
+// Labels identify purpose; they do not imply a service can be disabled separately.
+const PROCESS_FEATURES = new Map(Object.entries({
+ "starpilot.system.adj_spot_monitor_vision": "V-ASM",
+ "starpilot.system.speed_limit_vision": "Vision Speed Limit Controller",
+ "starpilot.system.speed_limit_filler": "Map speed-limit data",
+ "starpilot.navigation.mapd_wrapper": "Offline maps",
+ "starpilot.navigation.mapd": "Offline maps",
+ ".data.galaxy.frpc": "Galaxy remote access",
+ "starpilot.navigation.navigationd": "Navigation",
+ "system.sentryd.sentryd": "Sentry Mode",
+ "starpilot.system.bluetooth.daemon": "Bluetooth connections",
+ "starpilot.system.wheel_controls.wheel_controlsd": "Bluetooth controller actions",
+ "starpilot.system.model_statsd": "Model statistics",
+ "starpilot.system.the_galaxy.the_galaxy": "Galaxy web interface",
+ "starpilot.system.galaxy.galaxy": "Galaxy remote access",
+ "starpilot.system.device_syncd": "Galaxy settings sync",
+ "starpilot.starpilot_process": "StarPilot settings and features",
+ "selfdrive.ui.ui": "Comma display",
+ "selfdrive.ui.soundd": "Sounds and alerts",
+ "selfdrive.modeld.modeld": "Driving model",
+ "selfdrive.modeld.dmonitoringmodeld": "Driver monitoring model",
+ "selfdrive.monitoring.dmonitoringd": "Driver monitoring",
+ "selfdrive.controls.controlsd": "Steering and speed control",
+ "selfdrive.controls.plannerd": "Driving planner",
+ "selfdrive.controls.radard": "Lead vehicle tracking",
+ "selfdrive.selfdrived.selfdrived": "Driving state and engagement",
+ "selfdrive.car.card": "Vehicle interface",
+ "selfdrive.pandad.pandad": "Panda firmware management",
+ "selfdrive.locationd.locationd": "Vehicle positioning",
+ "selfdrive.locationd.calibrationd": "Camera calibration",
+ "selfdrive.locationd.torqued": "Steering torque calibration",
+ "selfdrive.locationd.paramsd": "Vehicle parameter learning",
+ "selfdrive.locationd.lagd": "Steering delay estimation",
+ "system.hardware.hardwared": "Power and temperature management",
+ "system.loggerd.deleter": "Recording cleanup",
+ "system.loggerd.uploader": "Log uploads",
+ "system.logmessaged": "Application logs",
+ "system.statsd": "System statistics",
+ "system.tombstoned": "Crash diagnostics",
+ "system.updated.updated": "Software updates",
+ "system.timed": "Clock synchronization",
+ "system.sensord.sensord": "Motion sensors",
+ "system.micd": "Microphone",
+ "system.qcomgpsd.qcomgpsd": "Onboard GPS",
+ "system.ubloxd.ubloxd": "External GPS",
+ "system.ubloxd.pigeond": "External GPS management",
+ "system.athena.manage_athenad": "Comma remote connection",
+ "system.athena.athenad": "Comma remote connection",
+ "system.webrtc.webrtcd": "Camera livestream",
+ "system.proclogd": "Process diagnostics",
+ "system.journald": "System log recording",
+ "selfdrive.ui.feedback.feedbackd": "Driver feedback",
+ "system.manager.manager": "Process manager",
+ "manager": "Process manager",
+ "pandad": "Vehicle CAN communication",
+ "camerad": "Camera capture",
+ "system.camerad.camerad": "Camera capture",
+ "loggerd": "Drive recording",
+ "system.loggerd.loggerd": "Drive recording",
+ "encoderd": "Video encoding",
+ "system.loggerd.encoderd": "Video encoding"
+}))
+
+export function processFeature(process) {
+ if (process.kernel || typeof process.name !== "string") return ""
+ const name = process.name.replace(/^\/data\/openpilot\//, "").replace(/^\.\//, "")
+ .replace(/\.py$/, "").replaceAll("/", ".").replace(/^openpilot\./, "")
+ return PROCESS_FEATURES.get(name) || ""
+}
+
+export const SystemMonitor = {
+ name: "SystemMonitor",
+ data() { return { snapshot: null, error: "", paused: false, loading: false, query: "", scope: "comma", sort: "cpu", descending: true, history: [], sensorNow: 0, receivedAt: 0, timer: null, stopped: false } },
+ mounted() { this.visibility = () => { if (!document.hidden) this.refresh() }; document.addEventListener("visibilitychange", this.visibility); this.refresh() },
+ beforeUnmount() { this.stopped = true; clearTimeout(this.timer); clearTimeout(this.sensorTimer); this.controller?.abort(); document.removeEventListener("visibilitychange", this.visibility) },
+ computed: {
+ rows() {
+ const q = this.query.trim().toLowerCase()
+ return (this.snapshot?.processes || []).map(p => ({ ...p, feature: processFeature(p) })).filter(p => (this.scope === "all" || (this.scope === "comma" ? p.user === "comma" : !p.kernel)) &&
+ (!q || `${p.name} ${p.feature} ${p.pid} ${p.user} ${this.state(p.state)}`.toLowerCase().includes(q))).slice().sort((a,b) => {
+ const av = a[this.sort], bv = b[this.sort]
+ if (av == null) return bv == null ? a.pid - b.pid : 1
+ if (bv == null) return -1
+ const result = typeof av === "number" ? av - bv : String(av).localeCompare(String(bv))
+ return (this.descending ? -result : result) || a.pid - b.pid
+ })
+ },
+ captured() { return this.snapshot ? new Date(this.snapshot.sampledAt * 1000).toLocaleTimeString() : "—" },
+ uptime() { const s = this.snapshot?.uptimeSeconds || 0; return `${Math.floor(s / 3600)}h ${Math.floor(s / 60) % 60}m` },
+ vramUsed() { return this.sensor('memoryUsedBytes', 'memoryMaxAgeMs') },
+ vramTotal() { return this.sensor('memoryTotalBytes', 'memoryMaxAgeMs') },
+ graph() { return this.history.map((v,i) => `${i * 100 / 29},${40 - v * .4}`).join(" ") },
+ },
+ methods: {
+ sensor(field, ageKey) {
+ const vitals = this.snapshot?.vitals
+ if (this.error || !vitals || !(vitals[ageKey] > 0) || (this.sensorNow - this.receivedAt >= vitals[ageKey])) return null
+ return vitals[field] ?? null
+ },
+ expireSensors() {
+ clearTimeout(this.sensorTimer)
+ this.sensorNow = performance.now()
+ if (this.stopped || this.paused) return
+ const ages = ['onboardMaxAgeMs', 'maxAgeMs', 'memoryMaxAgeMs'].map(key => (this.snapshot?.vitals?.[key] || 0) - (this.sensorNow - this.receivedAt)).filter(age => age > 0)
+ if (ages.length) this.sensorTimer = setTimeout(() => this.expireSensors(), Math.min(...ages) + 1)
+ },
+ number(value, suffix="") { return value == null ? "—" : Number(value).toFixed(1) + suffix },
+ state(value) { return ({R:"Running", S:"Sleeping", D:"Waiting", T:"Stopped", t:"Tracing", Z:"Zombie", I:"Idle"})[value] || value },
+ sortBy(key) { if (this.sort === key) this.descending = !this.descending; else { this.sort = key; this.descending = ["cpu", "memoryMiB"].includes(key) } },
+ arrow(key) { return this.sort === key ? (this.descending ? " ↓" : " ↑") : "" },
+ ariaSort(key) { return this.sort !== key ? "none" : this.descending ? "descending" : "ascending" },
+ togglePause() { this.paused = !this.paused; this.expireSensors(); if (!this.paused) this.refresh() },
+ async refresh() {
+ clearTimeout(this.timer)
+ if (this.stopped || this.loading) return
+ if (!this.paused && !document.hidden) {
+ this.loading = true
+ this.controller = new AbortController()
+ const timeout = setTimeout(() => this.controller.abort(), 8000)
+ const requestStarted = performance.now()
+ try {
+ const data = await api.systemMonitor(this.controller.signal)
+ if (this.stopped) return
+ if (data.sampledAt !== this.snapshot?.sampledAt && data.cpuPercent != null) this.history = [...this.history, data.cpuPercent].slice(-30)
+ this.snapshot = data; this.error = ""; this.receivedAt = requestStarted; this.expireSensors()
+ } catch (e) { if (!this.stopped) this.error = "Cannot refresh system monitor. Showing the last captured values." }
+ finally { clearTimeout(timeout); this.loading = false }
+ }
+ if (!this.stopped) {
+ // Refresh before sensor expiry, reserving time for the next response.
+ // Keep request-start timestamps: receipt time would overstate freshness.
+ const elapsed = performance.now() - this.receivedAt
+ const budgets = this.error || this.paused || document.hidden ? [] :
+ ['onboardMaxAgeMs', 'maxAgeMs', 'memoryMaxAgeMs'].map(key => this.snapshot?.vitals?.[key] || 0).filter(age => age > 0)
+ const delay = Math.max(100, Math.min(2000, ...budgets.map(age => age - elapsed - Math.max(500, elapsed))))
+ this.timer = setTimeout(() => this.refresh(), delay)
+ }
+ },
+ },
+ template: `
+
+
+
{{ error }}
+
{{ error ? 'Waiting for the device…' : 'Reading system activity…' }}
+
+
+
CPU{{ number(snapshot.cpuPercent, '%') }}{{ snapshot.cores.length }} cores · overall usage
+
+
+
Memory{{ number(snapshot.memory.percent, '%') }}{{ number(snapshot.memory.usedMiB / 1024) }} / {{ number(snapshot.memory.totalMiB / 1024) }} GiB
+
Processes{{ snapshot.processCount }}Uptime {{ uptime }}
+
Storage{{ snapshot.storage.usedGiB }} GiB{{ snapshot.storage.totalGiB }} GiB total
+
Onboard CPU temperature{{ number(sensor('cpuTempC', 'onboardMaxAgeMs'), ' °C') }}
+
Onboard GPU temperature{{ number(sensor('gpuTempC', 'onboardMaxAgeMs'), ' °C') }}
+
eGPU hotspot temperature{{ number(sensor('hotspotTempC', 'maxAgeMs'), ' °C') }}
+
eGPU temperature{{ number(sensor('gpuEdgeTempC', 'maxAgeMs'), ' °C') }}
+
eGPU VRAM{{ vramUsed == null ? '—' : number(vramUsed / 1073741824) + ' GiB' }}{{ number(vramTotal / 1073741824) }} GiB total · {{ number(100 * vramUsed / vramTotal, '%') }}
+
+ CPU cores
{{ core.name.toUpperCase() }} {{ number(core.percent, '%') }}
+
+ {{ rows.length }} processes shown. {{ snapshot.cpuPercent == null ? 'Collecting the first CPU sample…' : '' }}
+
+ |
+ | {{ process.name }} ({{ process.feature }}) | {{ process.pid }} | {{ number(process.cpu, '%') }} | {{ number(process.memoryMiB) }} MiB | {{ process.user }} | {{ state(process.state) }} |
| No matching processes. |
+
+
+
`,
+}
diff --git a/starpilot/system/the_galaxy/assets/mobile/js/views/Logs.js b/starpilot/system/the_galaxy/assets/mobile/js/views/Logs.js
index 79b6cae7cf..3620d382ce 100644
--- a/starpilot/system/the_galaxy/assets/mobile/js/views/Logs.js
+++ b/starpilot/system/the_galaxy/assets/mobile/js/views/Logs.js
@@ -4,10 +4,13 @@ import { GalaxyConfirm } from "../components/GalaxyModal.js"
import { TroubleshootPanel } from "../components/TroubleshootPanel.js"
import { GalaxyTabs } from "../components/GalaxyTabs.js"
+import { SystemMonitor } from "../components/SystemMonitor.js"
+
const TABS = {
troubleshoot: "Troubleshoot",
errors: "Error Logs",
tmux: "Tmux Live Log",
+ monitor: "System Monitor",
}
function parseLogDate(filename) {
@@ -19,7 +22,7 @@ function parseLogDate(filename) {
export const Logs = {
name: "Logs",
- components: { TroubleshootPanel, GalaxyTabs },
+ components: { TroubleshootPanel, GalaxyTabs, SystemMonitor },
data() {
return {
TABS,
@@ -35,7 +38,7 @@ export const Logs = {
}
},
setup() {
- return useTabRouting("/logs", { troubleshoot: "troubleshoot", errors: "errors", tmux: "tmux" })
+ return useTabRouting("/logs", { troubleshoot: "troubleshoot", errors: "errors", tmux: "tmux", monitor: "monitor" })
},
created() {
this.stream = useLogStream({ endpoint: "/api/tmux_log/live", snapshotFn: () => api.tmuxSnapshot(), interval: 2000 })
@@ -221,6 +224,10 @@ export const Logs = {
+
+
+
+
diff --git a/starpilot/system/the_galaxy/system_monitor.py b/starpilot/system/the_galaxy/system_monitor.py
new file mode 100644
index 0000000000..ed964c7560
--- /dev/null
+++ b/starpilot/system/the_galaxy/system_monitor.py
@@ -0,0 +1,100 @@
+"""Read-only, request-driven Linux process snapshots shared by Galaxy clients."""
+import os
+import pwd
+import shutil
+import threading
+import time
+from pathlib import Path
+
+
+class SystemMonitor:
+ def __init__(self, root=Path('/proc')):
+ self.root = Path(root)
+ self.lock = threading.Lock()
+ self.previous = {}
+ self.names = {}
+ self.cpu_previous = {}
+ self.previous_time = None
+ self.cached = None
+ self.hz = os.sysconf('SC_CLK_TCK')
+ self.page = os.sysconf('SC_PAGE_SIZE')
+
+ def sample(self):
+ with self.lock:
+ now = time.monotonic()
+ if self.cached is not None and now - self.previous_time < 1.5:
+ return self.cached
+ elapsed = now - self.previous_time if self.previous_time is not None else None
+ cpu_now, cores = {}, []
+ overall = None
+ cpu_capacity = None
+ for line in (self.root / 'stat').read_text().splitlines():
+ values = line.split()
+ if not values or not values[0].startswith('cpu'):
+ continue
+ values_num = [int(value) for value in values[1:9]]
+ pair = (sum(values_num), values_num[3] + values_num[4])
+ key = values[0]
+ cpu_now[key] = pair
+ old = self.cpu_previous.get(key)
+ percent = None
+ if old and pair[0] > old[0]:
+ percent = round(max(0, min(100, 100 * (1 - (pair[1] - old[1]) / (pair[0] - old[0])))), 1)
+ if key == 'cpu':
+ overall = percent
+ if old and pair[0] > old[0]:
+ cpu_capacity = pair[0] - old[0]
+ else:
+ cores.append({'name': key, 'percent': percent})
+ rows, ticks, names = [], {}, {}
+ for path in self.root.iterdir():
+ if not path.name.isdigit():
+ continue
+ try:
+ raw = (path / 'stat').read_text()
+ end = raw.rindex(')')
+ fields = raw[end + 2:].split()
+ identity = (int(path.name), fields[19])
+ current = int(fields[11]) + int(fields[12])
+ ticks[identity] = current
+ info = self.names.get(identity)
+ if info is None:
+ args = [part for part in (path / 'cmdline').read_bytes().decode(errors='replace').split('\0') if part]
+ name = raw[raw.index('(') + 1:end]
+ if args:
+ name = args[0]
+ if 'python' in Path(name).name and len(args) > 1:
+ name = args[2] if args[1] == '-m' and len(args) > 2 else (args[1] if not args[1].startswith('-') else Path(name).name)
+ uid = path.stat().st_uid
+ try:
+ user = pwd.getpwuid(uid).pw_name
+ except KeyError:
+ user = str(uid)
+ # Only executable/module names: never expose arguments or environment secrets.
+ info = {'name': name.removeprefix('/data/openpilot/'), 'user': user, 'kernel': not args}
+ names[identity] = info
+ cpu = None
+ if cpu_capacity and identity in self.previous and current >= self.previous[identity]:
+ # Same aggregate tick window as the total, including any core hotplug.
+ # 100% means all measured CPU capacity, not one fully occupied core.
+ cpu = round(min(100, (current - self.previous[identity]) / cpu_capacity * 100), 1)
+ rows.append({'pid': identity[0], **info, 'state': fields[0], 'cpu': cpu,
+ 'memoryMiB': round(max(0, int(fields[21])) * self.page / 1048576, 1)})
+ except (OSError, ValueError, IndexError):
+ continue # Process exited or is inaccessible during this snapshot.
+ memory = {line.split(':')[0]: int(line.split()[1]) for line in (self.root / 'meminfo').read_text().splitlines() if ':' in line}
+ total = memory['MemTotal'] / 1024
+ available = memory.get('MemAvailable', memory.get('MemFree', 0)) / 1024
+ disk = shutil.disk_usage('/data' if Path('/data').exists() else '/')
+ self.cached = {'sampledAt': time.time(), 'sampleSeconds': round(elapsed, 2) if elapsed else None,
+ 'cpuPercent': overall, 'cores': cores,
+ 'memory': {'totalMiB': round(total, 1), 'usedMiB': round(total - available, 1),
+ 'availableMiB': round(available, 1), 'percent': round(100 * (total - available) / total, 1)},
+ 'storage': {'usedGiB': round(disk.used / 1073741824, 1), 'totalGiB': round(disk.total / 1073741824, 1)},
+ 'uptimeSeconds': float((self.root / 'uptime').read_text().split()[0]),
+ 'processCount': len(rows), 'processes': rows}
+ self.previous, self.names, self.cpu_previous, self.previous_time = ticks, names, cpu_now, now
+ return self.cached
+
+
+monitor = SystemMonitor()
diff --git a/starpilot/system/the_galaxy/tests/test_monitor_feature_labels.mjs b/starpilot/system/the_galaxy/tests/test_monitor_feature_labels.mjs
new file mode 100644
index 0000000000..20f8a3f30a
--- /dev/null
+++ b/starpilot/system/the_galaxy/tests/test_monitor_feature_labels.mjs
@@ -0,0 +1,30 @@
+import assert from 'node:assert/strict';
+import {SystemMonitor, processFeature} from '../assets/mobile/js/components/SystemMonitor.js';
+assert.equal(typeof processFeature, 'function', 'Monitor must identify feature names');
+for (const name of ['starpilot.system.adj_spot_monitor_vision', 'openpilot.starpilot.system.adj_spot_monitor_vision', '/data/openpilot/starpilot/system/adj_spot_monitor_vision.py']) {
+ assert.equal(processFeature({name}), 'V-ASM');
+}
+assert.equal(processFeature({name:'starpilot.system.speed_limit_vision'}), 'Vision Speed Limit Controller');
+assert.equal(processFeature({name:'starpilot.system.wheel_controls.wheel_controlsd'}), 'Bluetooth controller actions');
+assert.equal(processFeature({name:'openpilot.starpilot.system.model_statsd'}), 'Model statistics');
+assert.equal(processFeature({name:'./pandad'}), 'Vehicle CAN communication');
+for (const name of ['python', 'bash', 'unknown.adj_spot_monitor_vision', 'constructor', 'toString', '', null]) {
+ assert.equal(processFeature({name}), '', 'Do not guess from generic or unknown process names');
+}
+assert.equal(processFeature({name:'starpilot.system.adj_spot_monitor_vision',kernel:true}), '');
+const processes=[
+ {name:'starpilot.system.adj_spot_monitor_vision',pid:1,user:'comma',cpu:12,state:'S'},
+ {name:'./pandad',pid:2,user:'comma',cpu:30,state:'R'},
+ {name:'python',pid:3,user:'root',cpu:40,state:'S'},
+];
+const view={query:'V-ASM',scope:'comma',sort:'cpu',descending:true,snapshot:{processes},state:SystemMonitor.methods.state};
+let rows=SystemMonitor.computed.rows.call(view);
+assert.deepEqual(rows.map(p=>p.pid),[1], 'Search must match feature names');
+assert.equal(rows[0].name,processes[0].name, 'Raw process name is preserved');
+assert.equal(processes[0].feature,undefined, 'Do not mutate the API snapshot');
+view.query='';
+assert.deepEqual(SystemMonitor.computed.rows.call(view).map(p=>p.pid),[2,1], 'CPU sorting and process scope are preserved');
+view.query='2';
+assert.deepEqual(SystemMonitor.computed.rows.call(view).map(p=>p.pid),[2]);
+assert.match(SystemMonitor.template,/\(\{\{ process\.feature \}\}\)/, 'Render feature names in brackets');
+console.log('PASS: feature matching, unknown/kernel exclusions, feature search, raw names, scope, CPU sorting and bracket rendering');
diff --git a/starpilot/system/the_galaxy/tests/test_monitor_refresh_deadlines.mjs b/starpilot/system/the_galaxy/tests/test_monitor_refresh_deadlines.mjs
new file mode 100644
index 0000000000..8cbe6a8c5d
--- /dev/null
+++ b/starpilot/system/the_galaxy/tests/test_monitor_refresh_deadlines.mjs
@@ -0,0 +1,25 @@
+import assert from 'node:assert/strict';
+import {SystemMonitor} from '../assets/mobile/js/components/SystemMonitor.js';
+import {api} from '../assets/mobile/js/api.js';
+let now=0, scheduled=[];
+globalThis.performance={now:()=>now};
+globalThis.document={hidden:false};
+globalThis.setTimeout=(fn,delay)=>{const entry={fn,delay};scheduled.push(entry);return entry};
+globalThis.clearTimeout=()=>{};
+for(const latency of [100,750,1000]) {
+ now=0;scheduled=[];
+ const view={...SystemMonitor.data(),...SystemMonitor.methods};
+ api.systemMonitor=async()=>{now+=latency;return {sampledAt:1,cpuPercent:20,vitals:{gpuTempC:45,hotspotTempC:70,memoryUsedBytes:100,memoryTotalBytes:200,onboardMaxAgeMs:2500,maxAgeMs:2500,memoryMaxAgeMs:2500}}};
+ await view.refresh();
+ const delay=scheduled.at(-1).delay;
+ assert.ok(delay>=100 && delay<=2000,'Bound refresh load');
+ const nextResponse=now+delay+latency;
+ view.sensorNow=nextResponse-1;
+ assert.equal(view.sensor('gpuTempC','onboardMaxAgeMs'),45,`No blank before next healthy response at ${latency}ms latency`);
+ view.sensorNow=2501;
+ assert.equal(view.sensor('gpuTempC','onboardMaxAgeMs'),null,'Stopped telemetry still expires');
+ view.sensorNow=now;view.snapshot.vitals.maxAgeMs=0;
+ assert.equal(view.sensor('hotspotTempC','maxAgeMs'),null,'Invalid sensor response is not held');
+ view.stopped=true;scheduled=[];await view.refresh();assert.equal(scheduled.length,0);
+}
+console.log('PASS: delayed healthy responses, bounded polling, stale/invalid expiry and unmount');
diff --git a/starpilot/system/the_galaxy/tests/test_system_monitor.py b/starpilot/system/the_galaxy/tests/test_system_monitor.py
new file mode 100644
index 0000000000..8a87e71485
--- /dev/null
+++ b/starpilot/system/the_galaxy/tests/test_system_monitor.py
@@ -0,0 +1,103 @@
+from pathlib import Path
+import pytest
+from openpilot.starpilot.system.the_galaxy.system_monitor import SystemMonitor
+
+
+def proc(root, pid=123, ticks=20, start=1, command=b'/usr/bin/python\0-m\0example.worker\0--token=secret\0'):
+ p=root/str(pid);p.mkdir(exist_ok=True)
+ fields=['S']+['0']*23
+ fields[11]=str(ticks);fields[19]=str(start);fields[21]='100'
+ (p/'stat').write_text(f'{pid} (worker (test)) '+ ' '.join(fields))
+ (p/'cmdline').write_bytes(command)
+
+
+def fixture(root, active=100,idle=900):
+ (root/'stat').write_text(f'cpu {active} 0 0 {idle} 0 0 0 0\ncpu0 {active} 0 0 {idle} 0 0 0 0\n')
+ (root/'meminfo').write_text('MemTotal: 1024000 kB\nMemAvailable: 512000 kB\n')
+ (root/'uptime').write_text('1234.0 0.0')
+
+
+def test_cpu_cache_memory_and_secret_exclusion(tmp_path,monkeypatch):
+ now=[0.0];monkeypatch.setattr('time.monotonic',lambda:now[0])
+ fixture(tmp_path);proc(tmp_path)
+ monitor=SystemMonitor(tmp_path);first=monitor.sample()
+ assert first['cpuPercent'] is None and first['processes'][0]['cpu'] is None
+ assert first['memory']['percent']==50
+ assert first['processes'][0]['name']=='example.worker'
+ assert 'secret' not in str(first)
+ now[0]=1;assert monitor.sample() is first
+ now[0]=2;fixture(tmp_path,150,1050);proc(tmp_path,ticks=70)
+ second=monitor.sample()
+ assert second['cpuPercent']==25
+ assert second['processes'][0]['cpu']==25
+
+
+def test_reused_pid_has_no_inherited_cpu_or_name(tmp_path,monkeypatch):
+ now=[0.0];monkeypatch.setattr('time.monotonic',lambda:now[0])
+ fixture(tmp_path);proc(tmp_path);monitor=SystemMonitor(tmp_path);monitor.sample()
+ now[0]=2;proc(tmp_path,start=2,command=b'/usr/bin/other\0private argument\0')
+ row=monitor.sample()['processes'][0]
+ assert row['cpu'] is None and row['name']=='/usr/bin/other'
+
+
+def test_exiting_and_kernel_processes(tmp_path):
+ fixture(tmp_path);proc(tmp_path,command=b'');(tmp_path/'456').mkdir()
+ result=SystemMonitor(tmp_path).sample()
+ assert result['processCount']==1
+ assert result['processes'][0]['kernel']
+
+
+@pytest.mark.parametrize("core_count", [4, 8])
+def test_processes_use_same_total_capacity_as_overall(tmp_path, monkeypatch, core_count):
+ now = [0.0]
+ monkeypatch.setattr('time.monotonic', lambda: now[0])
+ fixture(tmp_path)
+ proc(tmp_path, pid=123, ticks=20)
+ proc(tmp_path, pid=124, ticks=30)
+ monitor = SystemMonitor(tmp_path)
+ monitor.sample()
+ now[0] = 2
+ capacity = core_count * 2 * monitor.hz
+ used = capacity // 4
+ fixture(tmp_path, active=100 + used, idle=900 + capacity - used)
+ # /proc/stat has one row per currently online core.
+ with (tmp_path / 'stat').open('a') as f:
+ for i in range(1, core_count):
+ f.write(f'cpu{i} 100 0 0 900 0 0 0 0\n')
+ proc(tmp_path, pid=123, ticks=20 + used // 2)
+ proc(tmp_path, pid=124, ticks=30 + used // 2)
+ sample = monitor.sample()
+ assert len(sample['cores']) == core_count
+ assert sample['cpuPercent'] == 25
+ assert [row['cpu'] for row in sample['processes']] == [12.5, 12.5]
+ assert sum(row['cpu'] for row in sample['processes']) == sample['cpuPercent']
+
+
+def test_cpu_capacity_is_measured_across_core_hotplug(tmp_path, monkeypatch):
+ now = [0.0]
+ monkeypatch.setattr('time.monotonic', lambda: now[0])
+ fixture(tmp_path); proc(tmp_path)
+ monitor = SystemMonitor(tmp_path); monitor.sample()
+ now[0] = 2
+ # Four cores for one second, then eight for one second: 12 core-seconds.
+ capacity = 12 * monitor.hz
+ fixture(tmp_path, active=100 + monitor.hz, idle=900 + capacity - monitor.hz)
+ with (tmp_path / 'stat').open('a') as f:
+ for i in range(1, 8):
+ f.write(f'cpu{i} 100 0 0 900 0 0 0 0\n')
+ proc(tmp_path, ticks=20 + monitor.hz)
+ sample = monitor.sample()
+ assert sample['processes'][0]['cpu'] == sample['cpuPercent'] == 8.3
+
+
+@pytest.mark.parametrize("active,idle", [(100, 900), (0, 0)])
+def test_missing_or_reset_capacity_has_no_process_percentage(tmp_path, monkeypatch, active, idle):
+ now = [0.0]
+ monkeypatch.setattr('time.monotonic', lambda: now[0])
+ fixture(tmp_path); proc(tmp_path)
+ monitor = SystemMonitor(tmp_path); monitor.sample()
+ now[0] = 2
+ fixture(tmp_path, active=active, idle=idle); proc(tmp_path, ticks=30)
+ sample = monitor.sample()
+ assert sample['cpuPercent'] is None
+ assert sample['processes'][0]['cpu'] is None
diff --git a/starpilot/system/the_galaxy/tests/test_system_monitor_api.py b/starpilot/system/the_galaxy/tests/test_system_monitor_api.py
new file mode 100644
index 0000000000..78854f200b
--- /dev/null
+++ b/starpilot/system/the_galaxy/tests/test_system_monitor_api.py
@@ -0,0 +1,17 @@
+import sys
+from types import SimpleNamespace
+from test_dashboard_stats import _load_server_module
+
+
+def test_monitor_works_without_optional_gpu_provider(monkeypatch):
+ s = _load_server_module()
+ assert s._import_galaxy_web_symbols()
+ app = s.Flask("monitor_independent")
+ s.setup(app)
+ name = "openpilot.starpilot.system.the_galaxy.system_monitor"
+ monkeypatch.setitem(sys.modules, name, SimpleNamespace(monitor=SimpleNamespace(sample=lambda: {"cpuPercent": 20})))
+ monkeypatch.setitem(sys.modules, "openpilot.starpilot.system.the_galaxy.external_gpu_vitals", None)
+ response = app.test_client().get("/api/system/monitor")
+ assert response.status_code == 200
+ assert response.get_json() == {"cpuPercent": 20, "vitals": {}}
+ assert response.headers["Cache-Control"] == "no-store"
diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py
index 9f2176aa0d..3cd553f63d 100644
--- a/starpilot/system/the_galaxy/the_galaxy.py
+++ b/starpilot/system/the_galaxy/the_galaxy.py
@@ -6627,6 +6627,23 @@ def setup(app):
return jsonify(_sanitize_json_value(result)), 200
+ @app.route("/api/system/monitor", methods=["GET"])
+ def system_monitor_snapshot():
+ from openpilot.starpilot.system.the_galaxy.system_monitor import monitor
+ try:
+ # Telemetry is an optional companion; process monitoring works on its own.
+ try:
+ from openpilot.starpilot.system.the_galaxy.external_gpu_vitals import external_gpu_vitals
+ except ImportError:
+ vitals = {}
+ else:
+ vitals = external_gpu_vitals(include_onboard=True)
+ response = jsonify({**monitor.sample(), 'vitals': vitals})
+ response.headers['Cache-Control'] = 'no-store'
+ return response
+ except (OSError, ValueError, IndexError):
+ return jsonify({'error': 'System activity is temporarily unavailable.'}), 503
+
@app.route("/api/troubleshoot", methods=["GET"])
def get_troubleshoot_data():
try: