Add a live System Monitor to Galaxy

(cherry picked from commit aa042de324)
This commit is contained in:
AngusBell97
2026-09-10 21:17:26 +01:00
committed by firestar5683
parent 46596218ba
commit 8eb46987ff
10 changed files with 503 additions and 2 deletions
@@ -1874,3 +1874,25 @@ button.gx-chip:hover {
.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; } }
@@ -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) {
@@ -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: `
<div class="gx-monitor">
<div class="gx-monitor__toolbar">
<div><strong>System Monitor</strong><div class="gx-note">{{ paused ? 'Paused' : error ? 'Connection interrupted' : 'Live updates' }} · Captured {{ captured }}</div></div>
<button class="gx-btn gx-btn--tonal" type="button" @click="togglePause">{{ paused ? 'Resume' : 'Pause' }}</button>
</div>
<p v-if="error" class="gx-note gx-note--danger" role="status">{{ error }}</p>
<div v-if="!snapshot" class="gx-loading">{{ error ? 'Waiting for the device…' : 'Reading system activity…' }}</div>
<template v-else>
<div class="gx-monitor__summary">
<section class="gx-card gx-monitor__metric"><span>CPU</span><strong>{{ number(snapshot.cpuPercent, '%') }}</strong><small>{{ snapshot.cores.length }} cores · overall usage</small>
<svg viewBox="0 0 100 40" preserveAspectRatio="none" class="gx-monitor__graph" role="img" aria-label="Recent CPU usage"><polyline :points="graph" fill="none" stroke="currentColor" stroke-width="1.5" vector-effect="non-scaling-stroke"/></svg>
</section>
<section class="gx-card gx-monitor__metric"><span>Memory</span><strong>{{ number(snapshot.memory.percent, '%') }}</strong><small>{{ number(snapshot.memory.usedMiB / 1024) }} / {{ number(snapshot.memory.totalMiB / 1024) }} GiB</small><progress :value="snapshot.memory.percent" max="100" aria-label="Memory usage"></progress></section>
<section class="gx-card gx-monitor__metric"><span>Processes</span><strong>{{ snapshot.processCount }}</strong><small>Uptime {{ uptime }}</small></section>
<section class="gx-card gx-monitor__metric"><span>Storage</span><strong>{{ snapshot.storage.usedGiB }} GiB</strong><small>{{ snapshot.storage.totalGiB }} GiB total</small></section>
<section class="gx-card gx-monitor__metric"><span>Onboard CPU temperature</span><strong>{{ number(sensor('cpuTempC', 'onboardMaxAgeMs'), ' °C') }}</strong></section>
<section class="gx-card gx-monitor__metric"><span>Onboard GPU temperature</span><strong>{{ number(sensor('gpuTempC', 'onboardMaxAgeMs'), ' °C') }}</strong></section>
<section class="gx-card gx-monitor__metric"><span>eGPU hotspot temperature</span><strong>{{ number(sensor('hotspotTempC', 'maxAgeMs'), ' °C') }}</strong></section>
<section v-if="snapshot.vitals?.gpuEdgeTempC != null" class="gx-card gx-monitor__metric"><span>eGPU temperature</span><strong>{{ number(sensor('gpuEdgeTempC', 'maxAgeMs'), ' °C') }}</strong></section>
<section class="gx-card gx-monitor__metric"><span>eGPU VRAM</span><strong>{{ vramUsed == null ? '—' : number(vramUsed / 1073741824) + ' GiB' }}</strong><small v-if="vramTotal != null">{{ number(vramTotal / 1073741824) }} GiB total · {{ number(100 * vramUsed / vramTotal, '%') }}</small><progress v-if="vramTotal > 0 && vramUsed != null" :value="vramUsed" :max="vramTotal" aria-label="eGPU VRAM usage"></progress></section>
</div>
<details class="gx-card gx-monitor__cores"><summary>CPU cores</summary><div><span v-for="core in snapshot.cores" :key="core.name">{{ core.name.toUpperCase() }} <b>{{ number(core.percent, '%') }}</b><progress :value="core.percent || 0" max="100" :aria-label="core.name + ' usage'"></progress></span></div></details>
<div class="gx-monitor__filters"><input class="gx-field" type="search" v-model="query" aria-label="Search processes" placeholder="Search feature, process, PID or user…"/><select class="gx-field" v-model="scope" aria-label="Process group"><option value="comma">Comma processes</option><option value="users">Apps and services</option><option value="all">All processes</option></select></div>
<p class="gx-note">{{ rows.length }} processes shown. {{ snapshot.cpuPercent == null ? 'Collecting the first CPU sample…' : '' }}</p>
<section class="gx-card gx-monitor__table" tabindex="0" aria-label="Process table; scroll horizontally for more columns">
<table><thead><tr><th v-for="column in [['name','Process'],['pid','PID'],['cpu','CPU'],['memoryMiB','Memory'],['user','User'],['state','Status']]" :key="column[0]" :aria-sort="ariaSort(column[0])"><button type="button" @click="sortBy(column[0])">{{ column[1] }}{{ arrow(column[0]) }}</button></th></tr></thead>
<tbody><tr v-for="process in rows" :key="process.pid"><td :title="process.feature ? process.name + ' (' + process.feature + ')' : process.name">{{ process.name }}<span v-if="process.feature" style="color:var(--primary); font-weight:500;"> ({{ process.feature }})</span></td><td>{{ process.pid }}</td><td>{{ number(process.cpu, '%') }}</td><td>{{ number(process.memoryMiB) }} MiB</td><td>{{ process.user }}</td><td><span class="gx-chip">{{ state(process.state) }}</span></td></tr><tr v-if="!rows.length"><td colspan="6" class="gx-empty">No matching processes.</td></tr></tbody></table>
</section>
</template>
</div>`,
}
@@ -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 = {
</section>
</template>
<template v-else-if="tab === 'monitor'">
<SystemMonitor />
</template>
<template v-else>
<TroubleshootPanel />
</template>
@@ -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()
@@ -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');
@@ -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');
@@ -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
@@ -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"
+17
View File
@@ -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: