// sysinfo.js — pure parsing + issue-detection logic for the system popup. // NO I/O. Dual-mode: node `require` (tests) + QML import. Same pattern as caldav.js. // --- clamp a value into 0..1 --- function clamp01(value) { var n = Number(value); if (!isFinite(n)) return 0; return n < 0 ? 0 : (n > 1 ? 1 : n); } // --- /proc/stat first line: idle/total delta -> {usage, next}. // prev is the previous {idle, total} (null/empty on first call). curLine may be // the whole file or just the first line; only the leading "cpu " row is used. --- function cpuDelta(prev, curLine) { var line = String(curLine == null ? "" : curLine).split(/\r?\n/)[0] || ""; var parts = line.trim().split(/\s+/).slice(1).map(Number); if (parts.length < 4 || parts.some(function (n) { return !isFinite(n); })) { return { usage: 0, next: (prev && isFinite(prev.total)) ? prev : { idle: 0, total: 0 } }; } var idle = parts[3] + (parts[4] || 0); var total = parts.reduce(function (sum, n) { return sum + n; }, 0); var next = { idle: idle, total: total }; if (!prev || !isFinite(prev.total)) return { usage: 0, next: next }; var totalDelta = total - prev.total; var idleDelta = idle - prev.idle; var usage = totalDelta > 0 ? clamp01(1 - idleDelta / totalDelta) : 0; return { usage: usage, next: next }; } // --- /proc/meminfo (kB) -> bytes + usage ratios --- function parseMeminfo(text) { var values = {}; var lines = String(text == null ? "" : text).split(/\r?\n/); for (var i = 0; i < lines.length; i++) { var m = lines[i].match(/^([A-Za-z_()]+):\s+(\d+)/); if (m) values[m[1]] = Number(m[2]) * 1024; } var memTotal = Number(values.MemTotal || 0); var memAvailable = Number(values.MemAvailable != null ? values.MemAvailable : (values.MemFree || 0)); var swapTotal = Number(values.SwapTotal || 0); var swapFree = Number(values.SwapFree || 0); return { memTotal: memTotal, memAvailable: memAvailable, swapTotal: swapTotal, swapFree: swapFree, memoryUsage: memTotal > 0 ? clamp01((memTotal - memAvailable) / memTotal) : 0, swapUsage: swapTotal > 0 ? clamp01((swapTotal - swapFree) / swapTotal) : 0 }; } // --- /proc/net/dev -> {rx, tx} cumulative bytes, summed over non-lo ifaces. // After the colon: rx_bytes(0) ... | tx_bytes(8) ... --- function parseNetDev(text) { var rx = 0, tx = 0; var lines = String(text == null ? "" : text).split(/\r?\n/); for (var i = 0; i < lines.length; i++) { var colon = lines[i].indexOf(":"); if (colon < 0) continue; var iface = lines[i].slice(0, colon).trim(); if (!iface || iface === "lo") continue; var f = lines[i].slice(colon + 1).trim().split(/\s+/).map(Number); if (f.length < 9 || !isFinite(f[0]) || !isFinite(f[8])) continue; rx += f[0]; tx += f[8]; } return { rx: rx, tx: tx }; } // --- /proc/diskstats -> {readBytes, writeBytes} cumulative. // fields: major minor name reads(3) rmerged(4) sectorsRead(5) ... writes(7) wmerged(8) sectorsWritten(9) // skip loop*/ram*; bytes = sectors * 512. Sums partitions too (literal). --- function parseDiskstats(text) { var readBytes = 0, writeBytes = 0; var lines = String(text == null ? "" : text).split(/\r?\n/); for (var i = 0; i < lines.length; i++) { var p = lines[i].trim().split(/\s+/); if (p.length < 10) continue; var name = p[2]; if (/^(loop|ram)\d*/.test(name)) continue; // skip partitions (sda1, nvme0n1p1, mmcblk0p1, vda1) — their I/O is already // counted in the whole-disk line; summing both ~doubles the reported rate. if (/^sd[a-z]+\d+$|^nvme\d+n\d+p\d+$|^mmcblk\d+p\d+$|^vd[a-z]+\d+$/.test(name)) continue; var sread = Number(p[5]), swrite = Number(p[9]); if (!isFinite(sread) || !isFinite(swrite)) continue; readBytes += sread * 512; writeBytes += swrite * 512; } return { readBytes: readBytes, writeBytes: writeBytes }; } // --- generic per-second rate from two cumulative counters. Guards counter // reset (negative) and non-positive intervals -> 0. --- function rate(prev, cur, seconds) { var p = Number(prev), c = Number(cur), s = Number(seconds); if (!isFinite(p) || !isFinite(c) || !isFinite(s) || s <= 0) return 0; var d = c - p; return d > 0 ? d / s : 0; } // --- /proc/loadavg -> {one, five, fifteen} --- function parseLoadavg(text) { var p = String(text == null ? "" : text).trim().split(/\s+/); return { one: Number(p[0]) || 0, five: Number(p[1]) || 0, fifteen: Number(p[2]) || 0 }; } // --- `df -P` (or -B1) -> [{source, target, usedPercent}]. // header + pseudo rows whose capacity column is not a percent are skipped. --- function parseDf(text) { var out = []; var lines = String(text == null ? "" : text).split(/\r?\n/); for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); if (!line) continue; var p = line.split(/\s+/); if (p.length < 6) continue; var capIdx = -1; for (var j = 1; j < p.length; j++) { if (/^\d+%$/.test(p[j])) { capIdx = j; break; } } if (capIdx < 0) continue; // header / no percent column out.push({ source: p[0], target: p.slice(capIdx + 1).join(" "), usedPercent: parseInt(p[capIdx], 10) }); } return out; } // --- `systemctl --failed --no-legend --plain` -> [unit]. First token per line. --- function parseFailedUnits(text) { var out = []; var lines = String(text == null ? "" : text).split(/\r?\n/); for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); if (!line) continue; var name = line.split(/\s+/)[0]; if (name && name.indexOf(".") > 0) out.push(name); } return out; } // --- `ps -eo pid,comm,%cpu,%mem` -> [{pid, name, cpu, mem}]. Header skipped // (non-numeric pid). --- function parsePsTop(text) { var out = []; var lines = String(text == null ? "" : text).trim().split(/\r?\n/); for (var i = 0; i < lines.length; i++) { var p = lines[i].trim().split(/\s+/); if (p.length < 4) continue; var pid = Number(p[0]); if (!isFinite(pid)) continue; // header row out.push({ pid: pid, name: p[1], cpu: Number(p[2]) || 0, mem: Number(p[3]) || 0 }); } return out; } // --- parse a hwmon integer text (millidegrees -> °C) --- function parseTemp(text) { var s = String(text == null ? "" : text).trim(); if (s === "") return null; var v = Number(s); return isFinite(v) ? v / 1000 : null; } // --- parse a hwmon fan integer text (rpm) --- function parseFan(text) { var s = String(text == null ? "" : text).trim(); if (s === "") return null; var v = Number(s); return isFinite(v) ? v : null; } // --- binary formatters (verbatim thresholds from Resources.qml for visual parity) --- function formatBytes(value) { var bytes = Math.max(0, Number(value)); if (!isFinite(bytes)) bytes = 0; if (bytes >= 1.07374e+09) return (bytes / 1073741824).toFixed(1) + " GiB"; if (bytes >= 1.04858e+06) return (bytes / 1048576).toFixed(1) + " MiB"; if (bytes >= 1024) return (bytes / 1024).toFixed(1) + " KiB"; return Math.round(bytes) + " B"; } function formatRate(value) { return formatBytes(value) + "/s"; } // --- ring buffer: append value, cap to last `cap` samples. Returns a new array. --- function pushRing(buf, value, cap) { var next = (buf || []).slice(); next.push(value); var limit = Math.max(0, Number(cap) || 0); while (next.length > limit) next.shift(); return next; } // --- severity ranking for sort: error < warning < info --- function severityRank(s) { return s === "error" ? 0 : (s === "warning" ? 1 : 2); } // --- detectIssues(state) -> sorted [{id, severity, icon, label, detail, action}]. // A check whose input is absent (null/undefined) is skipped, never fired. // state fields (downstream contract): // failedUnits: [unit] | undefined // cpuTemperatureC: number | null tempHighC: number (default 87) // degradationReason: string | null (UPower PowerProfiles) // diskUsage: [{source,target,usedPercent}] | undefined // memoryUsage: 0..1 | null swapGrowing: bool // healthPercentage: number | null isLaptopBattery: bool // loadOne: number | null cores: number // updateCount: number | null function detectIssues(state) { var st = state || {}; var issues = []; // Failed services -> error if (st.failedUnits != null && st.failedUnits.length > 0) { var units = st.failedUnits.slice(); issues.push({ id: "failed-services", severity: "error", icon: "", label: units.length === 1 ? "failed service" : (units.length + " failed services"), detail: units.join(", "), action: st.failedScopes ? { kind: "systemctl-status", units: units, scopes: st.failedScopes } : { kind: "systemctl-status", units: units } }); } // Thermal -> error if power-profile reports thermal degradation, else // warning when temp >= high threshold. Skipped if no temp and no signal. var tempHigh = isFinite(Number(st.tempHighC)) ? Number(st.tempHighC) : 87; var degradeThermal = !!st.degradationReason && /thermal|temperature/i.test(String(st.degradationReason)); var tempHot = st.cpuTemperatureC != null && Number(st.cpuTemperatureC) >= tempHigh; if (degradeThermal || tempHot) { issues.push({ id: "thermal", severity: degradeThermal ? "error" : "warning", icon: "", label: degradeThermal ? "thermal throttling" : "cpu running hot", detail: st.cpuTemperatureC != null ? (Math.round(Number(st.cpuTemperatureC)) + "°C") : "thermal limit", measurements: { temperatureC: st.cpuTemperatureC, highThresholdC: tempHigh, degradationReason: st.degradationReason || null }, action: { kind: "btm" } }); } // Low disk -> warning >= 90, error >= 95. Worst mount per source device. if (st.diskUsage != null) { var worst = {}; for (var d = 0; d < st.diskUsage.length; d++) { var m = st.diskUsage[d]; var pct = Number(m.usedPercent); if (!isFinite(pct) || pct < 90) continue; var key = m.source || m.target; if (!worst[key] || pct > worst[key].usedPercent) { worst[key] = { usedPercent: pct, target: m.target, source: m.source }; } } var keys = Object.keys(worst); for (var k = 0; k < keys.length; k++) { var w = worst[keys[k]]; issues.push({ id: "low-disk:" + keys[k], severity: w.usedPercent >= 95 ? "error" : "warning", icon: "", label: "low disk space", detail: w.target + " " + w.usedPercent + "%", measurements: { source: w.source, mount: w.target, usedPercent: w.usedPercent }, action: { kind: "open-mount", target: w.target } }); } } // Memory pressure -> warning when mem high AND swap actively growing. if (st.memoryUsage != null && Number(st.memoryUsage) >= 0.9 && st.swapGrowing === true) { issues.push({ id: "mem-pressure", severity: "warning", icon: "", label: "memory pressure", detail: Math.round(Number(st.memoryUsage) * 100) + "% used, swapping", measurements: { memoryUsage: Number(st.memoryUsage), swapGrowing: st.swapGrowing }, action: { kind: "btm" } }); } // Battery health -> warning < 80, error < 60. Skipped if absent / no battery. if (st.isLaptopBattery !== false && st.healthPercentage != null) { var health = Number(st.healthPercentage); if (isFinite(health) && health < 80) { issues.push({ id: "battery-health", severity: health < 60 ? "error" : "warning", icon: "", label: "battery wear", detail: Math.round(health) + "% health", action: null }); } } // High load -> warning when 1-min load exceeds core count. if (st.loadOne != null && Number(st.cores) > 0 && Number(st.loadOne) > Number(st.cores)) { issues.push({ id: "high-load", severity: "warning", icon: "", label: "high load", detail: Number(st.loadOne).toFixed(2) + " on " + Number(st.cores) + " cores", measurements: { loadOne: Number(st.loadOne), cores: Number(st.cores) }, action: { kind: "btm" } }); } // Pending updates -> info. if (st.updateCount != null && Number(st.updateCount) > 0) { var count = Number(st.updateCount); issues.push({ id: "updates", severity: "info", icon: "", label: count === 1 ? "1 update available" : (count + " updates available"), detail: "pending packages", action: { kind: "update" } }); } issues.sort(function (a, b) { return severityRank(a.severity) - severityRank(b.severity); }); return issues; } if (typeof module !== 'undefined' && module.exports) { module.exports = { clamp01: clamp01, cpuDelta: cpuDelta, parseMeminfo: parseMeminfo, parseNetDev: parseNetDev, parseDiskstats: parseDiskstats, rate: rate, parseLoadavg: parseLoadavg, parseDf: parseDf, parseFailedUnits: parseFailedUnits, parsePsTop: parsePsTop, parseTemp: parseTemp, parseFan: parseFan, formatBytes: formatBytes, formatRate: formatRate, pushRing: pushRing, detectIssues: detectIssues }; }