diff --git a/starpilot/system/the_galaxy/assets/mobile/css/material.css b/starpilot/system/the_galaxy/assets/mobile/css/material.css index a39be23081..b1095ddaec 100644 --- a/starpilot/system/the_galaxy/assets/mobile/css/material.css +++ b/starpilot/system/the_galaxy/assets/mobile/css/material.css @@ -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; } } 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: ` +
+
+
System Monitor
{{ paused ? 'Paused' : error ? 'Connection interrupted' : 'Live updates' }} · Captured {{ captured }}
+ +
+

{{ error }}

+
{{ error ? 'Waiting for the device…' : 'Reading system activity…' }}
+ +
`, +} 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 92fa4acfd8..158f922473 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: