add nova-plugin: in-process cxx-qt stats plugin replacing nova-stats subprocess
This commit is contained in:
parent
40cc681e9a
commit
e39d47177b
19 changed files with 1893 additions and 233 deletions
269
plugin/src/cpu_service.rs
Normal file
269
plugin/src/cpu_service.rs
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
use crate::stats::cpu;
|
||||
use core::pin::Pin;
|
||||
use cxx_qt::CxxQtType;
|
||||
use cxx_qt_lib::{QList, QString};
|
||||
|
||||
#[cxx_qt::bridge]
|
||||
pub mod qobject {
|
||||
unsafe extern "C++" {
|
||||
include!("cxx-qt-lib/qstring.h");
|
||||
type QString = cxx_qt_lib::QString;
|
||||
|
||||
include!("cxx-qt-lib/core/qlist/qlist_i32.h");
|
||||
type QList_i32 = cxx_qt_lib::QList<i32>;
|
||||
|
||||
include!("cxx-qt-lib/core/qlist/qlist_f64.h");
|
||||
type QList_f64 = cxx_qt_lib::QList<f64>;
|
||||
|
||||
include!("cxx-qt-lib/core/qlist/qlist_QString.h");
|
||||
type QList_QString = cxx_qt_lib::QList<cxx_qt_lib::QString>;
|
||||
}
|
||||
|
||||
extern "RustQt" {
|
||||
#[qobject]
|
||||
#[qml_element]
|
||||
#[qml_singleton]
|
||||
#[qproperty(i32, usage)]
|
||||
#[qproperty(f64, freq_ghz, cxx_name = "freqGhz")]
|
||||
#[qproperty(QList_i32, history)]
|
||||
// JSON strings: [{usage, freq_ghz, history:[]}]
|
||||
#[qproperty(QList_QString, cores)]
|
||||
#[qproperty(QList_f64, core_max_freq, cxx_name = "coreMaxFreq")]
|
||||
#[qproperty(QList_QString, core_types, cxx_name = "coreTypes")]
|
||||
#[qproperty(bool, enable_core_history, cxx_name = "enableCoreHistory")]
|
||||
type CpuService = super::CpuServiceRust;
|
||||
|
||||
#[qinvokable]
|
||||
fn poll(self: Pin<&mut Self>);
|
||||
|
||||
#[qinvokable]
|
||||
#[cxx_name = "clearCoreHistory"]
|
||||
fn clear_core_history(self: Pin<&mut Self>);
|
||||
}
|
||||
|
||||
impl cxx_qt::Initialize for CpuService {}
|
||||
}
|
||||
|
||||
pub struct CpuServiceRust {
|
||||
prev_stat: Vec<cpu::Sample>,
|
||||
history_overall: Vec<i32>,
|
||||
core_history: Vec<Vec<i32>>,
|
||||
|
||||
usage: i32,
|
||||
freq_ghz: f64,
|
||||
history: QList<i32>,
|
||||
cores: QList<QString>,
|
||||
core_max_freq: QList<f64>,
|
||||
core_types: QList<QString>,
|
||||
enable_core_history: bool,
|
||||
}
|
||||
|
||||
impl Default for CpuServiceRust {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
prev_stat: Vec::new(),
|
||||
history_overall: Vec::new(),
|
||||
core_history: Vec::new(),
|
||||
usage: 0,
|
||||
freq_ghz: 0.0,
|
||||
history: QList::default(),
|
||||
cores: QList::default(),
|
||||
core_max_freq: QList::default(),
|
||||
core_types: QList::default(),
|
||||
enable_core_history: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl cxx_qt::Initialize for qobject::CpuService {
|
||||
fn initialize(mut self: Pin<&mut Self>) {
|
||||
let max_freqs = read_core_max_freqs();
|
||||
let types = infer_core_types(&max_freqs);
|
||||
|
||||
let mut mf_list = QList::<f64>::default();
|
||||
for f in &max_freqs {
|
||||
mf_list.append(*f);
|
||||
}
|
||||
self.as_mut().set_core_max_freq(mf_list);
|
||||
|
||||
let ct_list = strings_to_qlist(&types);
|
||||
self.as_mut().set_core_types(ct_list);
|
||||
}
|
||||
}
|
||||
|
||||
impl qobject::CpuService {
|
||||
fn poll(mut self: Pin<&mut Self>) {
|
||||
let curr = cpu::read_stat();
|
||||
let freqs = cpu::read_core_freqs();
|
||||
let prev = self.as_ref().rust().prev_stat.clone();
|
||||
|
||||
let Some(snap) = cpu::compute_snapshot(&prev, &curr, &freqs) else {
|
||||
self.as_mut().rust_mut().prev_stat = curr;
|
||||
return;
|
||||
};
|
||||
self.as_mut().rust_mut().prev_stat = curr;
|
||||
|
||||
let usage = snap.usage as i32;
|
||||
let freq_ghz = snap.freq_ghz;
|
||||
let enable = self.as_ref().rust().enable_core_history;
|
||||
let n_cores = snap.core_usage.len().max(freqs.len());
|
||||
|
||||
{
|
||||
let mut r = self.as_mut().rust_mut();
|
||||
push_capped(&mut r.history_overall, usage, 60);
|
||||
if r.core_history.len() < n_cores {
|
||||
r.core_history.resize(n_cores, Vec::new());
|
||||
}
|
||||
if enable {
|
||||
for (i, &u) in snap.core_usage.iter().enumerate() {
|
||||
push_capped(&mut r.core_history[i], u as i32, 16);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let history_qlist = vec_to_qlist_i32(&self.as_ref().rust().history_overall);
|
||||
let cores_qlist =
|
||||
build_cores_qlist(&snap, &freqs, &self.as_ref().rust().core_history, enable);
|
||||
|
||||
self.as_mut().set_usage(usage);
|
||||
self.as_mut().set_freq_ghz(freq_ghz);
|
||||
self.as_mut().set_history(history_qlist);
|
||||
self.as_mut().set_cores(cores_qlist);
|
||||
}
|
||||
|
||||
fn clear_core_history(mut self: Pin<&mut Self>) {
|
||||
for v in &mut self.as_mut().rust_mut().core_history {
|
||||
v.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_cores_qlist(
|
||||
snap: &cpu::CpuSnapshot,
|
||||
freqs: &[f64],
|
||||
core_history: &[Vec<i32>],
|
||||
enable: bool,
|
||||
) -> QList<QString> {
|
||||
let n = snap.core_usage.len().max(freqs.len());
|
||||
let mut list = QList::<QString>::default();
|
||||
for i in 0..n {
|
||||
let u = snap.core_usage.get(i).copied().unwrap_or(0) as i32;
|
||||
let f = freqs.get(i).copied().unwrap_or(0.0);
|
||||
let hist_slice = if enable {
|
||||
core_history.get(i).map(Vec::as_slice).unwrap_or(&[])
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
let hist_str = hist_slice
|
||||
.iter()
|
||||
.map(|v| v.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let json = format!(
|
||||
"{{\"usage\":{},\"freq_ghz\":{:.3},\"history\":[{}]}}",
|
||||
u, f, hist_str
|
||||
);
|
||||
list.append(QString::from(json.as_str()));
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
fn push_capped(v: &mut Vec<i32>, val: i32, max: usize) {
|
||||
v.push(val);
|
||||
if v.len() > max {
|
||||
v.drain(..v.len() - max);
|
||||
}
|
||||
}
|
||||
|
||||
fn vec_to_qlist_i32(v: &[i32]) -> QList<i32> {
|
||||
let mut list = QList::<i32>::default();
|
||||
for &x in v {
|
||||
list.append(x);
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
fn strings_to_qlist(types: &[String]) -> QList<QString> {
|
||||
let mut list = QList::<QString>::default();
|
||||
for t in types {
|
||||
list.append(QString::from(t.as_str()));
|
||||
}
|
||||
list
|
||||
}
|
||||
|
||||
fn read_core_max_freqs() -> Vec<f64> {
|
||||
let mut freqs = Vec::new();
|
||||
for i in 0.. {
|
||||
let path = format!("/sys/devices/system/cpu/cpu{i}/cpufreq/cpuinfo_max_freq");
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(s) => match s.trim().parse::<u64>() {
|
||||
Ok(khz) => freqs.push(khz as f64 / 1_000_000.0),
|
||||
Err(_) => break,
|
||||
},
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
freqs
|
||||
}
|
||||
|
||||
fn infer_core_types(max_freqs: &[f64]) -> Vec<String> {
|
||||
if let Some(types) = read_hybrid_topology(max_freqs.len()) {
|
||||
return types;
|
||||
}
|
||||
let freqs: Vec<f64> = max_freqs.iter().copied().filter(|&f| f > 0.0).collect();
|
||||
if freqs.len() < 2 {
|
||||
return Vec::new();
|
||||
}
|
||||
let max_f = freqs.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
|
||||
let min_f = freqs.iter().cloned().fold(f64::INFINITY, f64::min);
|
||||
if max_f <= 0.0 || (max_f - min_f) / max_f <= 0.15 {
|
||||
return Vec::new();
|
||||
}
|
||||
let threshold = (max_f + min_f) / 2.0;
|
||||
max_freqs
|
||||
.iter()
|
||||
.map(|&f| {
|
||||
if f >= threshold {
|
||||
"Performance".to_string()
|
||||
} else {
|
||||
"Efficiency".to_string()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_hybrid_topology(n_cores: usize) -> Option<Vec<String>> {
|
||||
let core_cpus = std::fs::read_to_string("/sys/devices/cpu_core/cpus").ok()?;
|
||||
let atom_cpus = std::fs::read_to_string("/sys/devices/cpu_atom/cpus").ok()?;
|
||||
let p_cores = expand_cpu_range(core_cpus.trim());
|
||||
let e_cores = expand_cpu_range(atom_cpus.trim());
|
||||
let max_cpu = p_cores.iter().chain(e_cores.iter()).copied().max()?;
|
||||
let count = (max_cpu + 1).max(n_cores);
|
||||
Some(
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
if e_cores.contains(&i) {
|
||||
"Efficiency".to_string()
|
||||
} else {
|
||||
"Performance".to_string()
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn expand_cpu_range(s: &str) -> std::collections::HashSet<usize> {
|
||||
let mut set = std::collections::HashSet::new();
|
||||
for part in s.split(',') {
|
||||
if let Some((a, b)) = part.split_once('-') {
|
||||
if let (Ok(lo), Ok(hi)) = (a.trim().parse::<usize>(), b.trim().parse::<usize>()) {
|
||||
for i in lo..=hi {
|
||||
set.insert(i);
|
||||
}
|
||||
}
|
||||
} else if let Ok(n) = part.trim().parse::<usize>() {
|
||||
set.insert(n);
|
||||
}
|
||||
}
|
||||
set
|
||||
}
|
||||
3
plugin/src/lib.rs
Normal file
3
plugin/src/lib.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod cpu_service;
|
||||
pub mod stats;
|
||||
pub mod system_stats;
|
||||
185
plugin/src/stats/cpu.rs
Normal file
185
plugin/src/stats/cpu.rs
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
use std::fs;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Sample {
|
||||
pub idle: u64,
|
||||
pub total: u64,
|
||||
}
|
||||
|
||||
pub fn parse_stat(input: &str) -> Vec<Sample> {
|
||||
input
|
||||
.lines()
|
||||
.filter(|l| l.starts_with("cpu"))
|
||||
.map(|l| {
|
||||
let vals: Vec<u64> = l
|
||||
.split_whitespace()
|
||||
.skip(1)
|
||||
.filter_map(|s| s.parse().ok())
|
||||
.collect();
|
||||
let idle = vals.get(3).copied().unwrap_or(0) + vals.get(4).copied().unwrap_or(0);
|
||||
let total = vals.iter().sum();
|
||||
Sample { idle, total }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn pct(prev: &Sample, curr: &Sample) -> u32 {
|
||||
let dt = curr.total.saturating_sub(prev.total);
|
||||
let di = curr.idle.saturating_sub(prev.idle);
|
||||
if dt == 0 {
|
||||
return 0;
|
||||
}
|
||||
(dt.saturating_sub(di) * 100 / dt) as u32
|
||||
}
|
||||
|
||||
pub fn read_stat() -> Vec<Sample> {
|
||||
parse_stat(&fs::read_to_string("/proc/stat").unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn read_core_freqs() -> Vec<f64> {
|
||||
let mut freqs = Vec::new();
|
||||
for i in 0.. {
|
||||
let path = format!("/sys/devices/system/cpu/cpu{i}/cpufreq/scaling_cur_freq");
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(s) => match s.trim().parse::<u64>() {
|
||||
Ok(khz) => freqs.push(khz as f64 / 1_000_000.0),
|
||||
Err(_) => break,
|
||||
},
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
freqs
|
||||
}
|
||||
|
||||
pub struct CpuSnapshot {
|
||||
pub usage: u32,
|
||||
pub freq_ghz: f64,
|
||||
pub core_usage: Vec<u32>,
|
||||
}
|
||||
|
||||
pub fn compute_snapshot(prev: &[Sample], curr: &[Sample], freqs: &[f64]) -> Option<CpuSnapshot> {
|
||||
if curr.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let usage = prev.first().zip(curr.first()).map_or(0, |(p, c)| pct(p, c));
|
||||
let core_usage: Vec<u32> = prev
|
||||
.iter()
|
||||
.skip(1)
|
||||
.zip(curr.iter().skip(1))
|
||||
.map(|(p, c)| pct(p, c))
|
||||
.collect();
|
||||
let freq_ghz = if freqs.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
freqs.iter().sum::<f64>() / freqs.len() as f64
|
||||
};
|
||||
Some(CpuSnapshot {
|
||||
usage,
|
||||
freq_ghz,
|
||||
core_usage,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample(idle: u64, total: u64) -> Sample {
|
||||
Sample { idle, total }
|
||||
}
|
||||
|
||||
// ── pct ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn pct_zero_delta_returns_zero() {
|
||||
let s = Sample {
|
||||
idle: 100,
|
||||
total: 400,
|
||||
};
|
||||
assert_eq!(pct(&s, &s), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pct_all_idle() {
|
||||
let prev = Sample {
|
||||
idle: 0,
|
||||
total: 100,
|
||||
};
|
||||
let curr = Sample {
|
||||
idle: 100,
|
||||
total: 200,
|
||||
};
|
||||
assert_eq!(pct(&prev, &curr), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pct_fully_busy() {
|
||||
let prev = Sample {
|
||||
idle: 100,
|
||||
total: 200,
|
||||
};
|
||||
let curr = Sample {
|
||||
idle: 100,
|
||||
total: 300,
|
||||
};
|
||||
assert_eq!(pct(&prev, &curr), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pct_half_busy() {
|
||||
let prev = Sample { idle: 0, total: 0 };
|
||||
let curr = Sample {
|
||||
idle: 50,
|
||||
total: 100,
|
||||
};
|
||||
assert_eq!(pct(&prev, &curr), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pct_no_underflow_on_backwards_clock() {
|
||||
let prev = Sample {
|
||||
idle: 200,
|
||||
total: 400,
|
||||
};
|
||||
let curr = Sample {
|
||||
idle: 100,
|
||||
total: 300,
|
||||
};
|
||||
assert_eq!(pct(&prev, &curr), 0);
|
||||
}
|
||||
|
||||
// ── parse_stat ───────────────────────────────────────────────────────
|
||||
|
||||
const STAT_SAMPLE: &str = "\
|
||||
cpu 100 10 50 700 40 0 0 0 0 0
|
||||
cpu0 50 5 25 350 20 0 0 0 0 0
|
||||
cpu1 50 5 25 350 20 0 0 0 0 0";
|
||||
|
||||
#[test]
|
||||
fn parse_stat_count() {
|
||||
assert_eq!(parse_stat(STAT_SAMPLE).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stat_aggregate_idle() {
|
||||
assert_eq!(parse_stat(STAT_SAMPLE)[0].idle, 740);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stat_aggregate_total() {
|
||||
assert_eq!(parse_stat(STAT_SAMPLE)[0].total, 900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stat_per_core_idle() {
|
||||
let s = parse_stat(STAT_SAMPLE);
|
||||
assert_eq!(s[1].idle, 370);
|
||||
assert_eq!(s[2].idle, 370);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_stat_ignores_non_cpu_lines() {
|
||||
let input = "intr 12345\ncpu 1 2 3 4 5 0 0 0 0 0\npage 0 0";
|
||||
assert_eq!(parse_stat(input).len(), 1);
|
||||
}
|
||||
}
|
||||
80
plugin/src/stats/disk.rs
Normal file
80
plugin/src/stats/disk.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
use std::fs;
|
||||
|
||||
pub struct DiskMount {
|
||||
pub target: String,
|
||||
pub pct: u32,
|
||||
pub used_bytes: u64,
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
|
||||
const EXCLUDED_FS: &[&str] = &[
|
||||
"tmpfs",
|
||||
"devtmpfs",
|
||||
"squashfs",
|
||||
"efivarfs",
|
||||
"overlay",
|
||||
"proc",
|
||||
"sysfs",
|
||||
"cgroup",
|
||||
"cgroup2",
|
||||
"devpts",
|
||||
"hugetlbfs",
|
||||
"mqueue",
|
||||
"nsfs",
|
||||
"pstore",
|
||||
"rpc_pipefs",
|
||||
"fusectl",
|
||||
"tracefs",
|
||||
"configfs",
|
||||
"securityfs",
|
||||
"binfmt_misc",
|
||||
"autofs",
|
||||
];
|
||||
|
||||
pub fn read_disk_mounts() -> Vec<DiskMount> {
|
||||
let content = fs::read_to_string("/proc/mounts").unwrap_or_default();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut mounts = Vec::new();
|
||||
|
||||
for line in content.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let target = parts[1];
|
||||
let fs_type = parts[2];
|
||||
if EXCLUDED_FS.contains(&fs_type) {
|
||||
continue;
|
||||
}
|
||||
if !seen.insert(target.to_string()) {
|
||||
continue;
|
||||
}
|
||||
if let Some((total, used)) = statvfs_bytes(target) {
|
||||
let pct = (used * 100 / total) as u32;
|
||||
mounts.push(DiskMount {
|
||||
target: target.to_string(),
|
||||
pct,
|
||||
used_bytes: used,
|
||||
total_bytes: total,
|
||||
});
|
||||
}
|
||||
}
|
||||
mounts
|
||||
}
|
||||
|
||||
fn statvfs_bytes(path: &str) -> Option<(u64, u64)> {
|
||||
use std::ffi::CString;
|
||||
let cpath = CString::new(path).ok()?;
|
||||
let mut st: libc::statvfs = unsafe { std::mem::zeroed() };
|
||||
let ret = unsafe { libc::statvfs(cpath.as_ptr(), &mut st) };
|
||||
if ret != 0 {
|
||||
return None;
|
||||
}
|
||||
let bsize = st.f_frsize as u64;
|
||||
let total = st.f_blocks as u64 * bsize;
|
||||
let avail = st.f_bavail as u64 * bsize;
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
Some((total, total - avail))
|
||||
}
|
||||
212
plugin/src/stats/gpu.rs
Normal file
212
plugin/src/stats/gpu.rs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
use std::fs;
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct GpuInfo {
|
||||
pub usage: u32,
|
||||
pub vram_used_gb: f64,
|
||||
pub vram_total_gb: f64,
|
||||
pub temp_c: i32,
|
||||
pub vendor: &'static str,
|
||||
}
|
||||
|
||||
pub enum GpuBackend {
|
||||
Amd {
|
||||
card_path: String,
|
||||
hwmon_path: Option<String>,
|
||||
},
|
||||
Nvidia,
|
||||
Intel {
|
||||
card_path: String,
|
||||
device_path: String,
|
||||
hwmon_path: Option<String>,
|
||||
prev_rc6: Option<(u64, Instant)>,
|
||||
},
|
||||
None,
|
||||
}
|
||||
|
||||
fn read_sysfs(path: &str) -> Option<String> {
|
||||
fs::read_to_string(path).ok().map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
fn read_sysfs_u64(path: &str) -> Option<u64> {
|
||||
read_sysfs(path)?.parse().ok()
|
||||
}
|
||||
|
||||
fn find_hwmon(driver_name: &str) -> Option<String> {
|
||||
for i in 0..32 {
|
||||
if let Some(name) = read_sysfs(&format!("/sys/class/hwmon/hwmon{i}/name")) {
|
||||
if name == driver_name {
|
||||
return Some(format!("/sys/class/hwmon/hwmon{i}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn detect_gpu() -> GpuBackend {
|
||||
// AMD: look for gpu_busy_percent exposed by the amdgpu driver
|
||||
for i in 0..8 {
|
||||
let p = format!("/sys/class/drm/card{i}/device/gpu_busy_percent");
|
||||
if fs::read_to_string(&p).is_ok() {
|
||||
let card = format!("/sys/class/drm/card{i}/device");
|
||||
let hwmon = find_hwmon("amdgpu");
|
||||
return GpuBackend::Amd {
|
||||
card_path: card,
|
||||
hwmon_path: hwmon,
|
||||
};
|
||||
}
|
||||
}
|
||||
// NVIDIA: probe nvidia-smi
|
||||
let nvidia_ok = std::process::Command::new("nvidia-smi")
|
||||
.args(["--query-gpu=name", "--format=csv,noheader"])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false);
|
||||
if nvidia_ok {
|
||||
return GpuBackend::Nvidia;
|
||||
}
|
||||
// Intel: look for i915 or xe driver
|
||||
for i in 0..8 {
|
||||
let driver_link = format!("/sys/class/drm/card{i}/device/driver");
|
||||
if let Some(drv) = fs::read_link(&driver_link)
|
||||
.ok()
|
||||
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
|
||||
{
|
||||
if drv == "i915" || drv == "xe" {
|
||||
let card_path = format!("/sys/class/drm/card{i}");
|
||||
let device_path = format!("/sys/class/drm/card{i}/device");
|
||||
let hwmon = find_hwmon(&drv);
|
||||
return GpuBackend::Intel {
|
||||
card_path,
|
||||
device_path,
|
||||
hwmon_path: hwmon,
|
||||
prev_rc6: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
GpuBackend::None
|
||||
}
|
||||
|
||||
fn read_amd(card: &str, hwmon: Option<&String>) -> Option<GpuInfo> {
|
||||
let usage: u32 = read_sysfs(&format!("{card}/gpu_busy_percent"))?
|
||||
.parse()
|
||||
.ok()?;
|
||||
let vram_used: u64 = read_sysfs(&format!("{card}/mem_info_vram_used"))?
|
||||
.parse()
|
||||
.ok()?;
|
||||
let vram_total: u64 = read_sysfs(&format!("{card}/mem_info_vram_total"))?
|
||||
.parse()
|
||||
.ok()?;
|
||||
let temp_c = hwmon
|
||||
.and_then(|h| read_sysfs(&format!("{h}/temp1_input")))
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.map_or(0, |mc| mc / 1000);
|
||||
Some(GpuInfo {
|
||||
usage,
|
||||
vram_used_gb: vram_used as f64 / 1_073_741_824.0,
|
||||
vram_total_gb: vram_total as f64 / 1_073_741_824.0,
|
||||
temp_c,
|
||||
vendor: "amd",
|
||||
})
|
||||
}
|
||||
|
||||
fn read_nvidia() -> Option<GpuInfo> {
|
||||
let out = std::process::Command::new("nvidia-smi")
|
||||
.args([
|
||||
"--query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu",
|
||||
"--format=csv,noheader,nounits",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8_lossy(&out.stdout);
|
||||
let p: Vec<&str> = s.trim().split(',').map(str::trim).collect();
|
||||
if p.len() < 4 {
|
||||
return None;
|
||||
}
|
||||
Some(GpuInfo {
|
||||
usage: p[0].parse().ok()?,
|
||||
vram_used_gb: p[1].parse::<f64>().ok()? / 1024.0,
|
||||
vram_total_gb: p[2].parse::<f64>().ok()? / 1024.0,
|
||||
temp_c: p[3].parse().ok()?,
|
||||
vendor: "nvidia",
|
||||
})
|
||||
}
|
||||
|
||||
fn read_intel(
|
||||
card: &str,
|
||||
device: &str,
|
||||
hwmon: Option<&String>,
|
||||
prev_rc6: &mut Option<(u64, Instant)>,
|
||||
) -> GpuInfo {
|
||||
let usage = read_intel_usage(card, prev_rc6).unwrap_or(0);
|
||||
|
||||
// VRAM - only present on discrete GPUs (Arc)
|
||||
let vram_total = read_sysfs_u64(&format!("{device}/mem_info_vram_total")).unwrap_or(0);
|
||||
let vram_used = if vram_total > 0 {
|
||||
read_sysfs_u64(&format!("{device}/mem_info_vram_used")).unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let temp_c = hwmon
|
||||
.and_then(|h| read_sysfs(&format!("{h}/temp1_input")))
|
||||
.and_then(|s| s.parse::<i32>().ok())
|
||||
.map_or(0, |mc| mc / 1000);
|
||||
|
||||
GpuInfo {
|
||||
usage,
|
||||
vram_used_gb: vram_used as f64 / 1_073_741_824.0,
|
||||
vram_total_gb: vram_total as f64 / 1_073_741_824.0,
|
||||
temp_c,
|
||||
vendor: "intel",
|
||||
}
|
||||
}
|
||||
|
||||
fn read_intel_usage(card: &str, prev_rc6: &mut Option<(u64, Instant)>) -> Option<u32> {
|
||||
// RC6 is the GPU idle state - compute usage from residency delta
|
||||
let rc6_ms = read_sysfs_u64(&format!("{card}/gt/gt0/rc6_residency_ms"))
|
||||
.or_else(|| read_sysfs_u64(&format!("{card}/power/rc6_residency_ms")))?;
|
||||
|
||||
let now = Instant::now();
|
||||
let usage = if let Some((prev_ms, prev_time)) = prev_rc6.take() {
|
||||
let dt_ms = now.duration_since(prev_time).as_millis() as u64;
|
||||
if dt_ms > 0 {
|
||||
let idle_ms = rc6_ms.saturating_sub(prev_ms);
|
||||
let idle_pct = (idle_ms * 100 / dt_ms).min(100);
|
||||
(100 - idle_pct) as u32
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0 // first reading, no delta yet
|
||||
};
|
||||
|
||||
*prev_rc6 = Some((rc6_ms, now));
|
||||
Some(usage)
|
||||
}
|
||||
|
||||
pub fn read_gpu(backend: &mut GpuBackend) -> Option<GpuInfo> {
|
||||
match backend {
|
||||
GpuBackend::Amd {
|
||||
card_path,
|
||||
hwmon_path,
|
||||
} => read_amd(card_path, hwmon_path.as_ref()),
|
||||
GpuBackend::Nvidia => read_nvidia(),
|
||||
GpuBackend::Intel {
|
||||
card_path,
|
||||
device_path,
|
||||
hwmon_path,
|
||||
prev_rc6,
|
||||
} => Some(read_intel(
|
||||
card_path,
|
||||
device_path,
|
||||
hwmon_path.as_ref(),
|
||||
prev_rc6,
|
||||
)),
|
||||
GpuBackend::None => None,
|
||||
}
|
||||
}
|
||||
104
plugin/src/stats/mem.rs
Normal file
104
plugin/src/stats/mem.rs
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
use std::fs;
|
||||
|
||||
pub struct MemInfo {
|
||||
pub percent: u64,
|
||||
pub used_gb: f64,
|
||||
pub total_gb: f64,
|
||||
pub avail_gb: f64,
|
||||
pub cached_gb: f64,
|
||||
pub buffers_gb: f64,
|
||||
}
|
||||
|
||||
pub fn parse_meminfo(input: &str) -> Option<MemInfo> {
|
||||
let mut total = 0u64;
|
||||
let mut avail = 0u64;
|
||||
let mut buffers = 0u64;
|
||||
let mut cached = 0u64;
|
||||
let mut sreclaimable = 0u64;
|
||||
|
||||
for line in input.lines() {
|
||||
let mut parts = line.splitn(2, ':');
|
||||
let key = parts.next().unwrap_or("").trim();
|
||||
let val: u64 = parts
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.parse()
|
||||
.unwrap_or(0);
|
||||
match key {
|
||||
"MemTotal" => total = val,
|
||||
"MemAvailable" => avail = val,
|
||||
"Buffers" => buffers = val,
|
||||
"Cached" => cached = val,
|
||||
"SReclaimable" => sreclaimable = val,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let used = total.saturating_sub(avail);
|
||||
let cached_total = cached + sreclaimable;
|
||||
let gb = |kb: u64| kb as f64 / 1_048_576.0;
|
||||
|
||||
Some(MemInfo {
|
||||
percent: used * 100 / total,
|
||||
used_gb: gb(used),
|
||||
total_gb: gb(total),
|
||||
avail_gb: gb(avail),
|
||||
cached_gb: gb(cached_total),
|
||||
buffers_gb: gb(buffers),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn read_meminfo() -> Option<MemInfo> {
|
||||
parse_meminfo(&fs::read_to_string("/proc/meminfo").unwrap_or_default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const MEMINFO_SAMPLE: &str = "\
|
||||
MemTotal: 16384000 kB
|
||||
MemFree: 2048000 kB
|
||||
MemAvailable: 4096000 kB
|
||||
Buffers: 512000 kB
|
||||
Cached: 3072000 kB
|
||||
SReclaimable: 512000 kB
|
||||
SwapTotal: 8192000 kB
|
||||
SwapFree: 8192000 kB";
|
||||
|
||||
#[test]
|
||||
fn parse_meminfo_percent() {
|
||||
let m = parse_meminfo(MEMINFO_SAMPLE).unwrap();
|
||||
assert_eq!(m.percent, 75);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_meminfo_total_gb() {
|
||||
let m = parse_meminfo(MEMINFO_SAMPLE).unwrap();
|
||||
assert!((m.total_gb - 15.625).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_meminfo_cached_includes_sreclaimable() {
|
||||
let m = parse_meminfo(MEMINFO_SAMPLE).unwrap();
|
||||
let expected = 3_584_000.0 / 1_048_576.0;
|
||||
assert!((m.cached_gb - expected).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_meminfo_zero_total_returns_none() {
|
||||
assert!(parse_meminfo("MemFree: 1000 kB\n").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_meminfo_empty_returns_none() {
|
||||
assert!(parse_meminfo("").is_none());
|
||||
}
|
||||
}
|
||||
5
plugin/src/stats/mod.rs
Normal file
5
plugin/src/stats/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod cpu;
|
||||
pub mod disk;
|
||||
pub mod gpu;
|
||||
pub mod mem;
|
||||
pub mod temp;
|
||||
45
plugin/src/stats/temp.rs
Normal file
45
plugin/src/stats/temp.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ThermalDevice {
|
||||
pub name: String,
|
||||
pub celsius: i32,
|
||||
}
|
||||
|
||||
pub fn read_thermal_devices() -> Vec<ThermalDevice> {
|
||||
let mut by_name: HashMap<String, i32> = HashMap::new();
|
||||
|
||||
for i in 0.. {
|
||||
let temp_path = format!("/sys/class/thermal/thermal_zone{i}/temp");
|
||||
let type_path = format!("/sys/class/thermal/thermal_zone{i}/type");
|
||||
|
||||
let Ok(temp_str) = fs::read_to_string(&temp_path) else {
|
||||
break;
|
||||
};
|
||||
|
||||
let millic: i32 = match temp_str.trim().parse() {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let celsius = millic / 1000;
|
||||
|
||||
let name = fs::read_to_string(&type_path)
|
||||
.map_or_else(|_| format!("zone{i}"), |s| s.trim().to_string());
|
||||
|
||||
// Keep the highest temp seen for each device type
|
||||
let entry = by_name.entry(name).or_insert(celsius);
|
||||
if celsius > *entry {
|
||||
*entry = celsius;
|
||||
}
|
||||
}
|
||||
|
||||
let mut devices: Vec<ThermalDevice> = by_name
|
||||
.into_iter()
|
||||
.map(|(name, celsius)| ThermalDevice { name, celsius })
|
||||
.collect();
|
||||
|
||||
// Sort descending by temp so the hottest shows first
|
||||
devices.sort_by(|a, b| b.celsius.cmp(&a.celsius));
|
||||
devices
|
||||
}
|
||||
225
plugin/src/system_stats.rs
Normal file
225
plugin/src/system_stats.rs
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
use crate::stats::{disk, gpu, mem, temp};
|
||||
use core::pin::Pin;
|
||||
use cxx_qt::CxxQtType;
|
||||
use cxx_qt_lib::{QList, QString};
|
||||
|
||||
#[cxx_qt::bridge]
|
||||
pub mod qobject {
|
||||
unsafe extern "C++" {
|
||||
include!("cxx-qt-lib/qstring.h");
|
||||
type QString = cxx_qt_lib::QString;
|
||||
|
||||
include!("cxx-qt-lib/core/qlist/qlist_i32.h");
|
||||
type QList_i32 = cxx_qt_lib::QList<i32>;
|
||||
|
||||
include!("cxx-qt-lib/core/qlist/qlist_QString.h");
|
||||
type QList_QString = cxx_qt_lib::QList<cxx_qt_lib::QString>;
|
||||
}
|
||||
|
||||
extern "RustQt" {
|
||||
#[qobject]
|
||||
#[qml_element]
|
||||
#[qml_singleton]
|
||||
// Temperature
|
||||
#[qproperty(i32, temp_celsius, cxx_name = "tempCelsius")]
|
||||
#[qproperty(QList_i32, temp_history, cxx_name = "tempHistory")]
|
||||
// JSON strings: [{name, celsius}]
|
||||
#[qproperty(QList_QString, temp_devices, cxx_name = "tempDevices")]
|
||||
// GPU
|
||||
#[qproperty(bool, gpu_available, cxx_name = "gpuAvailable")]
|
||||
#[qproperty(QString, gpu_vendor, cxx_name = "gpuVendor")]
|
||||
#[qproperty(i32, gpu_usage, cxx_name = "gpuUsage")]
|
||||
#[qproperty(f64, gpu_vram_used_gb, cxx_name = "gpuVramUsedGb")]
|
||||
#[qproperty(f64, gpu_vram_total_gb, cxx_name = "gpuVramTotalGb")]
|
||||
#[qproperty(i32, gpu_temp_c, cxx_name = "gpuTempC")]
|
||||
#[qproperty(QList_i32, gpu_history, cxx_name = "gpuHistory")]
|
||||
// Memory
|
||||
#[qproperty(i32, mem_percent, cxx_name = "memPercent")]
|
||||
#[qproperty(f64, mem_used_gb, cxx_name = "memUsedGb")]
|
||||
#[qproperty(f64, mem_total_gb, cxx_name = "memTotalGb")]
|
||||
#[qproperty(f64, mem_avail_gb, cxx_name = "memAvailGb")]
|
||||
#[qproperty(f64, mem_cached_gb, cxx_name = "memCachedGb")]
|
||||
#[qproperty(f64, mem_buffers_gb, cxx_name = "memBuffersGb")]
|
||||
#[qproperty(QList_i32, mem_history, cxx_name = "memHistory")]
|
||||
// Disk: JSON strings: [{target, pct, usedBytes, totalBytes}]
|
||||
#[qproperty(QList_QString, disk_mounts, cxx_name = "diskMounts")]
|
||||
#[qproperty(i32, disk_root_pct, cxx_name = "diskRootPct")]
|
||||
type SystemStatsService = super::SystemStatsServiceRust;
|
||||
|
||||
#[qinvokable]
|
||||
fn poll(self: Pin<&mut Self>);
|
||||
|
||||
#[qinvokable]
|
||||
#[cxx_name = "pollDisk"]
|
||||
fn poll_disk_invokable(self: Pin<&mut Self>);
|
||||
}
|
||||
|
||||
impl cxx_qt::Initialize for SystemStatsService {}
|
||||
}
|
||||
|
||||
pub struct SystemStatsServiceRust {
|
||||
history_temp: Vec<i32>,
|
||||
history_gpu: Vec<i32>,
|
||||
history_mem: Vec<i32>,
|
||||
gpu_backend: gpu::GpuBackend,
|
||||
|
||||
temp_celsius: i32,
|
||||
temp_history: QList<i32>,
|
||||
temp_devices: QList<QString>,
|
||||
gpu_available: bool,
|
||||
gpu_vendor: QString,
|
||||
gpu_usage: i32,
|
||||
gpu_vram_used_gb: f64,
|
||||
gpu_vram_total_gb: f64,
|
||||
gpu_temp_c: i32,
|
||||
gpu_history: QList<i32>,
|
||||
mem_percent: i32,
|
||||
mem_used_gb: f64,
|
||||
mem_total_gb: f64,
|
||||
mem_avail_gb: f64,
|
||||
mem_cached_gb: f64,
|
||||
mem_buffers_gb: f64,
|
||||
mem_history: QList<i32>,
|
||||
disk_mounts: QList<QString>,
|
||||
disk_root_pct: i32,
|
||||
}
|
||||
|
||||
impl Default for SystemStatsServiceRust {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
history_temp: Vec::new(),
|
||||
history_gpu: Vec::new(),
|
||||
history_mem: Vec::new(),
|
||||
gpu_backend: gpu::GpuBackend::None,
|
||||
temp_celsius: 0,
|
||||
temp_history: QList::default(),
|
||||
temp_devices: QList::default(),
|
||||
gpu_available: false,
|
||||
gpu_vendor: QString::default(),
|
||||
gpu_usage: 0,
|
||||
gpu_vram_used_gb: 0.0,
|
||||
gpu_vram_total_gb: 0.0,
|
||||
gpu_temp_c: 0,
|
||||
gpu_history: QList::default(),
|
||||
mem_percent: 0,
|
||||
mem_used_gb: 0.0,
|
||||
mem_total_gb: 0.0,
|
||||
mem_avail_gb: 0.0,
|
||||
mem_cached_gb: 0.0,
|
||||
mem_buffers_gb: 0.0,
|
||||
mem_history: QList::default(),
|
||||
disk_mounts: QList::default(),
|
||||
disk_root_pct: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl cxx_qt::Initialize for qobject::SystemStatsService {
|
||||
fn initialize(mut self: Pin<&mut Self>) {
|
||||
let backend = gpu::detect_gpu();
|
||||
let available = !matches!(backend, gpu::GpuBackend::None);
|
||||
let vendor = match &backend {
|
||||
gpu::GpuBackend::Amd { .. } => "amd",
|
||||
gpu::GpuBackend::Nvidia => "nvidia",
|
||||
gpu::GpuBackend::Intel { .. } => "intel",
|
||||
gpu::GpuBackend::None => "",
|
||||
};
|
||||
self.as_mut().rust_mut().gpu_backend = backend;
|
||||
self.as_mut().set_gpu_available(available);
|
||||
self.as_mut().set_gpu_vendor(QString::from(vendor));
|
||||
}
|
||||
}
|
||||
|
||||
impl qobject::SystemStatsService {
|
||||
fn poll(mut self: Pin<&mut Self>) {
|
||||
self.as_mut().poll_mem();
|
||||
self.as_mut().poll_temp();
|
||||
self.as_mut().poll_gpu();
|
||||
}
|
||||
|
||||
fn poll_disk_invokable(mut self: Pin<&mut Self>) {
|
||||
self.as_mut().poll_disk();
|
||||
}
|
||||
|
||||
fn poll_mem(mut self: Pin<&mut Self>) {
|
||||
let Some(m) = mem::read_meminfo() else { return };
|
||||
let pct = m.percent as i32;
|
||||
self.as_mut().set_mem_percent(pct);
|
||||
self.as_mut().set_mem_used_gb(m.used_gb);
|
||||
self.as_mut().set_mem_total_gb(m.total_gb);
|
||||
self.as_mut().set_mem_avail_gb(m.avail_gb);
|
||||
self.as_mut().set_mem_cached_gb(m.cached_gb);
|
||||
self.as_mut().set_mem_buffers_gb(m.buffers_gb);
|
||||
|
||||
push_history(&mut self.as_mut().rust_mut().history_mem, pct, 30);
|
||||
let hist = vec_to_qlist_i32(&self.as_ref().rust().history_mem);
|
||||
self.as_mut().set_mem_history(hist);
|
||||
}
|
||||
|
||||
fn poll_temp(mut self: Pin<&mut Self>) {
|
||||
let devices = temp::read_thermal_devices();
|
||||
if devices.is_empty() {
|
||||
return;
|
||||
}
|
||||
let max = devices.iter().map(|d| d.celsius).max().unwrap_or(0);
|
||||
self.as_mut().set_temp_celsius(max);
|
||||
|
||||
push_history(&mut self.as_mut().rust_mut().history_temp, max, 150);
|
||||
let hist = vec_to_qlist_i32(&self.as_ref().rust().history_temp);
|
||||
self.as_mut().set_temp_history(hist);
|
||||
|
||||
let mut list = QList::<QString>::default();
|
||||
for d in &devices {
|
||||
let json = format!("{{\"name\":{:?},\"celsius\":{}}}", d.name, d.celsius);
|
||||
list.append(QString::from(json.as_str()));
|
||||
}
|
||||
self.as_mut().set_temp_devices(list);
|
||||
}
|
||||
|
||||
fn poll_gpu(mut self: Pin<&mut Self>) {
|
||||
let info = gpu::read_gpu(&mut self.as_mut().rust_mut().gpu_backend);
|
||||
let Some(g) = info else { return };
|
||||
let usage = g.usage as i32;
|
||||
self.as_mut().set_gpu_usage(usage);
|
||||
self.as_mut().set_gpu_vram_used_gb(g.vram_used_gb);
|
||||
self.as_mut().set_gpu_vram_total_gb(g.vram_total_gb);
|
||||
self.as_mut().set_gpu_temp_c(g.temp_c);
|
||||
|
||||
push_history(&mut self.as_mut().rust_mut().history_gpu, usage, 60);
|
||||
let hist = vec_to_qlist_i32(&self.as_ref().rust().history_gpu);
|
||||
self.as_mut().set_gpu_history(hist);
|
||||
}
|
||||
|
||||
fn poll_disk(mut self: Pin<&mut Self>) {
|
||||
let mounts = disk::read_disk_mounts();
|
||||
let mut root_pct = 0i32;
|
||||
let mut list = QList::<QString>::default();
|
||||
for m in &mounts {
|
||||
if m.target == "/" {
|
||||
root_pct = m.pct as i32;
|
||||
}
|
||||
let json = format!(
|
||||
"{{\"target\":{:?},\"pct\":{},\"usedBytes\":{},\"totalBytes\":{}}}",
|
||||
m.target, m.pct as i32, m.used_bytes as i64, m.total_bytes as i64
|
||||
);
|
||||
list.append(QString::from(json.as_str()));
|
||||
}
|
||||
self.as_mut().set_disk_root_pct(root_pct);
|
||||
self.as_mut().set_disk_mounts(list);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_history(history: &mut Vec<i32>, value: i32, max_len: usize) {
|
||||
history.push(value);
|
||||
if history.len() > max_len {
|
||||
history.drain(..history.len() - max_len);
|
||||
}
|
||||
}
|
||||
|
||||
fn vec_to_qlist_i32(v: &[i32]) -> QList<i32> {
|
||||
let mut list = QList::<i32>::default();
|
||||
for &x in v {
|
||||
list.append(x);
|
||||
}
|
||||
list
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue