initial commit

This commit is contained in:
Andy Carlson 2023-01-23 00:31:16 -05:00 committed by Andy Carlson
commit 97c581a3fc
93 changed files with 12846 additions and 0 deletions

53
.eslintrc.cjs Normal file
View File

@ -0,0 +1,53 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"prettier"
],
plugins: ["svelte3", "@typescript-eslint"],
overrides: [{ files: ["*.svelte"], processor: "svelte3/svelte3" }],
settings: {
"svelte3/typescript": () => require("typescript")
},
parserOptions: {
sourceType: "module",
ecmaVersion: 2022
},
env: {
browser: true,
es2017: true,
node: true
},
globals: {
Data: true,
SummaryData: true,
CPUData: true,
MemData: true,
NetData: true,
DiskData: true,
TempData: true,
IOData: true,
BatteryData: true,
Process: true
},
rules: {
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unused-vars": "off",
camelcase: ["error", { properties: "never", ignoreDestructuring: true }],
eqeqeq: "error",
"func-names": "error",
"import/order": "error",
"import/no-unresolved": "off",
"no-unused-vars": ["error", { varsIgnorePattern: "^_", argsIgnorePattern: "^_" }],
"no-var": "error",
"prefer-arrow-callback": "error",
"prefer-const": "off",
"require-await": "error"
}
}

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
.prettierignore Normal file
View File

@ -0,0 +1,3 @@
/src-tauri
package-lock.json

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"useTabs": false,
"singleQuote": false,
"trailingComma": "none",
"printWidth": 100,
"semi": false,
"arrowParens": "avoid"
}

1
README.md Normal file
View File

@ -0,0 +1 @@
A work-in-progress system monitoring GUI inspired by Conky Seamod.

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + Svelte + TS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3814
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "toerings",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json",
"pretty": "prettier --write --plugin-search-dir . .",
"lint": "eslint --ignore-path .prettierignore .",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^1.2.0",
"lodash-es": "^4.17.21",
"path-browserify": "^1.0.1",
"uplot": "^1.6.23"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^2.0.0",
"@tauri-apps/cli": "^1.2.2",
"@tsconfig/svelte": "^3.0.0",
"@types/lodash-es": "^4.17.6",
"@types/node": "^18.7.10",
"@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.51.0",
"colord": "^2.9.3",
"eslint": "^8.34.0",
"eslint-config-prettier": "^8.6.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-svelte3": "^4.0.0",
"prettier": "^2.8.4",
"prettier-plugin-svelte": "^2.9.0",
"svelte": "^3.54.0",
"svelte-awesome-color-picker": "^2.4.1",
"svelte-check": "^3.0.0",
"svelte-preprocess": "^5.0.0",
"tslib": "^2.4.1",
"typescript": "^4.6.4",
"vite": "^4.0.0"
}
}

0
public/.gitkeep Normal file
View File

BIN
public/example1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

BIN
public/example2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

BIN
public/example3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

6
src-tauri/.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
# Generated by Cargo
# will have compiled files and executables
/target/
log.txt

4107
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

74
src-tauri/Cargo.toml Normal file
View File

@ -0,0 +1,74 @@
[package]
name = "toerings"
version = "0.0.0"
description = "A spiritual port of Conky SeaMod to Tauri."
authors = ["Andrew Carlson <2yinyang2@gmail.com>"]
license = "MIT"
repository = "https://github.com/acarl005/toerings"
edition = "2021"
rust-version = "1.66"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "1.2", features = [] }
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_millis = "0.1.1"
tauri = { version = "1.2", features = ["macos-private-api", "shell-open", "window-set-size", "window-start-dragging"] }
sysinfo = "0.27.7"
anyhow = "1.0.68"
backtrace = "0.3.67"
cfg-if = "1.0.0"
futures = "0.3.25"
futures-timer = "3.0.2"
fxhash = "0.2.1"
once_cell = "1.17.0"
itertools = "0.10.5"
thiserror = "1.0.38"
humantime = "2.1.0"
humantime-serde = "1.1.1"
local-ip-address = "0.5.1"
time = { version = "0.3.9", features = ["formatting", "macros"] }
fern = { version = "0.6.1", optional = true }
log = { version = "0.4.17", optional = true }
starship-battery = { version = "0.7.9", optional = true }
nvml-wrapper = { version = "0.8.0", optional = true }
[target.'cfg(unix)'.dependencies]
libc = "0.2.139"
[target.'cfg(target_os = "linux")'.dependencies]
heim = { git = "https://github.com/heim-rs/heim", features = ["cpu", "disk", "memory", "net", "sensors"] }
procfs = { version = "0.14.2", default-features = false }
smol = "1.2.5"
[target.'cfg(target_os = "macos")'.dependencies]
heim = { git = "https://github.com/heim-rs/heim", features = ["cpu", "disk", "memory", "net"] }
mach2 = "0.4.1"
[target.'cfg(target_os = "windows")'.dependencies]
heim = { git = "https://github.com/heim-rs/heim", features = ["cpu", "disk", "memory"] }
windows = { version = "0.44.0", features = ["Win32_System_Threading", "Win32_Foundation"] }
winapi = "0.3.9"
[target.'cfg(target_os = "freebsd")'.dependencies]
serde_json = { version = "1.0.82" }
sysctl = { version = "0.5.2", optional = true }
filedescriptor = "0.8.2"
[features]
battery = ["starship-battery"]
gpu = ["nvidia"]
nvidia = ["nvml-wrapper"]
zfs = ["sysctl"]
# by default Tauri runs in production mode
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` is an URL
default = ["custom-protocol", "fern", "log"]
# this feature is used used for production builds where `devPath` points to the filesystem
# DO NOT remove this
custom-protocol = ["tauri/custom-protocol"]

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

3
src-tauri/icons/icon.svg Normal file
View File

@ -0,0 +1,3 @@
<svg viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg" fill="transparent" stroke-linecap="round">
<path d="M60,115 A55,55 0 1,1 115,60" stroke="black" stroke-width="10px" />
</svg>

After

Width:  |  Height:  |  Size: 190 B

BIN
src-tauri/icons/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -0,0 +1,465 @@
//! This is the main file to house data collection functions.
use std::{
net::IpAddr,
time::{Duration, Instant},
};
use futures::join;
#[cfg(target_os = "linux")]
use fxhash::FxHashMap;
use serde::Serialize;
#[cfg(feature = "battery")]
use starship_battery::{Battery, Manager};
use sysinfo::{System, SystemExt};
#[cfg(feature = "nvidia")]
pub mod nvidia;
#[cfg(feature = "battery")]
pub mod batteries;
pub mod cpu;
pub mod disks;
pub mod memory;
pub mod network;
pub mod processes;
pub mod temperature;
#[derive(Clone, Debug, Serialize)]
pub struct Data {
#[serde(with = "serde_millis")]
pub last_collection_time: Instant,
pub cpu: Option<cpu::CpuHarvest>,
pub load_avg: Option<cpu::LoadAvgHarvest>,
pub memory: Option<memory::MemHarvest>,
pub swap: Option<memory::MemHarvest>,
pub temperature_sensors: Option<Vec<temperature::TempHarvest>>,
pub network: Option<network::NetworkHarvest>,
pub list_of_processes: Option<Vec<processes::ProcessHarvest>>,
pub disks: Option<Vec<disks::DiskHarvest>>,
pub io: Option<disks::IoHarvest>,
#[serde(with = "humantime_serde")]
#[serde(default)]
pub uptime: Duration,
pub hostname: Option<String>,
pub kernel_name: Option<String>,
pub kernel_version: Option<String>,
pub os_version: Option<String>,
pub local_ip: Option<IpAddr>,
#[cfg(feature = "battery")]
pub list_of_batteries: Option<Vec<batteries::BatteryHarvest>>,
#[cfg(feature = "zfs")]
pub arc: Option<memory::MemHarvest>,
#[cfg(feature = "gpu")]
pub gpu: Option<Vec<(String, memory::MemHarvest)>>,
}
impl Default for Data {
fn default() -> Self {
Data {
last_collection_time: Instant::now(),
cpu: None,
load_avg: None,
memory: None,
swap: None,
temperature_sensors: None,
list_of_processes: None,
disks: None,
io: None,
network: None,
uptime: Duration::ZERO,
hostname: None,
kernel_name: None,
kernel_version: None,
os_version: None,
local_ip: None,
#[cfg(feature = "battery")]
list_of_batteries: None,
#[cfg(feature = "zfs")]
arc: None,
#[cfg(feature = "gpu")]
gpu: None,
}
}
}
impl Data {
pub fn cleanup(&mut self) {
self.io = None;
self.temperature_sensors = None;
self.list_of_processes = None;
self.disks = None;
self.memory = None;
self.swap = None;
self.cpu = None;
self.load_avg = None;
if let Some(network) = &mut self.network {
network.first_run_cleanup();
}
#[cfg(feature = "zfs")]
{
self.arc = None;
}
#[cfg(feature = "gpu")]
{
self.gpu = None;
}
}
}
#[derive(Debug)]
pub struct DataCollector {
pub data: Data,
sys: System,
previous_cpu_times: Vec<(cpu::PastCpuWork, cpu::PastCpuTotal)>,
previous_average_cpu_time: Option<(cpu::PastCpuWork, cpu::PastCpuTotal)>,
#[cfg(target_os = "linux")]
pid_mapping: FxHashMap<crate::Pid, processes::PrevProcDetails>,
#[cfg(target_os = "linux")]
prev_idle: f64,
#[cfg(target_os = "linux")]
prev_non_idle: f64,
mem_total_kb: u64,
use_current_cpu_total: bool,
unnormalized_cpu: bool,
last_collection_time: Instant,
total_rx: u64,
total_tx: u64,
show_average_cpu: bool,
#[cfg(feature = "battery")]
battery_manager: Option<Manager>,
#[cfg(feature = "battery")]
battery_list: Option<Vec<Battery>>,
#[cfg(target_family = "unix")]
user_table: self::processes::UserTable,
}
impl DataCollector {
pub fn new() -> Self {
DataCollector {
data: Data::default(),
sys: System::new_with_specifics(sysinfo::RefreshKind::new()),
previous_cpu_times: vec![],
previous_average_cpu_time: None,
#[cfg(target_os = "linux")]
pid_mapping: FxHashMap::default(),
#[cfg(target_os = "linux")]
prev_idle: 0_f64,
#[cfg(target_os = "linux")]
prev_non_idle: 0_f64,
mem_total_kb: 0,
use_current_cpu_total: false,
unnormalized_cpu: false,
last_collection_time: Instant::now(),
total_rx: 0,
total_tx: 0,
show_average_cpu: false,
#[cfg(feature = "battery")]
battery_manager: None,
#[cfg(feature = "battery")]
battery_list: None,
#[cfg(target_family = "unix")]
user_table: Default::default(),
}
}
pub fn init(&mut self) {
#[cfg(target_os = "linux")]
{
futures::executor::block_on(self.initialize_memory_size());
}
#[cfg(not(target_os = "linux"))]
{
self.sys.refresh_memory();
self.mem_total_kb = self.sys.total_memory();
// TODO: Would be good to get this and network list running on a timer instead...?
// Refresh components list once...
self.sys.refresh_components_list();
// Refresh network list once...
if cfg!(target_os = "windows") {
self.sys.refresh_networks_list();
}
self.sys.refresh_cpu();
// Refresh disk list once...
if cfg!(target_os = "freebsd") {
self.sys.refresh_disks_list();
}
}
#[cfg(feature = "battery")]
{
if let Ok(battery_manager) = Manager::new() {
if let Ok(batteries) = battery_manager.batteries() {
let battery_list: Vec<Battery> = batteries.filter_map(Result::ok).collect();
if !battery_list.is_empty() {
self.battery_list = Some(battery_list);
self.battery_manager = Some(battery_manager);
}
}
}
}
futures::executor::block_on(self.update_data());
std::thread::sleep(std::time::Duration::from_millis(250));
self.data.cleanup();
}
#[cfg(target_os = "linux")]
async fn initialize_memory_size(&mut self) {
self.mem_total_kb = if let Ok(mem) = heim::memory::memory().await {
mem.total().get::<heim::units::information::kilobyte>()
} else {
1
};
}
pub async fn update_data(&mut self) {
#[cfg(not(target_os = "linux"))]
{
self.sys.refresh_cpu();
self.sys.refresh_processes();
self.sys.refresh_components();
#[cfg(target_os = "windows")]
{
self.sys.refresh_networks();
}
#[cfg(target_os = "freebsd")]
{
self.sys.refresh_disks();
self.sys.refresh_memory();
}
}
let current_instant = std::time::Instant::now();
// CPU
#[cfg(not(target_os = "freebsd"))]
{
if let Ok(cpu_data) = cpu::get_cpu_data_list(
self.show_average_cpu,
&mut self.previous_cpu_times,
&mut self.previous_average_cpu_time,
)
.await
{
self.data.cpu = Some(cpu_data);
}
}
#[cfg(target_os = "freebsd")]
{
if let Ok(cpu_data) = cpu::get_cpu_data_list(
&self.sys,
self.show_average_cpu,
&mut self.previous_cpu_times,
&mut self.previous_average_cpu_time,
)
.await
{
self.data.cpu = Some(cpu_data);
}
}
#[cfg(target_family = "unix")]
{
// Load Average
if let Ok(load_avg_data) = cpu::get_load_avg().await {
self.data.load_avg = Some(load_avg_data);
}
}
// Batteries
#[cfg(feature = "battery")]
{
if let Some(battery_manager) = &self.battery_manager {
if let Some(battery_list) = &mut self.battery_list {
self.data.list_of_batteries =
Some(batteries::refresh_batteries(battery_manager, battery_list));
}
}
}
if let Ok(mut process_list) = {
#[cfg(target_os = "linux")]
{
// Must do this here since we otherwise have to make `get_process_data` async.
use self::processes::CpuUsageStrategy;
let normalize_cpu = if self.unnormalized_cpu {
heim::cpu::logical_count()
.await
.map(|v| CpuUsageStrategy::NonNormalized(v as f64))
.unwrap_or(CpuUsageStrategy::Normalized)
} else {
CpuUsageStrategy::Normalized
};
processes::get_process_data(
&mut self.prev_idle,
&mut self.prev_non_idle,
&mut self.pid_mapping,
self.use_current_cpu_total,
normalize_cpu,
current_instant
.duration_since(self.last_collection_time)
.as_secs(),
self.mem_total_kb,
&mut self.user_table,
)
}
#[cfg(not(target_os = "linux"))]
{
#[cfg(target_family = "unix")]
{
processes::get_process_data(
&self.sys,
self.use_current_cpu_total,
self.unnormalized_cpu,
self.mem_total_kb,
&mut self.user_table,
)
}
#[cfg(not(target_family = "unix"))]
{
processes::get_process_data(
&self.sys,
self.use_current_cpu_total,
self.unnormalized_cpu,
self.mem_total_kb,
)
}
}
} {
// NB: To avoid duplicate sorts on rerenders/events, we sort the processes by PID here.
// We also want to avoid re-sorting *again* later on if we're sorting by PID, since we already
// did it here!
process_list.sort_unstable_by_key(|p| p.pid);
self.data.list_of_processes = Some(process_list);
}
#[cfg(not(target_os = "linux"))]
{
if let Ok(data) = temperature::get_temperature_data(&self.sys) {
self.data.temperature_sensors = data;
}
}
#[cfg(target_os = "linux")]
{
if let Ok(data) = temperature::get_temperature_data() {
self.data.temperature_sensors = data;
}
}
let network_data_fut = {
#[cfg(any(target_os = "windows", target_os = "freebsd"))]
{
network::get_network_data(
&self.sys,
self.last_collection_time,
&mut self.total_rx,
&mut self.total_tx,
current_instant,
)
}
#[cfg(not(any(target_os = "windows", target_os = "freebsd")))]
{
network::get_network_data(
self.last_collection_time,
&mut self.total_rx,
&mut self.total_tx,
current_instant,
)
}
};
let mem_data_fut = {
#[cfg(not(target_os = "freebsd"))]
{
memory::get_mem_data()
}
#[cfg(target_os = "freebsd")]
{
memory::get_mem_data(&self.sys)
}
};
let disk_data_fut = disks::get_disk_usage();
let disk_io_usage_fut = disks::get_io_usage();
let (net_data, mem_res, disk_res, io_res) = join!(
network_data_fut,
mem_data_fut,
disk_data_fut,
disk_io_usage_fut,
);
if let Ok(net_data) = net_data {
if let Some(net_data) = &net_data {
self.total_rx = net_data.total_rx;
self.total_tx = net_data.total_tx;
}
self.data.network = net_data;
}
if let Ok(memory) = mem_res.ram {
self.data.memory = memory;
}
if let Ok(swap) = mem_res.swap {
self.data.swap = swap;
}
#[cfg(feature = "zfs")]
if let Ok(arc) = mem_res.arc {
self.data.arc = arc;
}
#[cfg(feature = "gpu")]
if let Ok(gpu) = mem_res.gpus {
self.data.gpu = gpu;
}
if let Ok(disks) = disk_res {
self.data.disks = disks;
}
if let Ok(io) = io_res {
self.data.io = io;
}
self.data.uptime = Duration::from_secs(self.sys.uptime());
self.data.hostname = self.sys.host_name();
self.data.kernel_name = self.sys.name();
self.data.kernel_version = self.sys.kernel_version();
self.data.os_version = self.sys.long_os_version();
self.data.local_ip = local_ip_address::local_ip().ok();
// Update time
self.data.last_collection_time = current_instant;
self.last_collection_time = current_instant;
}
}
#[cfg(target_os = "freebsd")]
/// Deserialize [libxo](https://www.freebsd.org/cgi/man.cgi?query=libxo&apropos=0&sektion=0&manpath=FreeBSD+13.1-RELEASE+and+Ports&arch=default&format=html) JSON data
fn deserialize_xo<T>(key: &str, data: &[u8]) -> Result<T, std::io::Error>
where
T: serde::de::DeserializeOwned,
{
let mut value: serde_json::Value = serde_json::from_slice(data)?;
value
.as_object_mut()
.and_then(|map| map.remove(key))
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::Other, "key not found"))
.and_then(|val| serde_json::from_value(val).map_err(|err| err.into()))
}

View File

@ -0,0 +1,10 @@
//! Data collection for batteries.
//!
//! For Linux, macOS, Windows, FreeBSD, Dragonfly, and iOS, this is handled by the battery crate.
cfg_if::cfg_if! {
if #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux", target_os = "freebsd", target_os = "dragonfly", target_os = "ios"))] {
pub mod battery;
pub use self::battery::*;
}
}

View File

@ -0,0 +1,49 @@
//! Uses the battery crate from svartalf.
//! Covers battery usage for:
//! - Linux 2.6.39+
//! - MacOS 10.10+
//! - iOS
//! - Windows 7+
//! - FreeBSD
//! - DragonFlyBSD
//!
//! For more information, refer to the [starship_battery](https://github.com/starship/rust-battery) repo/docs.
use starship_battery::{
units::{power::watt, ratio::percent, time::second},
Battery, Manager,
};
#[derive(Debug, Clone, Serialize)]
pub struct BatteryHarvest {
pub charge_percent: f64,
pub secs_until_full: Option<i64>,
pub secs_until_empty: Option<i64>,
pub power_consumption_rate_watts: f64,
pub health_percent: f64,
}
pub fn refresh_batteries(manager: &Manager, batteries: &mut [Battery]) -> Vec<BatteryHarvest> {
batteries
.iter_mut()
.filter_map(|battery| {
if manager.refresh(battery).is_ok() {
Some(BatteryHarvest {
secs_until_full: {
let optional_time = battery.time_to_full();
optional_time.map(|time| f64::from(time.get::<second>()) as i64)
},
secs_until_empty: {
let optional_time = battery.time_to_empty();
optional_time.map(|time| f64::from(time.get::<second>()) as i64)
},
charge_percent: f64::from(battery.state_of_charge().get::<percent>()),
power_consumption_rate_watts: f64::from(battery.energy_rate().get::<watt>()),
health_percent: f64::from(battery.state_of_health().get::<percent>()),
})
} else {
None
}
})
.collect::<Vec<_>>()
}

View File

@ -0,0 +1,38 @@
//! Data collection for CPU usage and load average.
//!
//! For CPU usage, Linux, macOS, and Windows are handled by Heim, FreeBSD by sysinfo.
//!
//! For load average, macOS and Linux are supported through Heim, FreeBSD by sysinfo.
use serde::Serialize;
cfg_if::cfg_if! {
if #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] {
pub mod heim;
pub use self::heim::*;
} else if #[cfg(target_os = "freebsd")] {
pub mod sysinfo;
pub use self::sysinfo::*;
}
}
pub type LoadAvgHarvest = [f32; 3];
#[derive(Debug, Clone, Copy, Serialize)]
pub enum CpuDataType {
Avg,
Cpu(usize),
}
#[derive(Debug, Clone, Serialize)]
pub struct CpuData {
pub data_type: CpuDataType,
pub cpu_usage: f64,
}
pub type CpuHarvest = Vec<CpuData>;
pub type PastCpuWork = f64;
pub type PastCpuTotal = f64;
pub type Point = (f64, f64);

View File

@ -0,0 +1,159 @@
//! CPU stats through heim.
//! Supports macOS, Linux, and Windows.
cfg_if::cfg_if! {
if #[cfg(target_os = "linux")] {
pub mod linux;
pub use linux::*;
} else if #[cfg(any(target_os = "macos", target_os = "windows"))] {
pub mod windows_macos;
pub use windows_macos::*;
}
}
cfg_if::cfg_if! {
if #[cfg(target_family = "unix")] {
pub mod unix;
pub use unix::*;
}
}
use std::collections::VecDeque;
use futures::StreamExt;
use crate::data_harvester::cpu::{
CpuData, CpuDataType, CpuHarvest, PastCpuTotal, PastCpuWork, Point,
};
pub async fn get_cpu_data_list(
show_average_cpu: bool,
previous_cpu_times: &mut Vec<(PastCpuWork, PastCpuTotal)>,
previous_average_cpu_time: &mut Option<(PastCpuWork, PastCpuTotal)>,
) -> crate::error::Result<CpuHarvest> {
fn calculate_cpu_usage_percentage(
(previous_working_time, previous_total_time): Point,
(current_working_time, current_total_time): Point,
) -> f64 {
((if current_working_time > previous_working_time {
current_working_time - previous_working_time
} else {
0.0
}) * 100.0)
/ (if current_total_time > previous_total_time {
current_total_time - previous_total_time
} else {
1.0
})
}
// Get all CPU times...
let cpu_times = heim::cpu::times().await?;
futures::pin_mut!(cpu_times);
let mut cpu_deque: VecDeque<CpuData> = if previous_cpu_times.is_empty() {
// Must initialize ourselves. Use a very quick timeout to calculate an initial.
futures_timer::Delay::new(std::time::Duration::from_millis(100)).await;
let second_cpu_times = heim::cpu::times().await?;
futures::pin_mut!(second_cpu_times);
let mut new_cpu_times: Vec<(PastCpuWork, PastCpuTotal)> = Vec::new();
let mut cpu_deque: VecDeque<CpuData> = VecDeque::new();
let mut collected_zip = cpu_times.zip(second_cpu_times).enumerate(); // Gotta move it here, can't on while line.
while let Some((itx, (past, present))) = collected_zip.next().await {
if let (Ok(past), Ok(present)) = (past, present) {
let present_times = convert_cpu_times(&present);
new_cpu_times.push(present_times);
cpu_deque.push_back(CpuData {
data_type: CpuDataType::Cpu(itx),
cpu_usage: calculate_cpu_usage_percentage(
convert_cpu_times(&past),
present_times,
),
});
} else {
new_cpu_times.push((0.0, 0.0));
cpu_deque.push_back(CpuData {
data_type: CpuDataType::Cpu(itx),
cpu_usage: 0.0,
});
}
}
*previous_cpu_times = new_cpu_times;
cpu_deque
} else {
let (new_cpu_times, cpu_deque): (Vec<(PastCpuWork, PastCpuTotal)>, VecDeque<CpuData>) =
cpu_times
.collect::<Vec<_>>()
.await
.iter()
.zip(&*previous_cpu_times)
.enumerate()
.map(|(itx, (current_cpu, (past_cpu_work, past_cpu_total)))| {
if let Ok(cpu_time) = current_cpu {
let present_times = convert_cpu_times(cpu_time);
(
present_times,
CpuData {
data_type: CpuDataType::Cpu(itx),
cpu_usage: calculate_cpu_usage_percentage(
(*past_cpu_work, *past_cpu_total),
present_times,
),
},
)
} else {
(
(*past_cpu_work, *past_cpu_total),
CpuData {
data_type: CpuDataType::Cpu(itx),
cpu_usage: 0.0,
},
)
}
})
.unzip();
*previous_cpu_times = new_cpu_times;
cpu_deque
};
// Get average CPU if needed... and slap it at the top
if show_average_cpu {
let cpu_time = heim::cpu::time().await?;
let (cpu_usage, new_average_cpu_time) = if let Some((past_cpu_work, past_cpu_total)) =
previous_average_cpu_time
{
let present_times = convert_cpu_times(&cpu_time);
(
calculate_cpu_usage_percentage((*past_cpu_work, *past_cpu_total), present_times),
present_times,
)
} else {
// Again, we need to do a quick timeout...
futures_timer::Delay::new(std::time::Duration::from_millis(100)).await;
let second_cpu_time = heim::cpu::time().await?;
let present_times = convert_cpu_times(&second_cpu_time);
(
calculate_cpu_usage_percentage(convert_cpu_times(&cpu_time), present_times),
present_times,
)
};
*previous_average_cpu_time = Some(new_average_cpu_time);
cpu_deque.push_front(CpuData {
data_type: CpuDataType::Avg,
cpu_usage,
})
}
// Ok(Vec::from(cpu_deque.drain(0..3).collect::<Vec<_>>())) // For artificially limiting the CPU results
Ok(Vec::from(cpu_deque))
}

View File

@ -0,0 +1,19 @@
//! Linux-specific functions regarding CPU usage.
use heim::cpu::os::linux::CpuTimeExt;
use crate::data_harvester::cpu::Point;
pub fn convert_cpu_times(cpu_time: &heim::cpu::CpuTime) -> Point {
let working_time: f64 = (cpu_time.user()
+ cpu_time.nice()
+ cpu_time.system()
+ cpu_time.irq()
+ cpu_time.soft_irq()
+ cpu_time.steal())
.get::<heim::units::time::second>();
(
working_time,
working_time + (cpu_time.idle() + cpu_time.io_wait()).get::<heim::units::time::second>(),
)
}

View File

@ -0,0 +1,13 @@
//! Unix-specific functions regarding CPU usage.
use crate::data_harvester::cpu::LoadAvgHarvest;
pub async fn get_load_avg() -> crate::error::Result<LoadAvgHarvest> {
let (one, five, fifteen) = heim::cpu::os::unix::loadavg().await?;
Ok([
one.get::<heim::units::ratio::ratio>(),
five.get::<heim::units::ratio::ratio>(),
fifteen.get::<heim::units::ratio::ratio>(),
])
}

View File

@ -0,0 +1,12 @@
//! Windows and macOS-specific functions regarding CPU usage.
use crate::data_harvester::cpu::Point;
pub fn convert_cpu_times(cpu_time: &heim::cpu::CpuTime) -> Point {
let working_time: f64 =
(cpu_time.user() + cpu_time.system()).get::<heim::units::time::second>();
(
working_time,
working_time + cpu_time.idle().get::<heim::units::time::second>(),
)
}

View File

@ -0,0 +1,44 @@
//! CPU stats through sysinfo.
//! Supports FreeBSD.
use std::collections::VecDeque;
use sysinfo::{CpuExt, LoadAvg, System, SystemExt};
use super::{CpuData, CpuDataType, CpuHarvest, PastCpuTotal, PastCpuWork};
use crate::data_harvester::cpu::LoadAvgHarvest;
pub async fn get_cpu_data_list(
sys: &sysinfo::System,
show_average_cpu: bool,
_previous_cpu_times: &mut [(PastCpuWork, PastCpuTotal)],
_previous_average_cpu_time: &mut Option<(PastCpuWork, PastCpuTotal)>,
) -> crate::error::Result<CpuHarvest> {
let mut cpu_deque: VecDeque<_> = sys
.cpus()
.iter()
.enumerate()
.map(|(i, cpu)| CpuData {
data_type: CpuDataType::Cpu(i),
cpu_usage: cpu.cpu_usage() as f64,
})
.collect();
if show_average_cpu {
let cpu = sys.global_cpu_info();
cpu_deque.push_front(CpuData {
data_type: CpuDataType::Avg,
cpu_usage: cpu.cpu_usage() as f64,
})
}
Ok(Vec::from(cpu_deque))
}
pub async fn get_load_avg() -> crate::error::Result<LoadAvgHarvest> {
let sys = System::new();
let LoadAvg { one, five, fifteen } = sys.load_average();
Ok([one as f32, five as f32, fifteen as f32])
}

View File

@ -0,0 +1,33 @@
//! Data collection for disks (IO, usage, space, etc.).
//!
//! For Linux, macOS, and Windows, this is handled by heim. For FreeBSD there is a custom
//! implementation.
use serde::Serialize;
cfg_if::cfg_if! {
if #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] {
pub mod heim;
pub use self::heim::*;
} else if #[cfg(target_os = "freebsd")] {
pub mod freebsd;
pub use self::freebsd::*;
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct DiskHarvest {
pub name: String,
pub mount_point: String,
pub free_space: Option<u64>,
pub used_space: Option<u64>,
pub total_space: Option<u64>,
}
#[derive(Clone, Debug, Serialize)]
pub struct IoData {
pub read_bytes: u64,
pub write_bytes: u64,
}
pub type IoHarvest = std::collections::HashMap<String, Option<IoData>>;

View File

@ -0,0 +1,107 @@
//! Disk stats for FreeBSD.
use std::io;
use serde::Deserialize;
use super::{DiskHarvest, IoHarvest};
use crate::app::Filter;
use crate::data_harvester::deserialize_xo;
#[derive(Deserialize, Debug, Default)]
#[serde(rename_all = "kebab-case")]
struct StorageSystemInformation {
filesystem: Vec<FileSystem>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
struct FileSystem {
name: String,
total_blocks: u64,
used_blocks: u64,
available_blocks: u64,
mounted_on: String,
}
pub async fn get_io_usage(actually_get: bool) -> crate::utils::error::Result<Option<IoHarvest>> {
if !actually_get {
return Ok(None);
}
let io_harvest = get_disk_info().map(|storage_system_information| {
storage_system_information
.filesystem
.into_iter()
.map(|disk| (disk.name, None))
.collect()
})?;
Ok(Some(io_harvest))
}
pub async fn get_disk_usage(
actually_get: bool,
disk_filter: &Option<Filter>,
mount_filter: &Option<Filter>,
) -> crate::utils::error::Result<Option<Vec<DiskHarvest>>> {
if !actually_get {
return Ok(None);
}
let vec_disks: Vec<DiskHarvest> = get_disk_info().map(|storage_system_information| {
storage_system_information
.filesystem
.into_iter()
.filter_map(|disk| {
// Precedence ordering in the case where name and mount filters disagree, "allow"
// takes precedence over "deny".
//
// For implementation, we do this as follows:
//
// 1. Is the entry allowed through any filter? That is, does it match an entry in a
// filter where `is_list_ignored` is `false`? If so, we always keep this entry.
// 2. Is the entry denied through any filter? That is, does it match an entry in a
// filter where `is_list_ignored` is `true`? If so, we always deny this entry.
// 3. Anything else is allowed.
let filter_check_map =
[(disk_filter, &disk.name), (mount_filter, &disk.mounted_on)];
if matches_allow_list(filter_check_map.as_slice())
|| !matches_ignore_list(filter_check_map.as_slice())
{
Some(DiskHarvest {
free_space: Some(disk.available_blocks * 1024),
used_space: Some(disk.used_blocks * 1024),
total_space: Some(disk.total_blocks * 1024),
mount_point: disk.mounted_on,
name: disk.name,
})
} else {
None
}
})
.collect()
})?;
Ok(Some(vec_disks))
}
fn matches_allow_list(filter_check_map: &[(&Option<Filter>, &String)]) -> bool {
filter_check_map.iter().any(|(filter, text)| match filter {
Some(f) if !f.is_list_ignored => f.list.iter().any(|r| r.is_match(text)),
Some(_) | None => false,
})
}
fn matches_ignore_list(filter_check_map: &[(&Option<Filter>, &String)]) -> bool {
filter_check_map.iter().any(|(filter, text)| match filter {
Some(f) if f.is_list_ignored => f.list.iter().any(|r| r.is_match(text)),
Some(_) | None => false,
})
}
fn get_disk_info() -> io::Result<StorageSystemInformation> {
let output = std::process::Command::new("df")
.args(["--libxo", "json", "-k", "-t", "ufs,msdosfs,zfs"])
.output()?;
deserialize_xo("storage-system-information", &output.stdout)
}

View File

@ -0,0 +1,83 @@
//! Disk stats through heim.
//! Supports macOS, Linux, and Windows.
use crate::data_harvester::disks::{DiskHarvest, IoData, IoHarvest};
cfg_if::cfg_if! {
if #[cfg(target_os = "linux")] {
pub mod linux;
pub use linux::*;
} else if #[cfg(any(target_os = "macos", target_os = "windows"))] {
pub mod windows_macos;
pub use windows_macos::*;
}
}
pub async fn get_io_usage() -> crate::utils::error::Result<Option<IoHarvest>> {
use futures::StreamExt;
let mut io_hash: std::collections::HashMap<String, Option<IoData>> =
std::collections::HashMap::new();
let counter_stream = heim::disk::io_counters().await?;
futures::pin_mut!(counter_stream);
while let Some(io) = counter_stream.next().await {
if let Ok(io) = io {
let mount_point = io.device_name().to_str().unwrap_or("Name Unavailable");
io_hash.insert(
mount_point.to_string(),
Some(IoData {
read_bytes: io.read_bytes().get::<heim::units::information::byte>(),
write_bytes: io.write_bytes().get::<heim::units::information::byte>(),
}),
);
}
}
Ok(Some(io_hash))
}
pub async fn get_disk_usage() -> crate::utils::error::Result<Option<Vec<DiskHarvest>>> {
use futures::StreamExt;
let mut vec_disks: Vec<DiskHarvest> = Vec::new();
let partitions_stream = heim::disk::partitions_physical().await?;
futures::pin_mut!(partitions_stream);
while let Some(part) = partitions_stream.next().await {
if let Ok(partition) = part {
let name = get_device_name(&partition);
let mount_point = (partition
.mount_point()
.to_str()
.unwrap_or("Name Unavailable"))
.to_string();
// The usage line can fail in some cases (for example, if you use Void Linux + LUKS,
// see https://github.com/ClementTsang/bottom/issues/419 for details). As such, check
// it like this instead.
if let Ok(usage) = heim::disk::usage(partition.mount_point()).await {
vec_disks.push(DiskHarvest {
free_space: Some(usage.free().get::<heim::units::information::byte>()),
used_space: Some(usage.used().get::<heim::units::information::byte>()),
total_space: Some(usage.total().get::<heim::units::information::byte>()),
mount_point,
name,
});
} else {
vec_disks.push(DiskHarvest {
free_space: None,
used_space: None,
total_space: None,
mount_point,
name,
});
}
}
}
Ok(Some(vec_disks))
}

View File

@ -0,0 +1,34 @@
//! Linux-specific things for Heim disk data collection.
use heim::disk::Partition;
pub fn get_device_name(partition: &Partition) -> String {
if let Some(device) = partition.device() {
// See if this disk is actually mounted elsewhere on Linux...
// This is a workaround to properly map I/O in some cases (i.e. disk encryption), see
// https://github.com/ClementTsang/bottom/issues/419
if let Ok(path) = std::fs::read_link(device) {
if path.is_absolute() {
path.into_os_string()
} else {
let mut combined_path = std::path::PathBuf::new();
combined_path.push(device);
combined_path.pop(); // Pop the current file...
combined_path.push(path);
if let Ok(canon_path) = std::fs::canonicalize(combined_path) {
// Resolve the local path into an absolute one...
canon_path.into_os_string()
} else {
device.to_os_string()
}
}
} else {
device.to_os_string()
}
.into_string()
.unwrap_or_else(|_| "Name Unavailable".to_string())
} else {
"Name Unavailable".to_string()
}
}

View File

@ -0,0 +1,14 @@
//! macOS and Windows-specific things for Heim disk data collection.
use heim::disk::Partition;
pub fn get_device_name(partition: &Partition) -> String {
if let Some(device) = partition.device() {
device
.to_os_string()
.into_string()
.unwrap_or_else(|_| "Name Unavailable".to_string())
} else {
"Name Unavailable".to_string()
}
}

View File

@ -0,0 +1,10 @@
//! Data collection for memory.
//!
//! For Linux, macOS, and Windows, this is handled by Heim. On FreeBSD it is handled by sysinfo.
cfg_if::cfg_if! {
if #[cfg(any(target_os = "freebsd", target_os = "linux", target_os = "macos", target_os = "windows"))] {
pub mod general;
pub use self::general::*;
}
}

View File

@ -0,0 +1,28 @@
use serde::Serialize;
cfg_if::cfg_if! {
if #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] {
pub mod heim;
pub use self::heim::*;
} else if #[cfg(target_os = "freebsd")] {
pub mod sysinfo;
pub use self::sysinfo::*;
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct MemHarvest {
pub mem_total_in_kib: u64,
pub mem_used_in_kib: u64,
pub use_percent: Option<f64>,
}
#[derive(Debug)]
pub struct MemCollect {
pub ram: crate::utils::error::Result<Option<MemHarvest>>,
pub swap: crate::utils::error::Result<Option<MemHarvest>>,
#[cfg(feature = "zfs")]
pub arc: crate::utils::error::Result<Option<MemHarvest>>,
#[cfg(feature = "gpu")]
pub gpus: crate::utils::error::Result<Option<Vec<(String, MemHarvest)>>>,
}

View File

@ -0,0 +1,279 @@
//! Data collection for memory via heim.
use crate::data_harvester::memory::{MemCollect, MemHarvest};
pub async fn get_mem_data() -> MemCollect {
MemCollect {
ram: get_ram_data().await,
swap: get_swap_data().await,
#[cfg(feature = "zfs")]
arc: get_arc_data().await,
#[cfg(feature = "gpu")]
gpus: get_gpu_data().await,
}
}
pub async fn get_ram_data() -> crate::utils::error::Result<Option<MemHarvest>> {
let (mem_total_in_kib, mem_used_in_kib) = {
#[cfg(target_os = "linux")]
{
// TODO: [OPT] is this efficient?
use smol::fs::read_to_string;
let meminfo = read_to_string("/proc/meminfo").await?;
// All values are in KiB by default.
let mut mem_total = 0;
let mut cached = 0;
let mut s_reclaimable = 0;
let mut shmem = 0;
let mut buffers = 0;
let mut mem_free = 0;
let mut keys_read: u8 = 0;
const TOTAL_KEYS_NEEDED: u8 = 6;
for line in meminfo.lines() {
if let Some((label, value)) = line.split_once(':') {
let to_write = match label {
"MemTotal" => &mut mem_total,
"MemFree" => &mut mem_free,
"Buffers" => &mut buffers,
"Cached" => &mut cached,
"Shmem" => &mut shmem,
"SReclaimable" => &mut s_reclaimable,
_ => {
continue;
}
};
if let Some((number, _unit)) = value.trim_start().split_once(' ') {
// Parse the value, remember it's in KiB!
if let Ok(number) = number.parse::<u64>() {
*to_write = number;
// We only need a few keys, so we can bail early.
keys_read += 1;
if keys_read == TOTAL_KEYS_NEEDED {
break;
}
}
}
}
}
// Let's preface this by saying that memory usage calculations are... not straightforward.
// There are conflicting implementations everywhere.
//
// Now that we've added this preface (mainly for future reference), the current implementation below for usage
// is based on htop's calculation formula. See
// https://github.com/htop-dev/htop/blob/976c6123f41492aaf613b9d172eef1842fb7b0a3/linux/LinuxProcessList.c#L1584
// for implementation details as of writing.
//
// Another implementation, commonly used in other things, is to skip the shmem part of the calculation,
// which matches gopsutil and stuff like free.
let total = mem_total;
let cached_mem = cached + s_reclaimable - shmem;
let used_diff = mem_free + cached_mem + buffers;
let used = if total >= used_diff {
total - used_diff
} else {
total - mem_free
};
(total, used)
}
#[cfg(target_os = "macos")]
{
let memory = heim::memory::memory().await?;
use heim::memory::os::macos::MemoryExt;
use heim::units::information::kibibyte;
(
memory.total().get::<kibibyte>(),
memory.active().get::<kibibyte>() + memory.wire().get::<kibibyte>(),
)
}
#[cfg(target_os = "windows")]
{
let memory = heim::memory::memory().await?;
use heim::units::information::kibibyte;
let mem_total_in_kib = memory.total().get::<kibibyte>();
(
mem_total_in_kib,
mem_total_in_kib - memory.available().get::<kibibyte>(),
)
}
#[cfg(target_os = "freebsd")]
{
let mut s = System::new();
s.refresh_memory();
(s.total_memory(), s.used_memory())
}
};
Ok(Some(MemHarvest {
mem_total_in_kib,
mem_used_in_kib,
use_percent: if mem_total_in_kib == 0 {
None
} else {
Some(mem_used_in_kib as f64 / mem_total_in_kib as f64 * 100.0)
},
}))
}
pub async fn get_swap_data() -> crate::utils::error::Result<Option<MemHarvest>> {
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))]
let memory = heim::memory::swap().await?;
#[cfg(target_os = "freebsd")]
let mut memory = System::new();
let (mem_total_in_kib, mem_used_in_kib) = {
#[cfg(target_os = "linux")]
{
// Similar story to above - heim parses this information incorrectly as far as I can tell, so kilobytes = kibibytes here.
use heim::units::information::kilobyte;
(
memory.total().get::<kilobyte>(),
memory.used().get::<kilobyte>(),
)
}
#[cfg(any(target_os = "windows", target_os = "macos"))]
{
use heim::units::information::kibibyte;
(
memory.total().get::<kibibyte>(),
memory.used().get::<kibibyte>(),
)
}
#[cfg(target_os = "freebsd")]
{
memory.refresh_memory();
(memory.total_swap(), memory.used_swap())
}
};
Ok(Some(MemHarvest {
mem_total_in_kib,
mem_used_in_kib,
use_percent: if mem_total_in_kib == 0 {
None
} else {
Some(mem_used_in_kib as f64 / mem_total_in_kib as f64 * 100.0)
},
}))
}
#[cfg(feature = "zfs")]
pub async fn get_arc_data() -> crate::utils::error::Result<Option<MemHarvest>> {
let (mem_total_in_kib, mem_used_in_kib) = {
#[cfg(target_os = "linux")]
{
let mut mem_arc = 0;
let mut mem_total = 0;
let mut zfs_keys_read: u8 = 0;
const ZFS_KEYS_NEEDED: u8 = 2;
use smol::fs::read_to_string;
let arcinfo = read_to_string("/proc/spl/kstat/zfs/arcstats").await?;
for line in arcinfo.lines() {
if let Some((label, value)) = line.split_once(' ') {
let to_write = match label {
"size" => &mut mem_arc,
"memory_all_bytes" => &mut mem_total,
_ => {
continue;
}
};
if let Some((_type, number)) = value.trim_start().rsplit_once(' ') {
// Parse the value, remember it's in bytes!
if let Ok(number) = number.parse::<u64>() {
*to_write = number;
// We only need a few keys, so we can bail early.
zfs_keys_read += 1;
if zfs_keys_read == ZFS_KEYS_NEEDED {
break;
}
}
}
}
}
(mem_total / 1024, mem_arc / 1024)
}
#[cfg(target_os = "freebsd")]
{
use sysctl::Sysctl;
if let (Ok(mem_arc_value), Ok(mem_sys_value)) = (
sysctl::Ctl::new("kstat.zfs.misc.arcstats.size"),
sysctl::Ctl::new("hw.physmem"),
) {
if let (Ok(sysctl::CtlValue::U64(arc)), Ok(sysctl::CtlValue::Ulong(mem))) =
(mem_arc_value.value(), mem_sys_value.value())
{
(mem / 1024, arc / 1024)
} else {
(0, 0)
}
} else {
(0, 0)
}
}
#[cfg(target_os = "macos")]
{
(0, 0)
}
#[cfg(target_os = "windows")]
{
(0, 0)
}
};
Ok(Some(MemHarvest {
mem_total_in_kib,
mem_used_in_kib,
use_percent: if mem_total_in_kib == 0 {
None
} else {
Some(mem_used_in_kib as f64 / mem_total_in_kib as f64 * 100.0)
},
}))
}
#[cfg(feature = "nvidia")]
pub async fn get_gpu_data() -> crate::utils::error::Result<Option<Vec<(String, MemHarvest)>>> {
use crate::data_harvester::nvidia::NVML_DATA;
if let Ok(nvml) = &*NVML_DATA {
if let Ok(ngpu) = nvml.device_count() {
let mut results = Vec::with_capacity(ngpu as usize);
for i in 0..ngpu {
if let Ok(device) = nvml.device_by_index(i) {
if let (Ok(name), Ok(mem)) = (device.name(), device.memory_info()) {
// add device memory in bytes
let mem_total_in_kib = mem.total / 1024;
let mem_used_in_kib = mem.used / 1024;
results.push((
name,
MemHarvest {
mem_total_in_kib,
mem_used_in_kib,
use_percent: if mem_total_in_kib == 0 {
None
} else {
Some(mem_used_in_kib as f64 / mem_total_in_kib as f64 * 100.0)
},
},
));
}
}
}
Ok(Some(results))
} else {
Ok(None)
}
} else {
Ok(None)
}
}

View File

@ -0,0 +1,128 @@
//! Data collection for memory via sysinfo.
use sysinfo::{System, SystemExt};
use crate::data_harvester::memory::{MemCollect, MemHarvest};
pub async fn get_mem_data(sys: &System, actually_get: bool, _get_gpu: bool) -> MemCollect {
if !actually_get {
MemCollect {
ram: Ok(None),
swap: Ok(None),
#[cfg(feature = "zfs")]
arc: Ok(None),
#[cfg(feature = "gpu")]
gpus: Ok(None),
}
} else {
MemCollect {
ram: get_ram_data(sys).await,
swap: get_swap_data(sys).await,
#[cfg(feature = "zfs")]
arc: get_arc_data().await,
#[cfg(feature = "gpu")]
gpus: if _get_gpu {
get_gpu_data().await
} else {
Ok(None)
},
}
}
}
pub async fn get_ram_data(sys: &System) -> crate::utils::error::Result<Option<MemHarvest>> {
let (mem_total_in_kib, mem_used_in_kib) = (sys.total_memory() / 1024, sys.used_memory() / 1024);
Ok(Some(MemHarvest {
mem_total_in_kib,
mem_used_in_kib,
use_percent: if mem_total_in_kib == 0 {
None
} else {
Some(mem_used_in_kib as f64 / mem_total_in_kib as f64 * 100.0)
},
}))
}
pub async fn get_swap_data(sys: &System) -> crate::utils::error::Result<Option<MemHarvest>> {
let (mem_total_in_kib, mem_used_in_kib) = (sys.total_swap() / 1024, sys.used_swap() / 1024);
Ok(Some(MemHarvest {
mem_total_in_kib,
mem_used_in_kib,
use_percent: if mem_total_in_kib == 0 {
None
} else {
Some(mem_used_in_kib as f64 / mem_total_in_kib as f64 * 100.0)
},
}))
}
#[cfg(feature = "zfs")]
pub async fn get_arc_data() -> crate::utils::error::Result<Option<MemHarvest>> {
let (mem_total_in_kib, mem_used_in_kib) = {
#[cfg(target_os = "freebsd")]
{
use sysctl::Sysctl;
if let (Ok(mem_arc_value), Ok(mem_sys_value)) = (
sysctl::Ctl::new("kstat.zfs.misc.arcstats.size"),
sysctl::Ctl::new("hw.physmem"),
) {
if let (Ok(sysctl::CtlValue::U64(arc)), Ok(sysctl::CtlValue::Ulong(mem))) =
(mem_arc_value.value(), mem_sys_value.value())
{
(mem / 1024, arc / 1024)
} else {
(0, 0)
}
} else {
(0, 0)
}
}
};
Ok(Some(MemHarvest {
mem_total_in_kib,
mem_used_in_kib,
use_percent: if mem_total_in_kib == 0 {
None
} else {
Some(mem_used_in_kib as f64 / mem_total_in_kib as f64 * 100.0)
},
}))
}
#[cfg(feature = "nvidia")]
pub async fn get_gpu_data() -> crate::utils::error::Result<Option<Vec<(String, MemHarvest)>>> {
use crate::data_harvester::nvidia::NVML_DATA;
if let Ok(nvml) = &*NVML_DATA {
if let Ok(ngpu) = nvml.device_count() {
let mut results = Vec::with_capacity(ngpu as usize);
for i in 0..ngpu {
if let Ok(device) = nvml.device_by_index(i) {
if let (Ok(name), Ok(mem)) = (device.name(), device.memory_info()) {
// add device memory in bytes
let mem_total_in_kib = mem.total / 1024;
let mem_used_in_kib = mem.used / 1024;
results.push((
name,
MemHarvest {
mem_total_in_kib,
mem_used_in_kib,
use_percent: if mem_total_in_kib == 0 {
None
} else {
Some(mem_used_in_kib as f64 / mem_total_in_kib as f64 * 100.0)
},
},
));
}
}
}
Ok(Some(results))
} else {
Ok(None)
}
} else {
Ok(None)
}
}

View File

@ -0,0 +1,32 @@
//! Data collection for network usage/IO.
//!
//! For Linux and macOS, this is handled by Heim.
//! For Windows, this is handled by sysinfo.
use serde::Serialize;
cfg_if::cfg_if! {
if #[cfg(any(target_os = "linux", target_os = "macos"))] {
pub mod heim;
pub use self::heim::*;
} else if #[cfg(any(target_os = "freebsd", target_os = "windows"))] {
pub mod sysinfo;
pub use self::sysinfo::*;
}
}
#[derive(Default, Clone, Debug, Serialize)]
/// All units in bits.
pub struct NetworkHarvest {
pub rx: u64,
pub tx: u64,
pub total_rx: u64,
pub total_tx: u64,
}
impl NetworkHarvest {
pub fn first_run_cleanup(&mut self) {
self.rx = 0;
self.tx = 0;
}
}

View File

@ -0,0 +1,51 @@
//! Gets network data via heim.
use std::time::Instant;
use super::NetworkHarvest;
// TODO: Eventually make it so that this thing also takes individual usage into account, so we can show per-interface!
pub async fn get_network_data(
prev_net_access_time: Instant,
prev_net_rx: &mut u64,
prev_net_tx: &mut u64,
curr_time: Instant,
) -> crate::utils::error::Result<Option<NetworkHarvest>> {
use futures::StreamExt;
let io_data = heim::net::io_counters().await?;
futures::pin_mut!(io_data);
let mut total_rx: u64 = 0;
let mut total_tx: u64 = 0;
while let Some(io) = io_data.next().await {
if let Ok(io) = io {
// TODO: Use bytes as the default instead, perhaps?
// Since you might have to do a double conversion (bytes -> bits -> bytes) in some cases;
// but if you stick to bytes, then in the bytes, case, you do no conversion, and in the bits case,
// you only do one conversion...
total_rx += io.bytes_recv().get::<heim::units::information::bit>();
total_tx += io.bytes_sent().get::<heim::units::information::bit>();
}
}
let elapsed_time = curr_time.duration_since(prev_net_access_time).as_secs_f64();
let (rx, tx) = if elapsed_time == 0.0 {
(0, 0)
} else {
(
((total_rx.saturating_sub(*prev_net_rx)) as f64 / elapsed_time) as u64,
((total_tx.saturating_sub(*prev_net_tx)) as f64 / elapsed_time) as u64,
)
};
*prev_net_rx = total_rx;
*prev_net_tx = total_tx;
Ok(Some(NetworkHarvest {
rx,
tx,
total_rx,
total_tx,
}))
}

View File

@ -0,0 +1,44 @@
//! Gets network data via sysinfo.
use std::time::Instant;
use super::NetworkHarvest;
pub async fn get_network_data(
sys: &sysinfo::System,
prev_net_access_time: Instant,
prev_net_rx: &mut u64,
prev_net_tx: &mut u64,
curr_time: Instant,
) -> crate::utils::error::Result<Option<NetworkHarvest>> {
use sysinfo::{NetworkExt, SystemExt};
let mut total_rx: u64 = 0;
let mut total_tx: u64 = 0;
let networks = sys.networks();
for (name, network) in networks {
total_rx += network.total_received() * 8;
total_tx += network.total_transmitted() * 8;
}
let elapsed_time = curr_time.duration_since(prev_net_access_time).as_secs_f64();
let (rx, tx) = if elapsed_time == 0.0 {
(0, 0)
} else {
(
((total_rx.saturating_sub(*prev_net_rx)) as f64 / elapsed_time) as u64,
((total_tx.saturating_sub(*prev_net_tx)) as f64 / elapsed_time) as u64,
)
};
*prev_net_rx = total_rx;
*prev_net_tx = total_tx;
Ok(Some(NetworkHarvest {
rx,
tx,
total_rx,
total_tx,
}))
}

View File

@ -0,0 +1,3 @@
use nvml_wrapper::{error::NvmlError, Nvml};
use once_cell::sync::Lazy;
pub static NVML_DATA: Lazy<Result<Nvml, NvmlError>> = Lazy::new(Nvml::init);

View File

@ -0,0 +1,83 @@
//! Data collection for processes.
//!
//! For Linux, this is handled by a custom set of functions.
//! For Windows and macOS, this is handled by sysinfo.
cfg_if::cfg_if! {
if #[cfg(target_os = "linux")] {
pub mod linux;
pub use self::linux::*;
} else if #[cfg(target_os = "macos")] {
pub mod macos;
mod macos_freebsd;
pub use self::macos::*;
} else if #[cfg(target_os = "windows")] {
pub mod windows;
pub use self::windows::*;
} else if #[cfg(target_os = "freebsd")] {
pub mod freebsd;
mod macos_freebsd;
pub use self::freebsd::*;
}
}
cfg_if::cfg_if! {
if #[cfg(target_family = "unix")] {
pub mod unix;
pub use self::unix::*;
}
}
use serde::Serialize;
use crate::Pid;
#[derive(Debug, Clone, Default, Serialize)]
pub struct ProcessHarvest {
/// The pid of the process.
pub pid: Pid,
/// The parent PID of the process. Remember, parent_pid 0 is root.
pub parent_pid: Option<Pid>,
/// CPU usage as a percentage.
pub cpu_usage_percent: f64,
/// Memory usage as a percentage.
pub mem_usage_percent: f64,
/// Memory usage as bytes.
pub mem_usage_bytes: u64,
/// The name of the process.
pub name: String,
/// The exact command for the process.
pub command: String,
/// Bytes read per second.
pub read_bytes_per_sec: u64,
/// Bytes written per second.
pub write_bytes_per_sec: u64,
/// The total number of bytes read by the process.
pub total_read_bytes: u64,
/// The total number of bytes written by the process.
pub total_write_bytes: u64,
/// The current state of the process (e.g. zombie, asleep)
pub process_state: (String, char),
/// This is the *effective* user ID of the process. This is only used on Unix platforms.
#[cfg(target_family = "unix")]
pub uid: Option<libc::uid_t>,
/// This is the process' user. This is only used on Unix platforms.
#[cfg(target_family = "unix")]
pub user: std::borrow::Cow<'static, str>,
// TODO: Additional fields
// pub rss_kb: u64,
// pub virt_kb: u64,
}

View File

@ -0,0 +1,76 @@
//! Process data collection for FreeBSD. Uses sysinfo.
use std::io;
use serde::{Deserialize, Deserializer};
use sysinfo::System;
use super::ProcessHarvest;
use crate::data_harvester::deserialize_xo;
use crate::data_harvester::processes::UserTable;
#[derive(Deserialize, Debug, Default)]
#[serde(rename_all = "kebab-case")]
struct ProcessInformation {
process: Vec<ProcessRow>,
}
#[derive(Deserialize, Debug)]
#[serde(rename_all = "kebab-case")]
struct ProcessRow {
#[serde(deserialize_with = "pid")]
pid: i32,
#[serde(deserialize_with = "percent_cpu")]
percent_cpu: f64,
}
pub fn get_process_data(
sys: &System,
use_current_cpu_total: bool,
unnormalized_cpu: bool,
mem_total_kb: u64,
user_table: &mut UserTable,
) -> crate::utils::error::Result<Vec<ProcessHarvest>> {
super::macos_freebsd::get_process_data(
sys,
use_current_cpu_total,
unnormalized_cpu,
mem_total_kb,
user_table,
get_freebsd_process_cpu_usage,
)
}
fn get_freebsd_process_cpu_usage(pids: &[i32]) -> io::Result<std::collections::HashMap<i32, f64>> {
if pids.is_empty() {
return Ok(std::collections::HashMap::new());
}
let output = std::process::Command::new("ps")
.args(["--libxo", "json", "-o", "pid,pcpu", "-p"])
.args(pids.iter().map(i32::to_string))
.output()?;
deserialize_xo("process-information", &output.stdout).map(|process_info: ProcessInformation| {
process_info
.process
.into_iter()
.map(|row| (row.pid, row.percent_cpu))
.collect()
})
}
fn pid<'de, D>(deserializer: D) -> Result<i32, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}
fn percent_cpu<'de, D>(deserializer: D) -> Result<f64, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
s.parse().map_err(serde::de::Error::custom)
}

View File

@ -0,0 +1,357 @@
//! Process data collection for Linux.
use std::fs::File;
use std::io::{BufRead, BufReader};
use fxhash::{FxHashMap, FxHashSet};
use procfs::process::{Process, Stat};
use sysinfo::ProcessStatus;
use super::{ProcessHarvest, UserTable};
use crate::data_harvester::cpu::Point;
use crate::utils::error::{self, ToeError};
use crate::Pid;
/// Maximum character length of a /proc/<PID>/stat process name.
/// If it's equal or greater, then we instead refer to the command for the name.
const MAX_STAT_NAME_LEN: usize = 15;
#[derive(Debug, Clone, Default)]
pub struct PrevProcDetails {
total_read_bytes: u64,
total_write_bytes: u64,
cpu_time: u64,
}
fn calculate_idle_values(line: &str) -> Point {
/// Converts a `Option<&str>` value to an f64. If it fails to parse or is `None`, then it will return `0_f64`.
fn str_to_f64(val: Option<&str>) -> f64 {
val.and_then(|v| v.parse::<f64>().ok()).unwrap_or(0_f64)
}
let mut val = line.split_whitespace();
let user = str_to_f64(val.next());
let nice: f64 = str_to_f64(val.next());
let system: f64 = str_to_f64(val.next());
let idle: f64 = str_to_f64(val.next());
let iowait: f64 = str_to_f64(val.next());
let irq: f64 = str_to_f64(val.next());
let softirq: f64 = str_to_f64(val.next());
let steal: f64 = str_to_f64(val.next());
// Note we do not get guest/guest_nice, as they are calculated as part of user/nice respectively
// See https://github.com/htop-dev/htop/blob/main/linux/LinuxProcessList.c
let idle = idle + iowait;
let non_idle = user + nice + system + irq + softirq + steal;
(idle, non_idle)
}
struct CpuUsage {
/// Difference between the total delta and the idle delta.
cpu_usage: f64,
/// Overall CPU usage as a fraction.
cpu_fraction: f64,
}
fn cpu_usage_calculation(prev_idle: &mut f64, prev_non_idle: &mut f64) -> error::Result<CpuUsage> {
let (idle, non_idle) = {
// From SO answer: https://stackoverflow.com/a/23376195
let mut reader = BufReader::new(File::open("/proc/stat")?);
let mut first_line = String::new();
reader.read_line(&mut first_line)?;
calculate_idle_values(&first_line)
};
let total = idle + non_idle;
let prev_total = *prev_idle + *prev_non_idle;
let total_delta = total - prev_total;
let idle_delta = idle - *prev_idle;
*prev_idle = idle;
*prev_non_idle = non_idle;
// TODO: Should these return errors instead?
let cpu_usage = if total_delta - idle_delta != 0.0 {
total_delta - idle_delta
} else {
1.0
};
let cpu_fraction = if total_delta != 0.0 {
cpu_usage / total_delta
} else {
0.0
};
Ok(CpuUsage {
cpu_usage,
cpu_fraction,
})
}
/// Returns the usage and a new set of process times.
///
/// NB: cpu_fraction should be represented WITHOUT the x100 factor!
fn get_linux_cpu_usage(
stat: &Stat,
cpu_usage: f64,
cpu_fraction: f64,
prev_proc_times: u64,
use_current_cpu_total: bool,
) -> (f64, u64) {
// Based heavily on https://stackoverflow.com/a/23376195 and https://stackoverflow.com/a/1424556
let new_proc_times = stat.utime + stat.stime;
let diff = (new_proc_times - prev_proc_times) as f64; // No try_from for u64 -> f64... oh well.
if cpu_usage == 0.0 {
(0.0, new_proc_times)
} else if use_current_cpu_total {
((diff / cpu_usage) * 100.0, new_proc_times)
} else {
((diff / cpu_usage) * 100.0 * cpu_fraction, new_proc_times)
}
}
fn read_proc(
prev_proc: &PrevProcDetails,
process: &Process,
cpu_usage: f64,
cpu_fraction: f64,
use_current_cpu_total: bool,
time_difference_in_secs: u64,
mem_total_kb: u64,
user_table: &mut UserTable,
) -> error::Result<(ProcessHarvest, u64)> {
let stat = process.stat()?;
let (command, name) = {
let truncated_name = stat.comm.as_str();
if let Ok(cmdline) = process.cmdline() {
if cmdline.is_empty() {
(format!("[{}]", truncated_name), truncated_name.to_string())
} else {
(
cmdline.join(" "),
if truncated_name.len() >= MAX_STAT_NAME_LEN {
if let Some(first_part) = cmdline.first() {
// We're only interested in the executable part... not the file path.
// That's for command.
first_part
.rsplit_once('/')
.map(|(_prefix, suffix)| suffix)
.unwrap_or(truncated_name)
.to_string()
} else {
truncated_name.to_string()
}
} else {
truncated_name.to_string()
},
)
}
} else {
(truncated_name.to_string(), truncated_name.to_string())
}
};
let process_state_char = stat.state;
let process_state = (
ProcessStatus::from(process_state_char).to_string(),
process_state_char,
);
let (cpu_usage_percent, new_process_times) = get_linux_cpu_usage(
&stat,
cpu_usage,
cpu_fraction,
prev_proc.cpu_time,
use_current_cpu_total,
);
let parent_pid = Some(stat.ppid);
let mem_usage_bytes = stat.rss_bytes()?;
let mem_usage_kb = mem_usage_bytes / 1024;
let mem_usage_percent = mem_usage_kb as f64 / mem_total_kb as f64 * 100.0;
// This can fail if permission is denied!
let (total_read_bytes, total_write_bytes, read_bytes_per_sec, write_bytes_per_sec) =
if let Ok(io) = process.io() {
let total_read_bytes = io.read_bytes;
let total_write_bytes = io.write_bytes;
let prev_total_read_bytes = prev_proc.total_read_bytes;
let prev_total_write_bytes = prev_proc.total_write_bytes;
let read_bytes_per_sec = total_read_bytes
.saturating_sub(prev_total_read_bytes)
.checked_div(time_difference_in_secs)
.unwrap_or(0);
let write_bytes_per_sec = total_write_bytes
.saturating_sub(prev_total_write_bytes)
.checked_div(time_difference_in_secs)
.unwrap_or(0);
(
total_read_bytes,
total_write_bytes,
read_bytes_per_sec,
write_bytes_per_sec,
)
} else {
(0, 0, 0, 0)
};
let uid = process.uid()?;
Ok((
ProcessHarvest {
pid: process.pid,
parent_pid,
cpu_usage_percent,
mem_usage_percent,
mem_usage_bytes,
name,
command,
read_bytes_per_sec,
write_bytes_per_sec,
total_read_bytes,
total_write_bytes,
process_state,
uid: Some(uid),
user: user_table
.get_uid_to_username_mapping(uid)
.map(Into::into)
.unwrap_or_else(|_| "N/A".into()),
},
new_process_times,
))
}
/// How to calculate CPU usage.
pub enum CpuUsageStrategy {
/// Normalized means the displayed usage percentage is divided over the number of CPU cores.
///
/// For example, if the "overall" usage over the entire system is 105%, and there are 5 cores, then
/// the displayed percentage is 21%.
Normalized,
/// Non-normalized means that the overall usage over the entire system is shown, without dividing
/// over the number of cores.
NonNormalized(f64),
}
pub fn get_process_data(
prev_idle: &mut f64,
prev_non_idle: &mut f64,
pid_mapping: &mut FxHashMap<Pid, PrevProcDetails>,
use_current_cpu_total: bool,
normalization: CpuUsageStrategy,
time_difference_in_secs: u64,
mem_total_kb: u64,
user_table: &mut UserTable,
) -> crate::utils::error::Result<Vec<ProcessHarvest>> {
// TODO: [PROC THREADS] Add threads
if let Ok(CpuUsage {
mut cpu_usage,
cpu_fraction,
}) = cpu_usage_calculation(prev_idle, prev_non_idle)
{
if let CpuUsageStrategy::NonNormalized(num_cores) = normalization {
// Note we *divide* here because the later calculation divides `cpu_usage` - in effect,
// multiplying over the number of cores.
cpu_usage /= num_cores;
}
let mut pids_to_clear: FxHashSet<Pid> = pid_mapping.keys().cloned().collect();
let process_vector: Vec<ProcessHarvest> = std::fs::read_dir("/proc")?
.filter_map(|dir| {
if let Ok(dir) = dir {
if let Ok(pid) = dir.file_name().to_string_lossy().trim().parse::<Pid>() {
let Ok(process) = Process::new(pid) else {
return None;
};
let prev_proc_details = pid_mapping.entry(pid).or_default();
if let Ok((process_harvest, new_process_times)) = read_proc(
prev_proc_details,
&process,
cpu_usage,
cpu_fraction,
use_current_cpu_total,
time_difference_in_secs,
mem_total_kb,
user_table,
) {
prev_proc_details.cpu_time = new_process_times;
prev_proc_details.total_read_bytes = process_harvest.total_read_bytes;
prev_proc_details.total_write_bytes = process_harvest.total_write_bytes;
pids_to_clear.remove(&pid);
return Some(process_harvest);
}
}
}
None
})
.collect();
pids_to_clear.iter().for_each(|pid| {
pid_mapping.remove(pid);
});
Ok(process_vector)
} else {
Err(ToeError::GenericError(
"Could not calculate CPU usage.".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_proc_cpu_parse() {
assert_eq!(
(100_f64, 200_f64),
calculate_idle_values("100 0 100 100"),
"Failed to properly calculate idle/non-idle for /proc/stat CPU with 4 values"
);
assert_eq!(
(120_f64, 200_f64),
calculate_idle_values("100 0 100 100 20"),
"Failed to properly calculate idle/non-idle for /proc/stat CPU with 5 values"
);
assert_eq!(
(120_f64, 230_f64),
calculate_idle_values("100 0 100 100 20 30"),
"Failed to properly calculate idle/non-idle for /proc/stat CPU with 6 values"
);
assert_eq!(
(120_f64, 270_f64),
calculate_idle_values("100 0 100 100 20 30 40"),
"Failed to properly calculate idle/non-idle for /proc/stat CPU with 7 values"
);
assert_eq!(
(120_f64, 320_f64),
calculate_idle_values("100 0 100 100 20 30 40 50"),
"Failed to properly calculate idle/non-idle for /proc/stat CPU with 8 values"
);
assert_eq!(
(120_f64, 320_f64),
calculate_idle_values("100 0 100 100 20 30 40 50 100"),
"Failed to properly calculate idle/non-idle for /proc/stat CPU with 9 values"
);
assert_eq!(
(120_f64, 320_f64),
calculate_idle_values("100 0 100 100 20 30 40 50 100 200"),
"Failed to properly calculate idle/non-idle for /proc/stat CPU with 10 values"
);
}
}

View File

@ -0,0 +1,61 @@
//! Process data collection for macOS. Uses sysinfo and custom bindings.
use sysinfo::System;
use super::ProcessHarvest;
use crate::{data_harvester::processes::UserTable, Pid};
mod sysctl_bindings;
pub fn get_process_data(
sys: &System,
use_current_cpu_total: bool,
unnormalized_cpu: bool,
mem_total_kb: u64,
user_table: &mut UserTable,
) -> crate::utils::error::Result<Vec<ProcessHarvest>> {
super::macos_freebsd::get_process_data(
sys,
use_current_cpu_total,
unnormalized_cpu,
mem_total_kb,
user_table,
get_macos_process_cpu_usage,
)
}
pub(crate) fn fallback_macos_ppid(pid: Pid) -> Option<Pid> {
sysctl_bindings::kinfo_process(pid)
.map(|kinfo| kinfo.kp_eproc.e_ppid)
.ok()
}
fn get_macos_process_cpu_usage(
pids: &[Pid],
) -> std::io::Result<std::collections::HashMap<i32, f64>> {
use itertools::Itertools;
let output = std::process::Command::new("ps")
.args(["-o", "pid=,pcpu=", "-p"])
.arg(
// Has to look like this since otherwise, it you hit a `unstable_name_collisions` warning.
Itertools::intersperse(pids.iter().map(i32::to_string), ",".to_string())
.collect::<String>(),
)
.output()?;
let mut result = std::collections::HashMap::new();
String::from_utf8_lossy(&output.stdout)
.split_whitespace()
.chunks(2)
.into_iter()
.for_each(|chunk| {
let chunk: Vec<&str> = chunk.collect();
if chunk.len() != 2 {
panic!("Unexpected `ps` output");
}
let pid = chunk[0].parse();
let usage = chunk[1].parse();
if let (Ok(pid), Ok(usage)) = (pid, usage) {
result.insert(pid, usage);
}
});
Ok(result)
}

View File

@ -0,0 +1,323 @@
//! Partial bindings from Apple's open source code for getting process information.
//! Some of this is based on [heim's binding implementation](https://github.com/heim-rs/heim/blob/master/heim-process/src/sys/macos/bindings/process.rs).
use std::mem;
use anyhow::{bail, Result};
use libc::{
boolean_t, c_char, c_long, c_short, c_uchar, c_ushort, c_void, dev_t, gid_t, itimerval, pid_t,
rusage, sigset_t, timeval, uid_t, xucred, CTL_KERN, KERN_PROC, KERN_PROC_PID, MAXCOMLEN,
};
use mach2::vm_types::user_addr_t;
use crate::Pid;
#[allow(non_camel_case_types)]
#[repr(C)]
pub(crate) struct kinfo_proc {
pub kp_proc: extern_proc,
pub kp_eproc: eproc,
}
#[allow(non_camel_case_types)]
#[repr(C)]
#[derive(Copy, Clone)]
pub struct p_st1 {
/// Doubly-linked run/sleep queue.
p_forw: user_addr_t,
p_back: user_addr_t,
}
#[allow(non_camel_case_types)]
#[repr(C)]
pub union p_un {
pub p_st1: p_st1,
/// process start time
pub p_starttime: timeval,
}
/// Exported fields for kern sysctl. See
/// [`proc.h`](https://opensource.apple.com/source/xnu/xnu-201/bsd/sys/proc.h)
#[allow(non_camel_case_types)]
#[repr(C)]
pub(crate) struct extern_proc {
pub p_un: p_un,
/// Address space.
pub p_vmspace: *mut vmspace,
/// Signal actions, state (PROC ONLY). Should point to
/// a `sigacts` but we don't really seem to need this.
pub p_sigacts: user_addr_t,
/// P_* flags.
pub p_flag: i32,
/// S* process status.
pub p_stat: c_char,
/// Process identifier.
pub p_pid: pid_t,
/// Save parent pid during ptrace.
pub p_oppid: pid_t,
/// Sideways return value from fdopen.
pub p_dupfd: i32,
/// where user stack was allocated
pub user_stack: caddr_t,
/// Which thread is exiting?
pub exit_thread: *mut c_void,
/// allow to debug
pub p_debugger: i32,
/// indication to suspend
pub sigwait: boolean_t,
/// Time averaged value of p_cpticks.
pub p_estcpu: u32,
/// Ticks of cpu time.
pub p_cpticks: i32,
/// %cpu for this process during p_swtime
pub p_pctcpu: fixpt_t,
/// Sleep address.
pub p_wchan: *mut c_void,
/// Reason for sleep.
pub p_wmesg: *mut c_char,
/// Time swapped in or out.
pub p_swtime: u32,
/// Time since last blocked.
pub p_slptime: u32,
/// Alarm timer.
pub p_realtimer: itimerval,
/// Real time.
pub p_rtime: timeval,
/// Statclock hit in user mode.
pub p_uticks: u64,
/// Statclock hits in system mode.
pub p_sticks: u64,
/// Statclock hits processing intr.
pub p_iticks: u64,
/// Kernel trace points.
pub p_traceflag: i32,
/// Trace to vnode. Originally a pointer to a struct of vnode.
pub p_tracep: *mut c_void,
/// DEPRECATED.
pub p_siglist: i32,
/// Vnode of executable. Originally a pointer to a struct of vnode.
pub p_textvp: *mut c_void,
/// If non-zero, don't swap.
pub p_holdcnt: i32,
/// DEPRECATED.
pub p_sigmask: sigset_t,
/// Signals being ignored.
pub p_sigignore: sigset_t,
/// Signals being caught by user.
pub p_sigcatch: sigset_t,
/// Process priority.
pub p_priority: c_uchar,
/// User-priority based on p_cpu and p_nice.
pub p_usrpri: c_uchar,
/// Process "nice" value.
pub p_nice: c_char,
pub p_comm: [c_char; MAXCOMLEN + 1],
/// Pointer to process group. Originally a pointer to a `pgrp`.
pub p_pgrp: *mut c_void,
/// Kernel virtual addr of u-area (PROC ONLY). Originally a pointer to a `user`.
pub p_addr: *mut c_void,
/// Exit status for wait; also stop signal.
pub p_xstat: c_ushort,
/// Accounting flags.
pub p_acflag: c_ushort,
/// Exit information. XXX
pub p_ru: *mut rusage,
}
const WMESGLEN: usize = 7;
const COMAPT_MAXLOGNAME: usize = 12;
/// See `_caddr_t.h`.
#[allow(non_camel_case_types)]
type caddr_t = *const libc::c_char;
/// See `types.h`.
#[allow(non_camel_case_types)]
type segsz_t = i32;
/// See `types.h`.
#[allow(non_camel_case_types)]
type fixpt_t = u32;
/// See [`proc.h`](https://opensource.apple.com/source/xnu/xnu-201/bsd/sys/proc.h)
#[allow(non_camel_case_types)]
#[repr(C)]
pub(crate) struct pcred {
pub pc_lock: [c_char; 72],
pub pc_ucred: *mut xucred,
pub p_ruid: uid_t,
pub p_svuid: uid_t,
pub p_rgid: gid_t,
pub p_svgid: gid_t,
pub p_refcnt: i32,
}
/// See `vm.h`.
#[allow(non_camel_case_types)]
#[repr(C)]
pub(crate) struct vmspace {
pub dummy: i32,
pub dummy2: caddr_t,
pub dummy3: [i32; 5],
pub dummy4: [caddr_t; 3],
}
/// See [`sysctl.h`](https://opensource.apple.com/source/xnu/xnu-344/bsd/sys/sysctl.h).
#[allow(non_camel_case_types)]
#[repr(C)]
pub(crate) struct eproc {
/// Address of proc. We just cheat and use a c_void pointer since we aren't using this.
pub e_paddr: *mut c_void,
/// Session pointer. We just cheat and use a c_void pointer since we aren't using this.
pub e_sess: *mut c_void,
/// Process credentials
pub e_pcred: pcred,
/// Current credentials
pub e_ucred: xucred,
/// Address space
pub e_vm: vmspace,
/// Parent process ID
pub e_ppid: pid_t,
/// Process group ID
pub e_pgid: pid_t,
/// Job control counter
pub e_jobc: c_short,
/// Controlling tty dev
pub e_tdev: dev_t,
/// tty process group id
pub e_tpgid: pid_t,
/// tty session pointer. We just cheat and use a c_void pointer since we aren't using this.
pub e_tsess: *mut c_void,
/// wchan message
pub e_wmesg: [c_char; WMESGLEN + 1],
/// text size
pub e_xsize: segsz_t,
/// text rss
pub e_xrssize: c_short,
/// text references
pub e_xccount: c_short,
pub e_xswrss: c_short,
pub e_flag: c_long,
/// short setlogin() name
pub e_login: [c_char; COMAPT_MAXLOGNAME],
pub e_spare: [c_long; 4],
}
/// Obtains the [`kinfo_proc`] given a process PID.
///
/// From [heim](https://github.com/heim-rs/heim/blob/master/heim-process/src/sys/macos/bindings/process.rs#L235).
pub(crate) fn kinfo_process(pid: Pid) -> Result<kinfo_proc> {
let mut name: [i32; 4] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid];
let mut size = mem::size_of::<kinfo_proc>();
let mut info = mem::MaybeUninit::<kinfo_proc>::uninit();
let result = unsafe {
libc::sysctl(
name.as_mut_ptr(),
4,
info.as_mut_ptr() as *mut libc::c_void,
&mut size,
std::ptr::null_mut(),
0,
)
};
if result < 0 {
bail!("failed to get process for pid {pid}");
}
// sysctl succeeds but size is zero, happens when process has gone away
if size == 0 {
bail!("failed to get process for pid {pid}");
}
unsafe { Ok(info.assume_init()) }
}
#[cfg(test)]
mod test {
use std::mem;
use super::*;
/// A quick test to ensure that things are sized correctly.
#[test]
fn test_struct_sizes() {
assert_eq!(mem::size_of::<p_st1>(), 16);
assert_eq!(mem::align_of::<p_st1>(), 8);
assert_eq!(mem::size_of::<pcred>(), 104);
assert_eq!(mem::align_of::<pcred>(), 8);
assert_eq!(mem::size_of::<vmspace>(), 64);
assert_eq!(mem::align_of::<vmspace>(), 8);
assert_eq!(mem::size_of::<extern_proc>(), 296);
assert_eq!(mem::align_of::<extern_proc>(), 8);
assert_eq!(mem::size_of::<eproc>(), 376);
assert_eq!(mem::align_of::<eproc>(), 8);
assert_eq!(mem::size_of::<kinfo_proc>(), 672);
assert_eq!(mem::align_of::<kinfo_proc>(), 8);
}
}

View File

@ -0,0 +1,147 @@
//! Shared process data harvesting code from macOS and FreeBSD via sysinfo.
use std::collections::HashMap;
use std::io;
use sysinfo::{CpuExt, PidExt, ProcessExt, ProcessStatus, System, SystemExt};
use super::ProcessHarvest;
use crate::{data_harvester::processes::UserTable, utils::error::Result, Pid};
pub fn get_process_data<F>(
sys: &System,
use_current_cpu_total: bool,
unnormalized_cpu: bool,
mem_total_kb: u64,
user_table: &mut UserTable,
backup_cpu_proc_usage: F,
) -> Result<Vec<ProcessHarvest>>
where
F: Fn(&[Pid]) -> io::Result<HashMap<Pid, f64>>,
{
let mut process_vector: Vec<ProcessHarvest> = Vec::new();
let process_hashmap = sys.processes();
let cpu_usage = sys.global_cpu_info().cpu_usage() as f64 / 100.0;
let num_processors = sys.cpus().len() as f64;
for process_val in process_hashmap.values() {
let name = if process_val.name().is_empty() {
let process_cmd = process_val.cmd();
if process_cmd.len() > 1 {
process_cmd[0].clone()
} else {
let process_exe = process_val.exe().file_stem();
if let Some(exe) = process_exe {
let process_exe_opt = exe.to_str();
if let Some(exe_name) = process_exe_opt {
exe_name.to_string()
} else {
"".to_string()
}
} else {
"".to_string()
}
}
} else {
process_val.name().to_string()
};
let command = {
let command = process_val.cmd().join(" ");
if command.is_empty() {
name.to_string()
} else {
command
}
};
let pcu = {
let usage = process_val.cpu_usage() as f64;
if unnormalized_cpu || num_processors == 0.0 {
usage
} else {
usage / num_processors
}
};
let process_cpu_usage = if use_current_cpu_total && cpu_usage > 0.0 {
pcu / cpu_usage
} else {
pcu
};
let disk_usage = process_val.disk_usage();
let process_state = {
let ps = process_val.status();
(ps.to_string(), convert_process_status_to_char(ps))
};
let uid = process_val.user_id().map(|u| **u);
let pid = process_val.pid().as_u32() as Pid;
process_vector.push(ProcessHarvest {
pid,
parent_pid: {
#[cfg(target_os = "macos")]
{
process_val
.parent()
.map(|p| p.as_u32() as _)
.or_else(|| super::fallback_macos_ppid(pid))
}
#[cfg(not(target_os = "macos"))]
{
process_val.parent().map(|p| p.as_u32() as _)
}
},
name,
command,
mem_usage_percent: if mem_total_kb > 0 {
process_val.memory() as f64 * 100.0 / mem_total_kb as f64
} else {
0.0
},
mem_usage_bytes: process_val.memory(),
cpu_usage_percent: process_cpu_usage,
read_bytes_per_sec: disk_usage.read_bytes,
write_bytes_per_sec: disk_usage.written_bytes,
total_read_bytes: disk_usage.total_read_bytes,
total_write_bytes: disk_usage.total_written_bytes,
process_state,
uid,
user: uid
.and_then(|uid| {
user_table
.get_uid_to_username_mapping(uid)
.map(Into::into)
.ok()
})
.unwrap_or_else(|| "N/A".into()),
});
}
let unknown_state = ProcessStatus::Unknown(0).to_string();
let cpu_usage_unknown_pids: Vec<Pid> = process_vector
.iter()
.filter(|process| process.process_state.0 == unknown_state)
.map(|process| process.pid)
.collect();
let cpu_usages = backup_cpu_proc_usage(&cpu_usage_unknown_pids)?;
for process in &mut process_vector {
if cpu_usages.contains_key(&process.pid) {
process.cpu_usage_percent = if unnormalized_cpu || num_processors == 0.0 {
*cpu_usages.get(&process.pid).unwrap()
} else {
*cpu_usages.get(&process.pid).unwrap() / num_processors
};
}
}
Ok(process_vector)
}
fn convert_process_status_to_char(status: ProcessStatus) -> char {
match status {
ProcessStatus::Run => 'R',
ProcessStatus::Sleep => 'S',
ProcessStatus::Idle => 'D',
ProcessStatus::Zombie => 'Z',
_ => '?',
}
}

View File

@ -0,0 +1,32 @@
//! Unix-specific parts of process collection.
use fxhash::FxHashMap;
use crate::utils::error;
#[derive(Debug, Default)]
pub struct UserTable {
pub uid_user_mapping: FxHashMap<libc::uid_t, String>,
}
impl UserTable {
pub fn get_uid_to_username_mapping(&mut self, uid: libc::uid_t) -> error::Result<String> {
if let Some(user) = self.uid_user_mapping.get(&uid) {
Ok(user.clone())
} else {
// SAFETY: getpwuid returns a null pointer if no passwd entry is found for the uid
let passwd = unsafe { libc::getpwuid(uid) };
if passwd.is_null() {
return Err(error::ToeError::QueryError("Missing passwd".into()));
}
let username = unsafe { std::ffi::CStr::from_ptr((*passwd).pw_name) }
.to_str()?
.to_string();
self.uid_user_mapping.insert(uid, username.clone());
Ok(username)
}
}
}

View File

@ -0,0 +1,85 @@
//! Process data collection for Windows. Uses sysinfo.
use sysinfo::{CpuExt, PidExt, ProcessExt, System, SystemExt};
use super::ProcessHarvest;
pub fn get_process_data(
sys: &System,
use_current_cpu_total: bool,
unnormalized_cpu: bool,
mem_total_kb: u64,
) -> crate::utils::error::Result<Vec<ProcessHarvest>> {
let mut process_vector: Vec<ProcessHarvest> = Vec::new();
let process_hashmap = sys.processes();
let cpu_usage = sys.global_cpu_info().cpu_usage() as f64 / 100.0;
let num_processors = sys.cpus().len();
for process_val in process_hashmap.values() {
let name = if process_val.name().is_empty() {
let process_cmd = process_val.cmd();
if process_cmd.len() > 1 {
process_cmd[0].clone()
} else {
let process_exe = process_val.exe().file_stem();
if let Some(exe) = process_exe {
let process_exe_opt = exe.to_str();
if let Some(exe_name) = process_exe_opt {
exe_name.to_string()
} else {
"".to_string()
}
} else {
"".to_string()
}
}
} else {
process_val.name().to_string()
};
let command = {
let command = process_val.cmd().join(" ");
if command.is_empty() {
name.to_string()
} else {
command
}
};
let pcu = {
let usage = process_val.cpu_usage() as f64;
if unnormalized_cpu || num_processors == 0 {
usage
} else {
usage / (num_processors as f64)
}
};
let process_cpu_usage = if use_current_cpu_total && cpu_usage > 0.0 {
pcu / cpu_usage
} else {
pcu
};
let disk_usage = process_val.disk_usage();
let process_state = (process_val.status().to_string(), 'R');
process_vector.push(ProcessHarvest {
pid: process_val.pid().as_u32() as _,
parent_pid: process_val.parent().map(|p| p.as_u32() as _),
name,
command,
mem_usage_percent: if mem_total_kb > 0 {
process_val.memory() as f64 * 100.0 / mem_total_kb as f64
} else {
0.0
},
mem_usage_bytes: process_val.memory(),
cpu_usage_percent: process_cpu_usage,
read_bytes_per_sec: disk_usage.read_bytes,
write_bytes_per_sec: disk_usage.written_bytes,
total_read_bytes: disk_usage.total_read_bytes,
total_write_bytes: disk_usage.total_written_bytes,
process_state,
});
}
Ok(process_vector)
}

View File

@ -0,0 +1,25 @@
//! Data collection for temperature metrics.
//!
//! For Linux and macOS, this is handled by Heim.
//! For Windows, this is handled by sysinfo.
cfg_if::cfg_if! {
if #[cfg(target_os = "linux")] {
pub mod linux;
pub use self::linux::*;
} else if #[cfg(any(target_os = "freebsd", target_os = "macos", target_os = "windows"))] {
pub mod sysinfo;
pub use self::sysinfo::*;
}
}
#[cfg(feature = "nvidia")]
pub mod nvidia;
use serde::Serialize;
#[derive(Default, Debug, Clone, Serialize)]
pub struct TempHarvest {
pub name: String,
pub temperature: f32,
}

View File

@ -0,0 +1,230 @@
//! Gets temperature sensor data for Linux platforms.
use std::{fs, path::Path};
use anyhow::{anyhow, Result};
use super::TempHarvest;
/// Get temperature sensors from the linux sysfs interface `/sys/class/hwmon`.
/// See [here](https://www.kernel.org/doc/Documentation/ABI/testing/sysfs-class-hwmon) for
/// details.
///
/// This method will return `0` as the temperature for devices, such as GPUs,
/// that support power management features and power themselves off.
///
/// Specifically, in laptops with iGPUs and dGPUs, if the dGPU is capable of
/// entering ACPI D3cold, reading the temperature sensors will wake it,
/// and keep it awake, wasting power.
///
/// For such devices, this method will only query the sensors *only* if
/// the device is already in ACPI D0. This has the notable issue that
/// once this happens, the device will be *kept* on through the sensor
/// reading, and not be able to re-enter ACPI D3cold.
fn get_from_hwmon() -> Result<Vec<TempHarvest>> {
let mut temperature_vec: Vec<TempHarvest> = vec![];
let path = Path::new("/sys/class/hwmon");
// NOTE: Technically none of this is async, *but* sysfs is in memory,
// so in theory none of this should block if we're slightly careful.
// Of note is that reading the temperature sensors of a device that has
// `/sys/class/hwmon/hwmon*/device/power_state` == `D3cold` will
// wake the device up, and will block until it initializes.
//
// Reading the `hwmon*/device/power_state` or `hwmon*/temp*_label` properties
// will not wake the device, and thus not block,
// and meaning no sensors have to be hidden depending on `power_state`
//
// It would probably be more ideal to use a proper async runtime..
for entry in path.read_dir()? {
let file = entry?;
let mut file_path = file.path();
// hwmon includes many sensors, we only want ones with at least one temperature sensor
// Reading this file will wake the device, but we're only checking existence.
if !file_path.join("temp1_input").exists() {
// Note we also check for a `device` subdirectory (e.g. `/sys/class/hwmon/hwmon*/device/`).
// This is needed for CentOS, which adds this extra `/device` directory. See:
// - https://github.com/nicolargo/glances/issues/1060
// - https://github.com/giampaolo/psutil/issues/971
// - https://github.com/giampaolo/psutil/blob/642438375e685403b4cd60b0c0e25b80dd5a813d/psutil/_pslinux.py#L1316
//
// If it does match, then add the `device/` directory to the path.
if file_path.join("device/temp1_input").exists() {
file_path.push("device");
} else {
continue;
}
}
let hwmon_name = file_path.join("name");
let hwmon_name = Some(fs::read_to_string(hwmon_name)?);
// Whether the temperature should *actually* be read during enumeration
// Set to false if the device is in ACPI D3cold.
let should_read_temp = {
// Documented at https://www.kernel.org/doc/Documentation/ABI/testing/sysfs-devices-power_state
let device = file_path.join("device");
let power_state = device.join("power_state");
if power_state.exists() {
let state = fs::read_to_string(power_state)?;
let state = state.trim();
// The zenpower3 kernel module (incorrectly?) reports "unknown"
// causing this check to fail and temperatures to appear as zero
// instead of having the file not exist..
// their self-hosted git instance has disabled sign up,
// so this bug cant be reported either.
state == "D0" || state == "unknown"
} else {
true
}
};
// Enumerate the devices temperature sensors
for entry in file_path.read_dir()? {
let file = entry?;
let name = file.file_name();
// This should always be ASCII
let name = name
.to_str()
.ok_or_else(|| anyhow!("temperature device filenames should be ASCII"))?;
// We only want temperature sensors, skip others early
if !(name.starts_with("temp") && name.ends_with("input")) {
continue;
}
let temp = file.path();
let temp_label = file_path.join(name.replace("input", "label"));
let temp_label = fs::read_to_string(temp_label).ok();
// Do some messing around to get a more sensible name for sensors
//
// - For GPUs, this will use the kernel device name, ex `card0`
// - For nvme drives, this will also use the kernel name, ex `nvme0`.
// This is found differently than for GPUs
// - For whatever acpitz is, on my machine this is now `thermal_zone0`.
// - For k10temp, this will still be k10temp, but it has to be handled special.
let human_hwmon_name = {
let device = file_path.join("device");
// This will exist for GPUs but not others, this is how
// we find their kernel name
let drm = device.join("drm");
if drm.exists() {
// This should never actually be empty
let mut gpu = None;
for card in drm.read_dir()? {
let card = card?;
let name = card.file_name().to_str().unwrap_or_default().to_owned();
if name.starts_with("card") {
if let Some(hwmon_name) = hwmon_name.as_ref() {
gpu = Some(format!("{} ({})", name, hwmon_name.trim()));
} else {
gpu = Some(name)
}
break;
}
}
gpu
} else {
// This little mess is to account for stuff like k10temp
// This is needed because the `device` symlink
// points to `nvme*` for nvme drives, but to PCI buses for anything else
// If the first character is alphabetic,
// its an actual name like k10temp or nvme0, not a PCI bus
let link = fs::read_link(device)?
.file_name()
.map(|f| f.to_str().unwrap_or_default().to_owned())
.unwrap();
if link.as_bytes()[0].is_ascii_alphabetic() {
if let Some(hwmon_name) = hwmon_name.as_ref() {
Some(format!("{} ({})", link, hwmon_name.trim()))
} else {
Some(link)
}
} else {
hwmon_name.clone()
}
}
};
let name = match (&human_hwmon_name, &temp_label) {
(Some(name), Some(label)) => format!("{}: {}", name.trim(), label.trim()),
(None, Some(label)) => label.to_string(),
(Some(name), None) => name.to_string(),
(None, None) => String::default(),
};
let temp = if should_read_temp {
if let Ok(temp) = fs::read_to_string(temp) {
let temp = temp.trim_end().parse::<f32>().map_err(|e| {
crate::utils::error::ToeError::ConversionError(e.to_string())
})?;
temp / 1_000.0
} else {
// For some devices (e.g. iwlwifi), this file becomes empty when the device
// is disabled. In this case we skip the device.
continue;
}
} else {
0.0
};
temperature_vec.push(TempHarvest {
name,
temperature: temp,
});
}
}
Ok(temperature_vec)
}
/// Gets data from `/sys/class/thermal/thermal_zone*`. This should only be used if
/// [`get_from_hwmon`] doesn't return anything. See
/// [here](https://www.kernel.org/doc/Documentation/ABI/testing/sysfs-class-thermal) for details.
fn get_from_thermal_zone() -> Result<Vec<TempHarvest>> {
let mut temperatures = vec![];
let path = Path::new("/sys/class/thermal");
for entry in path.read_dir()? {
let file = entry?;
if file
.file_name()
.to_string_lossy()
.starts_with("thermal_zone")
{
let file_path = file.path();
let name_path = file_path.join("type");
let name = fs::read_to_string(name_path)?.trim_end().to_string();
let temp_path = file_path.join("temp");
let temp = fs::read_to_string(temp_path)?
.trim_end()
.parse::<f32>()
.map_err(|e| crate::utils::error::ToeError::ConversionError(e.to_string()))?
/ 1_000.0;
temperatures.push(TempHarvest {
name,
temperature: temp,
});
}
}
Ok(temperatures)
}
/// Gets temperature sensors and data.
pub fn get_temperature_data() -> Result<Option<Vec<TempHarvest>>> {
let mut temperature_vec: Vec<TempHarvest> = get_from_hwmon()?;
if temperature_vec.is_empty() {
// If it's empty, fall back to checking `thermal_zone*`.
temperature_vec = get_from_thermal_zone()?;
}
#[cfg(feature = "nvidia")]
{
super::nvidia::add_nvidia_data(&mut temperature_vec)?;
}
Ok(Some(temperature_vec))
}

View File

@ -0,0 +1,26 @@
use nvml_wrapper::enum_wrappers::device::TemperatureSensor;
use super::TempHarvest;
use crate::data_harvester::nvidia::NVML_DATA;
pub fn add_nvidia_data(
temperature_vec: &mut Vec<TempHarvest>,
temp_type: &TemperatureType,
) -> crate::utils::error::Result<()> {
if let Ok(nvml) = &*NVML_DATA {
if let Ok(ngpu) = nvml.device_count() {
for i in 0..ngpu {
if let Ok(device) = nvml.device_by_index(i) {
if let (Ok(name), Ok(temperature)) =
(device.name(), device.temperature(TemperatureSensor::Gpu))
{
let temperature = temperature as f32;
temperature_vec.push(TempHarvest { name, temperature });
}
}
}
}
}
Ok(())
}

View File

@ -0,0 +1,28 @@
//! Gets temperature data via sysinfo.
use anyhow::Result;
use super::TempHarvest;
pub fn get_temperature_data(sys: &sysinfo::System) -> Result<Option<Vec<TempHarvest>>> {
use sysinfo::{ComponentExt, SystemExt};
let mut temperature_vec: Vec<TempHarvest> = Vec::new();
let sensor_data = sys.components();
for component in sensor_data {
let name = component.label().to_string();
temperature_vec.push(TempHarvest {
name,
temperature: component.temperature(),
});
}
#[cfg(feature = "nvidia")]
{
super::nvidia::add_nvidia_data(&mut temperature_vec, temp_type, filter)?;
}
Ok(Some(temperature_vec))
}

64
src-tauri/src/main.rs Normal file
View File

@ -0,0 +1,64 @@
#![cfg_attr(
all(not(debug_assertions), target_os = "windows"),
windows_subsystem = "windows"
)]
mod data_harvester;
mod utils;
use std::sync::Mutex;
use crate::utils::error;
use data_harvester::{Data, DataCollector};
use tauri::{AboutMetadata, CustomMenuItem, Menu, MenuItem, Submenu};
#[cfg(target_family = "windows")]
pub type Pid = usize;
#[cfg(target_family = "unix")]
pub type Pid = libc::pid_t;
#[tauri::command]
fn collect_data(data_state: tauri::State<Mutex<DataCollector>>) -> Data {
futures::executor::block_on(data_state.lock().unwrap().update_data());
data_state.lock().unwrap().data.clone()
}
fn main() {
let mut data_state = DataCollector::new();
data_state.init();
let preferences = CustomMenuItem::new("preferences", "Open Preferences").accelerator("cmd+,");
let submenu = Submenu::new(
"Menu",
Menu::new()
.add_item(preferences)
.add_native_item(MenuItem::SelectAll)
.add_native_item(MenuItem::Copy)
.add_native_item(MenuItem::Quit)
.add_native_item(MenuItem::About(
"ToeRings".to_string(),
AboutMetadata::new()
.version(env!("CARGO_PKG_VERSION"))
.authors(
env!("CARGO_PKG_AUTHORS")
.split(":")
.map(String::from)
.collect(),
)
.license("MIT"),
)),
);
let menu = Menu::new().add_submenu(submenu);
tauri::Builder::default()
.manage(Mutex::new(data_state))
.menu(menu)
.on_menu_event(|event| match event.menu_item_id() {
"preferences" => event.window().emit("openPreferences", ()).unwrap(),
_ => {}
})
.invoke_handler(tauri::generate_handler![collect_data])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View File

@ -0,0 +1,107 @@
use std::{borrow::Cow, result};
use thiserror::Error;
#[cfg(target_os = "linux")]
use procfs::ProcError;
/// A type alias for handling errors related to Bottom.
pub type Result<T> = result::Result<T, ToeError>;
/// An error that can occur while Bottom runs.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ToeError {
/// An error when there is an IO exception.
#[error("IO exception, {0}")]
InvalidIo(String),
/// An error when the heim library encounters a problem.
#[error("Error caused by Heim, {0}")]
InvalidHeim(String),
/// An error when the Crossterm library encounters a problem.
#[error("Error caused by Crossterm, {0}")]
CrosstermError(String),
/// An error to represent generic errors.
#[error("Error, {0}")]
GenericError(String),
/// An error to represent errors with fern.
#[error("Fern error, {0}")]
FernError(String),
/// An error to represent errors with the config.
#[error("Configuration file error, {0}")]
ConfigError(String),
/// An error to represent errors with converting between data types.
#[error("Conversion error, {0}")]
ConversionError(String),
/// An error to represent errors with querying.
#[error("Query error, {0}")]
QueryError(Cow<'static, str>),
/// An error that just signifies something minor went wrong; no message.
#[error("Minor error.")]
MinorError,
/// An error to represent errors with procfs
#[cfg(target_os = "linux")]
#[error("Procfs error, {0}")]
ProcfsError(String),
}
impl From<std::io::Error> for ToeError {
fn from(err: std::io::Error) -> Self {
ToeError::InvalidIo(err.to_string())
}
}
#[cfg(not(target_os = "freebsd"))]
impl From<heim::Error> for ToeError {
fn from(err: heim::Error) -> Self {
ToeError::InvalidHeim(err.to_string())
}
}
impl From<std::num::ParseIntError> for ToeError {
fn from(err: std::num::ParseIntError) -> Self {
ToeError::ConfigError(err.to_string())
}
}
impl From<std::string::String> for ToeError {
fn from(err: std::string::String) -> Self {
ToeError::GenericError(err)
}
}
#[cfg(feature = "fern")]
impl From<fern::InitError> for ToeError {
fn from(err: fern::InitError) -> Self {
ToeError::FernError(err.to_string())
}
}
impl From<std::str::Utf8Error> for ToeError {
fn from(err: std::str::Utf8Error) -> Self {
ToeError::ConversionError(err.to_string())
}
}
impl From<std::string::FromUtf8Error> for ToeError {
fn from(err: std::string::FromUtf8Error) -> Self {
ToeError::ConversionError(err.to_string())
}
}
#[cfg(target_os = "linux")]
impl From<ProcError> for ToeError {
fn from(err: ProcError) -> Self {
match err {
ProcError::PermissionDenied(p) => {
ToeError::ProcfsError(format!("Permission denied for {:?}", p))
}
ProcError::NotFound(p) => ToeError::ProcfsError(format!("{:?} not found", p)),
ProcError::Incomplete(p) => ToeError::ProcfsError(format!("{:?} incomplete", p)),
ProcError::Io(e, p) => ToeError::ProcfsError(format!("io error: {:?} for {:?}", e, p)),
ProcError::Other(s) => ToeError::ProcfsError(format!("Other procfs error: {}", s)),
ProcError::InternalError(e) => {
ToeError::ProcfsError(format!("procfs internal error: {:?}", e))
}
}
}
}

View File

@ -0,0 +1,31 @@
#[cfg(feature = "fern")]
pub fn init_logger(
min_level: log::LevelFilter,
debug_file_name: &std::ffi::OsStr,
) -> Result<(), fern::InitError> {
fern::Dispatch::new()
.format(|out, message, record| {
// Note we aren't using local time since it only works on single-threaded processes.
// If that ever does get patched in again, enable the "local-offset" feature.
let offset = time::OffsetDateTime::now_utc();
out.finish(format_args!(
"{}[{}][{}] {}",
offset
.format(&time::macros::format_description!(
// The weird "[[[" is because we need to escape a bracket ("[[") to show one "[".
// See https://time-rs.github.io/book/api/format-description.html
"[[[year]-[month]-[day]][[[hour]:[minute]:[second][subsecond digits:9]]"
))
.unwrap(),
record.target(),
record.level(),
message
))
})
.level(min_level)
.chain(fern::log_file(debug_file_name)?)
.apply()?;
Ok(())
}

View File

@ -0,0 +1,2 @@
pub mod error;
pub mod logging;

79
src-tauri/tauri.conf.json Normal file
View File

@ -0,0 +1,79 @@
{
"build": {
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build",
"devPath": "http://localhost:1420",
"distDir": "../dist",
"withGlobalTauri": false
},
"package": {
"productName": "ToeRings",
"version": "0.0.0"
},
"tauri": {
"allowlist": {
"all": false,
"window": {
"startDragging": true,
"setSize": true
},
"shell": {
"all": false,
"open": true
}
},
"bundle": {
"active": true,
"category": "DeveloperTool",
"copyright": "",
"deb": {
"depends": []
},
"externalBin": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"identifier": "io.toerings",
"longDescription": "",
"macOS": {
"entitlements": null,
"exceptionDomain": "",
"frameworks": [],
"providerShortName": null,
"signingIdentity": null
},
"resources": [],
"shortDescription": "",
"targets": "all",
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
}
},
"security": {
"csp": null
},
"updater": {
"active": false
},
"macOSPrivateApi": true,
"windows": [
{
"fullscreen": false,
"height": 850,
"width": 325,
"x": 30,
"y": 50,
"resizable": true,
"title": "toerings",
"decorations": false,
"transparent": true
}
]
}
}

172
src/App.svelte Normal file
View File

@ -0,0 +1,172 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/tauri"
import { appWindow, LogicalSize } from "@tauri-apps/api/window"
import { listen } from "@tauri-apps/api/event"
import { sum, pick } from "lodash-es"
import { onMount } from "svelte"
import "uplot/dist/uPlot.min.css"
import { styleVars } from "./lib/actions"
import {
foregroundColor,
backgroundColor,
titleColor,
accentColor,
fontFamily
} from "./lib/stores"
import { sleep, saturatedPush } from "./lib/utils"
import SummaryWidget from "./components/SummaryWidget.svelte"
import CPUWidget from "./components/CPUWidget.svelte"
import MemWidget from "./components/MemWidget.svelte"
import DiskWidget from "./components/DiskWidget.svelte"
import NetWidget from "./components/NetWidget.svelte"
import Preferences from "./components/Preferences.svelte"
let preferencesVisible = false
onMount(() => {
const unlisten = listen("openPreferences", () => {
preferencesVisible = true
})
return unlisten
})
$: if (preferencesVisible) {
appWindow.setSize(new LogicalSize(650, 850))
} else {
appWindow.setSize(new LogicalSize(325, 850))
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
preferencesVisible = false
}
}
const graphXLimit = 60
let summaryData: SummaryData = {
uptime: "0s",
hostname: null,
kernel_name: null,
kernel_version: null,
os_version: null
}
let cpuData = {
perCoreUtil: [],
cpuLoads: Array(60).fill(0)
}
let processList: Array<Process> = []
let tempData: Array<TempData> = []
let memData = {
ram: {
usage: {
mem_total_in_kib: 0,
mem_used_in_kib: 0,
use_percent: null
},
percentages: Array(60).fill(0)
},
swap: {
usage: {
mem_total_in_kib: 0,
mem_used_in_kib: 0,
use_percent: null
}
}
}
let diskData: Array<DiskData> = []
let ioData = Array(60).fill({ read: 0, write: 0 })
let networkData = {
rx: Array(60).fill(0),
tx: Array(60).fill(0)
}
let localIp = ""
let lastDataCollection = 0
async function collectData() {
lastDataCollection = Date.now()
const data: Data = await invoke("collect_data")
processData(data)
const sleepMs = Math.max(lastDataCollection + 1000 - Date.now(), 0)
await sleep(sleepMs)
collectData()
}
function processData(data: Data) {
summaryData = pick(data, ["uptime", "hostname", "kernel_name", "kernel_version", "os_version"])
processList = data.list_of_processes
cpuData.perCoreUtil = data.cpu.map(cpu => cpu.cpu_usage)
saturatedPush(cpuData.cpuLoads, sum(cpuData.perCoreUtil) / 100, graphXLimit)
cpuData = cpuData
tempData = data.temperature_sensors
memData.ram.usage = data.memory
saturatedPush(memData.ram.percentages, data.memory.use_percent, graphXLimit)
memData.swap.usage = data.swap
diskData = data.disks
const ioDataPoint = {
read: sum(data.list_of_processes.map(process => process.read_bytes_per_sec)),
write: sum(data.list_of_processes.map(process => process.write_bytes_per_sec))
}
saturatedPush(ioData, ioDataPoint, graphXLimit)
ioData = ioData
saturatedPush(networkData.rx, data.network.rx, graphXLimit)
saturatedPush(networkData.tx, data.network.tx, graphXLimit)
networkData = networkData
localIp = data.local_ip
}
collectData()
$: cssVars = {
foregroundColor: $foregroundColor.toHslString(),
backgroundColor: $backgroundColor.toHslString(),
titleColor: $titleColor.toHslString(),
accentColor: $accentColor.toHslString(),
fontFamily: $fontFamily
}
</script>
<svelte:window on:keydown={onKeydown} />
<div class="flex" use:styleVars={cssVars}>
<main>
<SummaryWidget {summaryData} />
<CPUWidget {cpuData} {tempData} {processList} />
<MemWidget {memData} {processList} />
<DiskWidget {diskData} {ioData} {processList} />
<NetWidget {networkData} {localIp} hostname={summaryData.hostname} />
</main>
{#if preferencesVisible}
<Preferences bind:preferencesVisible />
{/if}
</div>
<style>
.flex {
background-color: var(--backgroundColor);
display: flex;
}
main {
width: 325px;
padding: 10px;
max-height: 850px;
overflow-y: hidden;
font-family: var(--fontFamily);
color: var(--foregroundColor);
}
</style>

131
src/components/Arc.svelte Normal file
View File

@ -0,0 +1,131 @@
<script lang="ts">
export let value = 0
export let max = 100
export let strokeColor = "rgba(255, 255, 255, 0.6)"
export let strokeWidth = 8
export let trackColor = "rgba(255, 255, 255, 0.2)"
export let capColor = "white"
export let size = 100
export let label = null
export let tooltip = null
import { styleVars } from "../lib/actions"
let showTooltip = false
const margin = 5
$: sizeHalf = size / 2
$: zero = [sizeHalf, size - margin]
$: x = zero[0]
$: y = zero[1]
$: fullPath = `M${zero[0]},${zero[1]} A${sizeHalf - margin},${sizeHalf - margin} 0 1,1 ${
zero[1]
},${zero[0]}`
$: cssVars = {
strokeColor,
strokeWidth: `${strokeWidth}px`,
trackColor,
size: `${size}px`
}
let arcPath = ""
$: if (value <= 0) {
arcPath = ""
x = zero[0]
y = zero[1]
} else {
const frac = Math.min(value / max, 1)
const angle = ((3 * Math.PI) / 2) * frac
x = sizeHalf + Math.cos(angle + Math.PI / 2) * (sizeHalf - margin)
y = sizeHalf + Math.sin(angle + Math.PI / 2) * (sizeHalf - margin)
const majorArc = Number(angle > Math.PI)
arcPath = `M${zero[0]},${zero[1]} A${sizeHalf - margin},${
sizeHalf - margin
} 0 ${majorArc},1 ${x},${y}`
}
</script>
<div use:styleVars={cssVars} class="arc-wrap">
<svg viewBox="0 0 {size} {size}" xmlns="http://www.w3.org/2000/svg">
<path d={fullPath} />
<path d={arcPath} />
<circle cx={x} cy={y} r={strokeWidth / 2} fill={capColor} />
</svg>
{#if label}
{#if showTooltip && tooltip}
<div class="tooltip">
{@html tooltip}
</div>
{/if}
<div
class="label"
on:mouseenter={() => {
showTooltip = true
}}
on:mouseleave={() => {
showTooltip = false
}}
>
{label}
</div>
{/if}
{#if $$slots.default}
<div>
<div class="slot-parent">
<slot {value} />
</div>
</div>
{/if}
</div>
<style>
svg {
fill: transparent;
position: absolute;
stroke-linecap: round;
left: 0;
}
path {
stroke-width: var(--strokeWidth);
stroke: var(--trackColor);
}
path:nth-child(2) {
stroke: var(--strokeColor);
}
.arc-wrap {
height: var(--size);
width: var(--size);
position: relative;
}
.label {
position: absolute;
bottom: -4px;
left: calc(var(--size) / 2 + 7px);
width: 45px;
white-space: nowrap;
overflow-x: hidden;
}
.slot-parent {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
height: fit-content;
width: fit-content;
}
.tooltip {
position: absolute;
bottom: 20px;
left: calc(var(--size) / 2 + 7px);
background-color: white;
color: black;
padding: 5px 8px;
}
</style>

View File

@ -0,0 +1,65 @@
<script lang="ts">
export let arcs: Array<ArcSpec>
export let size = 100
export let strokeWidth = 8
interface ArcSpec {
value: number
max: number
label?: string
tooltip?: string
}
import { styleVars } from "../lib/actions"
import { arcTrackColor, arcCapColor, foregroundColor } from "../lib/stores"
import Arc from "./Arc.svelte"
function computeGap(strokeWidth: number) {
if (strokeWidth <= 5) {
return 1
}
if (strokeWidth <= 10) {
return 2
}
return 3
}
$: gap = computeGap(strokeWidth)
$: levelWidth = (strokeWidth + gap) * 2
$: cssVars = {
size: `${size}px`
}
$: trackColor = $arcTrackColor.toHslString()
$: strokeColor = $foregroundColor.alpha(0.6).toHslString()
$: capColor = $arcCapColor.toHslString()
</script>
<div class="arcs-container" use:styleVars={cssVars}>
{#each arcs as arc, i}
<div class="arc-wrap">
<Arc
{...arc}
size={size - levelWidth * i}
{strokeWidth}
{strokeColor}
{capColor}
{trackColor}
/>
</div>
{/each}
</div>
<style>
.arcs-container {
position: relative;
width: var(--size);
height: var(--size);
}
.arc-wrap {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
height: fit-content;
}
</style>

View File

@ -0,0 +1,60 @@
<script lang="ts">
export let attrs: Array<{ key: string; value: string }>
</script>
<div class="container">
<div class="arc-wrap">
<slot name="arcStack" />
</div>
<ul>
{#each attrs as pair}
<li>
<div class="key">{pair.key}</div>
<div class="value">{pair.value}</div>
</li>
{/each}
</ul>
<div class="flowing-content">
<slot name="content" />
</div>
</div>
<style>
.container {
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
margin-bottom: 30px;
}
.arc-wrap {
position: absolute;
top: 0;
left: 0;
z-index: 2;
}
ul {
position: absolute;
top: 0;
right: 12px;
text-align: right;
min-width: 150px;
}
li {
display: flex;
justify-content: space-between;
}
.key {
font-weight: bold;
margin-right: 6px;
}
.flowing-content {
margin-top: 65px;
margin-left: 75px;
}
</style>

View File

@ -0,0 +1,44 @@
<script lang="ts">
interface CPUProps {
perCoreUtil: Array<number>
cpuLoads: Array<number>
}
export let cpuData: CPUProps
export let processList: Array<Process>
export let tempData: Array<TempData>
import { mean } from "lodash-es"
import { calcStrokeWidth } from "../lib/utils"
import { foregroundColor } from "../lib/stores"
import ArcStack from "./ArcStack.svelte"
import ArcWidget from "./ArcWidget.svelte"
import ProcessList from "./ProcessList.svelte"
$: arcs = cpuData.perCoreUtil.map(utilPercent => ({
value: utilPercent,
max: 100
}))
$: plotData = {
x: cpuData.cpuLoads.map((_, i) => i),
y: cpuData.cpuLoads,
color: $foregroundColor
}
$: avgDieTemp = mean(tempData.map(sensor => sensor.temperature))
$: attrs = [
{ key: "CPU Temp:", value: `${avgDieTemp.toFixed(1)}°C` },
{ key: "CPU Load:", value: cpuData.cpuLoads.at(-1).toFixed(2) }
]
$: cpuSortedProcesses = [...processList]
.sort((a, b) => a.cpu_usage_percent < b.cpu_usage_percent)
.slice(0, 5)
</script>
<ArcWidget {attrs}>
<ArcStack slot="arcStack" {arcs} size={120} strokeWidth={calcStrokeWidth(arcs.length)} />
<ProcessList slot="content" title="CPU" plotDatas={[plotData]} processList={cpuSortedProcesses}>
<span slot="processVal" let:process>{process.cpu_usage_percent.toFixed(2)}%</span>
</ProcessList>
</ArcWidget>

View File

@ -0,0 +1,70 @@
<script lang="ts">
export let diskData: Array<DiskData>
export let ioData: Array<{ read: number; write: number }>
export let processList: Array<Process>
import { basename } from "path"
import { uniqBy } from "lodash-es"
import { toMetric, calcStrokeWidth } from "../lib/utils"
import { foregroundColor } from "../lib/stores"
import ArcStack from "./ArcStack.svelte"
import ArcWidget from "./ArcWidget.svelte"
import ProcessList from "./ProcessList.svelte"
function formatPath(filepath: string): string {
if (filepath === "/") {
return "/"
}
return basename(filepath)
}
$: pathSortedDisks = uniqBy(
[...diskData].sort((a, b) => a.mount_point > b.mount_point),
disk => `${disk.free_space},${disk.used_space},${disk.total_space}`
)
$: ioSortedProcesses = [...processList]
.sort(
(a, b) =>
a.write_bytes_per_sec + a.read_bytes_per_sec < b.write_bytes_per_sec + b.read_bytes_per_sec
)
.slice(0, 5)
$: arcs = pathSortedDisks.slice(0, 4).map(disk => ({
label: formatPath(disk.mount_point),
value: disk.used_space,
max: disk.total_space,
tooltip: `${disk.name}<br/>${disk.mount_point}<br/>${(
(disk.used_space / disk.total_space) *
100
).toFixed(1)}% used`
}))
$: attrs = [
{ key: "Read:", value: toMetric(ioData.at(-1).read) },
{ key: "Write:", value: toMetric(ioData.at(-1).write) }
]
$: plotDatas = [
{
x: ioData.map((_, i) => i),
y: ioData.map(point => point.read),
color: $foregroundColor
},
{
x: ioData.map((_, i) => i),
y: ioData.map(point => point.write),
color: $foregroundColor
}
]
</script>
<ArcWidget {attrs}>
<ArcStack slot="arcStack" {arcs} size={120} strokeWidth={calcStrokeWidth(arcs.length)} />
<ProcessList slot="content" title="Disk" {plotDatas} processList={ioSortedProcesses}>
<span slot="processVal" let:process>
r:{toMetric(process.read_bytes_per_sec, 0)}, w:{toMetric(process.write_bytes_per_sec, 0)}
</span>
</ProcessList>
</ArcWidget>

View File

@ -0,0 +1,50 @@
<script lang="ts">
export let memData: {
ram: { usage: MemData; percentages: Array<number> }
swap: { usage: MemData }
}
export let processList: Array<Process>
import { toMetric, calcStrokeWidth } from "../lib/utils"
import { foregroundColor } from "../lib/stores"
import ArcStack from "./ArcStack.svelte"
import ArcWidget from "./ArcWidget.svelte"
import ProcessList from "./ProcessList.svelte"
$: arcs = [
{
value: memData.ram.usage.mem_used_in_kib,
max: memData.ram.usage.mem_total_in_kib,
label: "RAM"
},
{
value: memData.swap.usage.mem_used_in_kib,
max: memData.swap.usage.mem_total_in_kib,
label: "Swap"
}
]
$: attrs = [
{
key: "Available:",
value: toMetric(memData.ram.usage.mem_total_in_kib * 1024)
},
{ key: "Used:", value: toMetric(memData.ram.usage.mem_used_in_kib * 1024) }
]
$: plotData = {
x: memData.ram.percentages.map((_, i) => i),
y: memData.ram.percentages,
color: $foregroundColor
}
$: memSortedProcesses = [...processList]
.sort((a, b) => a.mem_usage_bytes < b.mem_usage_bytes)
.slice(0, 5)
</script>
<ArcWidget {attrs}>
<ArcStack slot="arcStack" {arcs} size={120} strokeWidth={calcStrokeWidth(arcs.length)} />
<ProcessList slot="content" title="Mem" plotDatas={[plotData]} processList={memSortedProcesses}>
<span slot="processVal" let:process>{toMetric(process.mem_usage_bytes)}</span>
</ProcessList>
</ArcWidget>

View File

@ -0,0 +1,76 @@
<script>
export let networkData
export let localIp = null
export let hostname
import { calcStrokeWidth, toMetric } from "../lib/utils"
import { uPlotAction } from "../lib/actions"
import { titleColor, accentColor } from "../lib/stores"
import ArcWidget from "./ArcWidget.svelte"
import ArcStack from "./ArcStack.svelte"
$: attrs = [
{ key: "Hostname:", value: hostname },
{ key: "Local IP:", value: localIp }
]
$: arcs = [
{ label: "Down", value: networkData.rx.at(-1), max: 100 * 1024 ** 2 },
{ label: "Up", value: networkData.tx.at(-1), max: 100 * 1024 ** 2 }
]
$: inPlotData = {
x: networkData.rx.map((_, i) => i),
y: networkData.rx,
color: $accentColor
}
$: outPlotData = {
x: networkData.tx.map((_, i) => i),
y: networkData.tx,
color: $titleColor
}
</script>
<ArcWidget {attrs}>
<ArcStack slot="arcStack" {arcs} size={120} strokeWidth={calcStrokeWidth(arcs.length)} />
<div slot="content">
<div class="flex">
<h2>Net</h2>
<div class="plot-container">
<div use:uPlotAction={inPlotData} />
<p class="caption">
Down: {toMetric(networkData.rx.at(-1))}
</p>
<div use:uPlotAction={outPlotData} />
<p class="caption">
Up: {toMetric(networkData.tx.at(-1))}
</p>
</div>
</div>
</div>
</ArcWidget>
<style>
.flex {
display: flex;
}
h2 {
margin: 0;
font-size: 13px;
font-weight: 500;
padding-right: 5px;
width: 48px;
text-align: right;
color: var(--titleColor);
}
.plot-container {
position: relative;
}
.caption {
text-align: right;
margin-top: 5px;
}
</style>

View File

@ -0,0 +1,141 @@
<script lang="ts">
export let preferencesVisible
import ColorPicker from "svelte-awesome-color-picker"
import { get } from "svelte/store"
import {
foregroundColor,
backgroundColor,
titleColor,
accentColor,
fontFamily,
arcTrackColor,
arcCapColor
} from "../lib/stores"
function closePreferences() {
preferencesVisible = false
}
</script>
<aside>
<h1>Preferences</h1>
<button on:click={closePreferences} aria-label="close"></button>
<div class="picker">
<ColorPicker
rgb={get(foregroundColor).toRgb()}
label="Foreground color"
isAlpha={false}
on:input={e => foregroundColor.set(e.detail.color)}
/>
</div>
<div class="picker">
<ColorPicker
rgb={get(backgroundColor).toRgb()}
label="Background color"
on:input={e => backgroundColor.set(e.detail.color)}
/>
</div>
<div class="picker">
<ColorPicker
rgb={get(titleColor).toRgb()}
label="Title color"
isAlpha={false}
on:input={e => titleColor.set(e.detail.color)}
/>
</div>
<div class="picker">
<ColorPicker
rgb={get(accentColor).toRgb()}
label="Accent color"
isAlpha={false}
on:input={e => accentColor.set(e.detail.color)}
/>
</div>
<div class="picker">
<ColorPicker
rgb={get(arcTrackColor).toRgb()}
label="Arc track color"
isAlpha={true}
on:input={e => arcTrackColor.set(e.detail.color)}
/>
</div>
<div class="picker">
<ColorPicker
rgb={get(arcCapColor).toRgb()}
label="Arc cap color"
isAlpha={true}
on:input={e => arcCapColor.set(e.detail.color)}
/>
</div>
<form on:submit|preventDefault={() => {}}>
<fieldset>
<div>
<label for="font-family">Font</label>
<input
type="text"
id="font-family"
value={get(fontFamily)}
on:change={e => fontFamily.set(e.target.value)}
placeholder="Avenir, Arial"
/>
</div>
</fieldset>
</form>
</aside>
<style>
aside {
position: relative;
width: 325px;
background-color: white;
color: #2b3e51;
padding: 15px;
}
h1 {
font-weight: 500;
margin-bottom: 20px;
}
.picker {
margin-bottom: 20px;
font-size: 12px;
}
label {
font-size: 14px;
}
input {
width: 100%;
padding: 12px;
border: 1px solid #cfd9db;
background-color: #ffffff;
border-radius: 0.25em;
box-shadow: inset 0 1px 1px rgb(0 0 0 / 8%);
}
input:focus {
outline: none;
border-color: #2c3e50;
box-shadow: 0 0 5px rgb(44 151 222 / 20%);
}
button {
position: absolute;
top: 10px;
right: 10px;
border: none;
background-color: white;
font-size: 30px;
border-radius: 5px;
cursor: pointer;
}
button:hover {
background-color: #f1f1f1;
}
</style>

View File

@ -0,0 +1,105 @@
<script lang="ts">
export let title: string
export let plotDatas: Array<{ x: Array<number>; y: Array<number> }>
export let processList: Array<Process>
import type { Colord } from "colord"
import { uPlotAction, styleVars } from "../lib/actions"
import { foregroundColor } from "../lib/stores"
function attenuateLightness(color: Colord): Colord {
const lightness = color.toHsl().l / 100 // 0 to 1
const delta = lightness - 0.5
return delta >= 0 ? color.darken(delta / 2) : color.lighten(delta / -2)
}
function halveSaturation(color: Colord): Colord {
const hsl = color.toHsl()
return color.desaturate(hsl.s / 2 / 100)
}
$: cssVars = {
mutedForegroundColor: attenuateLightness(halveSaturation($foregroundColor)).toHslString()
}
</script>
<div class="flex">
<h2>{title}</h2>
<div class="plot-container">
{#each plotDatas as plotData, i}
{#if i === 0}
<div use:uPlotAction={plotData} />
{:else}
<div class="plot-wrap">
<div use:uPlotAction={plotData} />
</div>
{/if}
{/each}
</div>
</div>
<ul class="processes" use:styleVars={cssVars}>
{#each processList as process}
<li>
<div class="key">{process.name}</div>
<div class="value">
<slot name="processVal" {process} />
</div>
</li>
{/each}
</ul>
<style>
.flex {
display: flex;
}
h2 {
margin: 0;
font-size: 13px;
font-weight: 500;
padding-right: 5px;
width: 48px;
text-align: right;
color: var(--titleColor);
}
.plot-container {
position: relative;
}
.plot-wrap {
position: absolute;
top: 0;
left: 0;
}
.processes {
margin: 0;
padding-left: 40px;
}
.processes li {
display: flex;
}
.processes li:first-child {
color: var(--accentColor);
}
.processes li:nth-child(n + 3) {
color: var(--mutedForegroundColor);
}
.key {
flex-grow: 1;
margin-right: 10px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.value {
text-align: right;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,44 @@
<script lang="ts">
export let summaryData: SummaryData
</script>
<header data-tauri-drag-region>
<h1>System</h1>
<div class="divider" />
</header>
<ul>
<li><strong>Uptime:</strong> {summaryData.uptime}</li>
<li><strong>OS Version:</strong> {summaryData.os_version}</li>
<li>
<strong>Kernel:</strong>
{summaryData.kernel_name}
{summaryData.kernel_version}
</li>
</ul>
<style>
header {
display: flex;
padding: 5px 10px 0 10px;
}
h1 {
font-weight: 500;
font-size: 13px;
padding-right: 3px;
}
.divider {
flex-grow: 1;
border-top: 1px solid var(--foregroundColor);
height: 0;
align-self: center;
}
ul {
margin-top: 0;
margin-bottom: 20px;
padding-left: 10px;
}
</style>

60
src/lib/actions.ts Normal file
View File

@ -0,0 +1,60 @@
import uPlot from "uplot"
import type { Options } from "uplot"
import { colord, Colord } from "colord"
export function styleVars(node: HTMLElement, props: Record<string, any>) {
Object.entries(props).forEach(([key, value]) => {
node.style.setProperty(`--${key}`, value)
})
return {
update(newProps: Record<string, any>) {
Object.entries(newProps).forEach(([key, value]) => {
node.style.setProperty(`--${key}`, value)
delete props[key]
})
Object.keys(props).forEach(name => node.style.removeProperty(`--${name}`))
props = newProps
}
}
}
export interface PlotData {
x: Array<number>
y: Array<number>
color?: Colord
}
function makePlotOptions(color: Colord): Options {
const stroke = color.alpha(0.7).lighten(0.2).toHslString()
const fill = color.alpha(0.5).lighten(0.2).toHslString()
return {
width: 180,
height: 15,
pxAlign: false,
cursor: { show: false },
select: { show: false },
legend: { show: false },
scales: {
x: { time: false }
},
axes: [{ show: false }, { show: false }],
series: [{}, { stroke, fill }]
}
}
export function uPlotAction(node: HTMLElement, data: PlotData) {
const { x, y, color } = data
const options = makePlotOptions(color || colord("#fff"))
const plot = new uPlot(options, [x, y], node)
return {
update(newData: PlotData) {
plot.setData([newData.x, newData.y])
plot.series[1].stroke = () =>
(newData.color || colord("#fff")).alpha(0.7).lighten(0.2).toHslString()
plot.series[1].fill = () =>
(newData.color || colord("#fff")).alpha(0.5).lighten(0.2).toHslString()
}
}
}

10
src/lib/stores.ts Normal file
View File

@ -0,0 +1,10 @@
import { writable } from "svelte/store"
import { colord } from "colord"
export const foregroundColor = writable(colord("#ffffff"))
export const backgroundColor = writable(colord("rgba(0, 0, 0, 0.5)"))
export const titleColor = writable(colord("#00ff00"))
export const accentColor = writable(colord("#ff00ff"))
export const arcTrackColor = writable(colord("rgba(255, 255, 255, 0.2)"))
export const arcCapColor = writable(colord("#ffffff"))
export const fontFamily = writable("Inter, Avenir, Helvetica, Arial, sans-serif")

45
src/lib/utils.ts Normal file
View File

@ -0,0 +1,45 @@
export function toMetric(bytes: number | null, precision = 1): string {
if (!bytes) {
return "0B"
}
const units = ["B", "kB", "MB", "GB", "TB", "PB", "EB"]
let unitIndex = 0
while (bytes > 1000) {
bytes /= 1024
unitIndex++
}
return bytes.toFixed(precision) + units[unitIndex]
}
export function calcStrokeWidth(numArcs: number): number {
if (numArcs === 1) {
return 12
}
if (numArcs < 4) {
return 10
}
if (numArcs === 4) {
return 8
}
if (numArcs === 5) {
return 6
}
if (numArcs === 6) {
return 5
}
if (numArcs < 9) {
return 4
}
return 3
}
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
export function saturatedPush<T>(arr: Array<T>, element: T, limit: number) {
arr.push(element)
while (arr.length > limit) {
arr.shift()
}
}

8
src/main.ts Normal file
View File

@ -0,0 +1,8 @@
import "./style.css"
import App from "./App.svelte"
const app = new App({
target: document.getElementById("app")
})
export default app

38
src/style.css Normal file
View File

@ -0,0 +1,38 @@
:root {
font-family: Inter, Avenir, Helvetica, Arial, sans-serif;
font-size: 10px;
font-weight: 400;
line-height: 1.4;
color: white;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-text-size-adjust: 100%;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
ul {
list-style-type: none;
}
fieldset {
border: none;
}
[data-tauri-drag-region] {
cursor: grab;
}
[data-tauri-drag-region]:active {
cursor: grabbing;
}

93
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,93 @@
/* eslint no-unused-vars: 0 */
/// <reference types="svelte" />
/// <reference types="vite/client" />
interface CPUData {
cpu_usage: number
}
interface Process {
name: string
command: string
pid: string
parent_pid: string | null
cpu_usage_percent: number
mem_usage_percent: number
mem_usage_bytes: number
read_bytes_per_sec: number
write_bytes_per_sec: number
total_read_bytes: number
total_write_bytes: number
process_state: [string, string]
uid: string | null
user: string | null
}
interface DiskData {
name: string
mount_point: string
free_space: number | null
used_space: number | null
total_space: number | null
}
interface MemData {
mem_total_in_kib: number
mem_used_in_kib: number
use_percent: number | null
}
interface NetData {
rx: number
tx: number
total_rx: number
total_tx: number
}
interface TempData {
name: string
temperature: number
}
interface BatteryData {
charge_percent: number
secs_until_full: number | null
secs_until_empty: number | null
power_consumption_rate_watts: number
health_percent: number
}
interface SummaryData {
uptime: string
hostname: string | null
kernel_name: string | null
kernel_version: string | null
os_version: string | null
}
interface IOData {
read_bytes: number
write_bytes: number
}
interface Data {
last_collection_time: number
uptime: string
hostname: string | null
kernel_name: string | null
kernel_version: string | null
os_version: string | null
list_of_processes: Array<Process>
cpu: Array<CPUData>
load_avg: Array<number>
memory: MemData
swap: MemData
disks: Array<DiskData>
io: Record<string, IOData>
local_ip: string | null
network: NetData
temperature_sensors: Array<TempData>
list_of_batteries?: Array<BatteryData>
arc?: MemData
gpu?: Array<[string, MemData]>
}

21
tsconfig.json Normal file
View File

@ -0,0 +1,21 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"resolveJsonModule": true,
"baseUrl": ".",
/**
* Typecheck JS in `.svelte` and `.js` files by default.
* Disable checkJs if you'd like to use dynamic types in JS.
* Note that setting allowJs false does not prevent the use
* of JS in `.svelte` files.
*/
"allowJs": true,
"checkJs": true,
"isolatedModules": true
},
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }]
}

8
tsconfig.node.json Normal file
View File

@ -0,0 +1,8 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node"
},
"include": ["vite.config.ts"]
}

40
vite.config.ts Normal file
View File

@ -0,0 +1,40 @@
import { defineConfig } from "vite"
import { svelte } from "@sveltejs/vite-plugin-svelte"
import sveltePreprocess from "svelte-preprocess"
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
svelte({
preprocess: [
sveltePreprocess({
typescript: true
})
]
})
],
resolve: {
alias: {
path: "path-browserify"
}
},
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
// prevent vite from obscuring rust errors
clearScreen: false,
// tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true
},
// to make use of `TAURI_DEBUG` and other env variables
// https://tauri.studio/v1/api/config#buildconfig.beforedevcommand
envPrefix: ["VITE_", "TAURI_"],
build: {
// Tauri supports es2021
target: process.env.TAURI_PLATFORM === "windows" ? "chrome105" : "safari13",
// don't minify for debug builds
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
// produce sourcemaps for debug builds
sourcemap: !!process.env.TAURI_DEBUG
}
})