diff --git a/Cargo.lock b/Cargo.lock index b9c2f0d..d771ddf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,6 +292,9 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "block" @@ -1380,6 +1383,8 @@ name = "itop" version = "0.1.0" dependencies = [ "iced", + "ron", + "serde", "smol", "sysinfo", ] @@ -2482,6 +2487,20 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.1", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -3091,6 +3110,12 @@ dependencies = [ "core_maths", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "uds_windows" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 5a74d3e..00abd23 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,10 +5,23 @@ license = "AGPL-3.0-or-later" version = "0.1.0" edition = "2024" +[features] +default = [ "app" ] +app = [ "dep:iced", "dep:smol" ] + [dependencies] -smol = "2.0.2" +ron = "0.12.2" sysinfo = "0.39.6" [dependencies.iced] version = "0.14.0" features = [ "canvas", "smol" ] +optional = true + +[dependencies.smol] +version = "2.0.2" +optional = true + +[dependencies.serde] +version = "1.0.229" +features = [ "derive" ] diff --git a/flake.nix b/flake.nix index 827d996..1a338f0 100644 --- a/flake.nix +++ b/flake.nix @@ -58,7 +58,6 @@ desktopItem = pkgs.makeDesktopItem { inherit name; desktopName = name; - icon = name; exec = name; }; in @@ -89,6 +88,13 @@ { packages.default = crate; + packages."${name}-server-only" = craneLib.buildPackage { + name = "${name}-server-only"; + src = ./.; + doCheck = false; + cargoExtraArgs = "--locked --no-default-features"; + }; + checks = { crate-clippy = craneLib.cargoClippy ( commonArgs diff --git a/src/app/graph.rs b/src/app/graph.rs index 8a3796a..5dea431 100644 --- a/src/app/graph.rs +++ b/src/app/graph.rs @@ -6,18 +6,42 @@ use crate::app; const CYAN: Color = Color::from_rgb8(0, 255, 255); -#[derive(Default)] pub struct Graph { - pub cache: canvas::Cache, timeline: VecDeque, + + pub show_borders: bool, + pub cache: canvas::Cache, +} +impl Default for Graph { + fn default() -> Self { + Self { + show_borders: true, + + timeline: Default::default(), + cache: Default::default(), + } + } } impl Graph { + pub fn new(value: f32) -> Self { + Self { + timeline: { + let mut vec = VecDeque::new(); + vec.push_front(value); + vec + }, + ..Default::default() + } + } + pub fn update(&mut self, value: f32) { self.timeline.push_back(value); - if self.timeline.len() > 1000 { + if self.timeline.len() > 500 { self.timeline.pop_front(); } + + self.cache.clear(); } } @@ -32,11 +56,19 @@ impl canvas::Program for Graph { _cursor: mouse::Cursor, ) -> Vec> { let geometry = self.cache.draw(renderer, bounds.size(), |frame| { + if self.show_borders { + frame.stroke_rectangle( + Point::new(0., 0.), + bounds.size(), + canvas::Stroke::default().with_color(Color::from_rgb8(255, 0, 0)), + ); + } frame.stroke( &canvas::Path::new(|builder| { for (index, value) in self.timeline.iter().enumerate() { let x = index as f32 / self.timeline.len() as f32 * bounds.width; - let y = bounds.height - value / 100. * bounds.height; + let y = (bounds.height - value / 100. * bounds.height) + .clamp(1., bounds.height - 1.); builder.line_to(Point::new(x, y)) } }), diff --git a/src/app/info_stream.rs b/src/app/info_stream.rs new file mode 100644 index 0000000..787e459 --- /dev/null +++ b/src/app/info_stream.rs @@ -0,0 +1,64 @@ +use std::env; + +use iced::{ + Task, + futures::{self, SinkExt}, + stream, +}; +use ron::de; +use smol::{Timer, unblock}; + +use crate::{ + app, + sysinfo_snapshot::{self, SysinfoSnapshot}, +}; + +pub fn create() -> Task { + if env::args() + .nth(1) + .is_some_and(|command| command == "client") + { + Task::run( + stream::channel( + 1, + async |mut sender: futures::channel::mpsc::Sender| { + let mut buf = String::new(); + loop { + buf = unblock(move || { + std::io::stdin().read_line(&mut buf).unwrap(); + buf + }) + .await; + + match de::from_str(&buf) { + Ok(info) => sender.send(info).await.unwrap(), + Err(e) => eprintln!("{e}"), + } + buf.clear(); + } + }, + ), + app::Message::Info, + ) + } else { + Task::run( + stream::channel( + 1, + async |mut sender: futures::channel::mpsc::Sender| { + let mut sys = sysinfo::System::new(); + let refreshes = sysinfo_snapshot::refreshes(); + + sys.refresh_specifics(refreshes); + Timer::after(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL).await; + + loop { + sys.refresh_specifics(refreshes); + sender.send(SysinfoSnapshot::from(&sys)).await.unwrap(); + Timer::after(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL).await; + } + }, + ), + app::Message::Info, + ) + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index f48d29a..c636868 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,66 +1,198 @@ -use iced::{ - Color, Element, Length, Renderer, Task, Theme, application, futures::SinkExt, stream, theme, - widget, -}; -use smol::Timer; +use std::time::Duration; -use crate::app::graph::Graph; +use iced::{ + Color, Error, Event, Length, Renderer, Subscription, Task, Theme, application, event, + keyboard::{self, Key}, + theme, widget, +}; + +use crate::{ + app::{graph::Graph, utils::maybe_widget}, + sysinfo_snapshot::{ProcessSnapshot, SysinfoSnapshot}, + utils::Toggle, +}; mod graph; +mod info_stream; +mod utils; + +type Element<'a> = iced::Element<'a, Message, Theme, Renderer>; enum Message { - Cpu(f32), + ToggleMain, + ToggleCpu, + ToggleMemory, + ToggleProcesses, + + Info(SysinfoSnapshot), } +#[derive(Default)] struct State { - cpu_graph: Graph, + uptime: Duration, + + is_main_section_shown: bool, + total_cpu_graph: Graph, + + is_cpu_section_shown: bool, + cpu_graphs: Vec, + + is_memory_section_shown: bool, + memory_graph: Graph, + swap_graph: Graph, + + is_processes_section_shown: bool, + processes: Vec, } impl State { fn new() -> (Self, Task) { - ( - Self { - cpu_graph: Default::default(), - }, - Task::run( - stream::channel(1, async |mut sender| { - let mut sys = sysinfo::System::new(); - let refreshes = sysinfo::RefreshKind::nothing() - .with_cpu(sysinfo::CpuRefreshKind::nothing().with_cpu_usage()); + let mut state = Self { + is_main_section_shown: true, + ..Default::default() + }; + state.total_cpu_graph.show_borders = false; - sys.refresh_specifics(refreshes); - Timer::after(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL).await; - - loop { - sys.refresh_specifics(refreshes); - Timer::after(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL).await; - sender.send(sys.global_cpu_usage()).await.unwrap(); - } - }), - Message::Cpu, - ), - ) + (state, info_stream::create()) } fn update(&mut self, message: Message) -> Task { match message { - Message::Cpu(value) => { - self.cpu_graph.update(value); - self.cpu_graph.cache.clear(); + Message::ToggleMain => { + self.is_main_section_shown.toggle(); + self.check_borders(); + } + Message::ToggleCpu => { + self.is_cpu_section_shown.toggle(); + self.check_borders(); + } + Message::ToggleMemory => { + self.is_memory_section_shown.toggle(); + self.check_borders(); + } + Message::ToggleProcesses => { + self.is_processes_section_shown.toggle(); + self.check_borders(); + } + + Message::Info(snapshot) => { + self.uptime = snapshot.uptime(); + + self.total_cpu_graph.update(snapshot.cpu_total); + + self.memory_graph + .update(snapshot.memory as f32 / snapshot.memory_total as f32 * 100.); + + self.swap_graph + .update(snapshot.swap as f32 / snapshot.swap_total as f32 * 100.); + + if self.cpu_graphs.len() != snapshot.cpus.len() { + self.cpu_graphs = snapshot + .cpus + .into_iter() + .map(|value| Graph::new(value)) + .collect(); + } else { + self.cpu_graphs + .iter_mut() + .zip(snapshot.cpus.into_iter()) + .for_each(|(graph, value)| graph.update(value)); + } + + self.processes = snapshot.processes; + self.processes + .sort_by(|a, b| b.cpu_usage.total_cmp(&a.cpu_usage)); } } Task::none() } - fn view(&self) -> Element<'_, Message, Theme, Renderer> { - widget::Canvas::new(&self.cpu_graph) - .width(Length::Fill) - .height(Length::Fill) - .into() + fn view(&self) -> Element<'_> { + widget::column([ + widget::row([ + maybe_widget(self.is_main_section_shown, || { + widget::canvas(&self.total_cpu_graph) + .width(Length::Fill) + .height(Length::Fill) + .into() + }), + maybe_widget(self.is_cpu_section_shown, || { + widget::grid( + self.cpu_graphs + .iter() + .map(|graph| widget::Canvas::new(graph).into()), + ) + .columns(2) + .height(Length::Fill) + .into() + }), + ]) + .into(), + widget::row([ + maybe_widget(self.is_memory_section_shown, || { + widget::row([ + widget::canvas(&self.memory_graph) + .width(Length::Fill) + .height(Length::Fill) + .into(), + widget::canvas(&self.swap_graph) + .width(Length::Fill) + .height(Length::Fill) + .into(), + ]) + .into() + }), + maybe_widget(self.is_processes_section_shown, || { + widget::scrollable( + widget::grid(self.processes.iter().flat_map(|process| { + [ + widget::text(&process.name).into(), + widget::text(process.cpu_usage).into(), + widget::text(format!("{:?}", process.cpu_time())).into(), + widget::text(process.pid).into(), + ] + })) + .height(Length::Shrink) + .columns(4), + ) + .width(Length::Fill) + .height(Length::Fill) + .into() + }), + ]) + .into(), + ]) + .into() + } + + fn subscription(&self) -> Subscription { + event::listen().filter_map(|event| match event { + Event::Keyboard(keyboard::Event::KeyPressed { modified_key, .. }) => { + match modified_key.as_ref() { + Key::Character("M" | "1") => Some(Message::ToggleMain), + Key::Character("c" | "2") => Some(Message::ToggleCpu), + Key::Character("m" | "3") => Some(Message::ToggleMemory), + Key::Character("p" | "4") => Some(Message::ToggleProcesses), + _ => None, + } + } + _ => None, + }) + } + + fn title(&self) -> String { + format!("itop {:?}", self.uptime) + } + + fn check_borders(&mut self) { + self.total_cpu_graph.show_borders = + self.is_cpu_section_shown || self.is_memory_section_shown; } } -pub fn run() -> Result<(), impl std::error::Error> { +pub fn run() -> Result<(), Error> { application(State::new, State::update, State::view) + .subscription(State::subscription) + .title(State::title) .theme(theme::Theme::custom( "high-contrast-dark", theme::Palette { diff --git a/src/app/utils.rs b/src/app/utils.rs new file mode 100644 index 0000000..ceef158 --- /dev/null +++ b/src/app/utils.rs @@ -0,0 +1,14 @@ +use iced::widget; + +use crate::app; + +pub fn maybe_widget<'a>( + condition: bool, + widget: impl FnOnce() -> app::Element<'a>, +) -> app::Element<'a> { + if condition { + widget() + } else { + widget::space().into() + } +} diff --git a/src/main.rs b/src/main.rs index 2e13470..327e179 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,19 @@ -mod app; +use std::env; -fn main() -> Result<(), impl std::error::Error> { - app::run() +#[cfg(feature = "app")] +mod app; +mod server; +mod sysinfo_snapshot; +mod utils; + +fn main() { + match env::args().nth(1).as_deref() { + #[cfg(feature = "app")] + None | Some("client") => app::run().unwrap(), + + #[cfg_attr(feature = "app", allow(unreachable_patterns))] + None | Some("server") => server::run().unwrap(), + + Some(s) => panic!("invalid argument: {s}"), + } } diff --git a/src/server/mod.rs b/src/server/mod.rs new file mode 100644 index 0000000..5777c84 --- /dev/null +++ b/src/server/mod.rs @@ -0,0 +1,21 @@ +use std::{io::Write, thread}; + +use ron::{Error, ser}; + +use crate::sysinfo_snapshot::{self, SysinfoSnapshot}; + +pub fn run() -> Result<(), Error> { + let mut sys = sysinfo::System::new(); + let refreshes = sysinfo_snapshot::refreshes(); + + sys.refresh_specifics(refreshes); + thread::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL); + + let mut stdout = std::io::stdout(); + + loop { + sys.refresh_specifics(refreshes); + stdout.write_all((ser::to_string(&SysinfoSnapshot::from(&sys))? + "\n").as_bytes())?; + thread::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL); + } +} diff --git a/src/sysinfo_snapshot.rs b/src/sysinfo_snapshot.rs new file mode 100644 index 0000000..ef0f36e --- /dev/null +++ b/src/sysinfo_snapshot.rs @@ -0,0 +1,87 @@ +use std::time::Duration; + +pub fn refreshes() -> sysinfo::RefreshKind { + sysinfo::RefreshKind::nothing() + .with_memory(sysinfo::MemoryRefreshKind::everything()) + .with_cpu(sysinfo::CpuRefreshKind::nothing().with_cpu_usage()) + .with_processes( + sysinfo::ProcessRefreshKind::nothing() + .with_cpu() + .with_cmd(sysinfo::UpdateKind::OnlyIfNotSet) + .with_exe(sysinfo::UpdateKind::OnlyIfNotSet), + ) +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct SysinfoSnapshot { + /// in seconds + uptime: u64, + + pub cpu_total: f32, + pub cpus: Vec, + + pub memory: u64, + pub memory_total: u64, + pub swap: u64, + pub swap_total: u64, + + pub processes: Vec, +} +impl SysinfoSnapshot { + #[cfg_attr(not(feature = "app"), allow(dead_code))] + pub fn uptime(&self) -> Duration { + Duration::from_secs(self.uptime) + } +} + +impl From<&sysinfo::System> for SysinfoSnapshot { + fn from(value: &sysinfo::System) -> Self { + Self { + uptime: sysinfo::System::uptime(), + + cpu_total: value.global_cpu_usage(), + cpus: value.cpus().iter().map(|cpu| cpu.cpu_usage()).collect(), + + memory: value.used_memory(), + memory_total: value.total_memory(), + swap: value.used_swap(), + swap_total: value.total_swap(), + + processes: value + .processes() + .iter() + .map(|(_pid, process)| ProcessSnapshot::from(process)) + .collect(), + } + } +} + +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct ProcessSnapshot { + pub pid: usize, + + /// in CPU-milliseconds + accumulated_cpu_time: u64, + + /// note: it's by core, and can be up to eg 1600 on a 16-core device + pub cpu_usage: f32, + + pub name: String, +} +impl ProcessSnapshot { + pub fn cpu_time(&self) -> Duration { + Duration::from_millis(self.accumulated_cpu_time) + } +} + +impl From<&sysinfo::Process> for ProcessSnapshot { + fn from(value: &sysinfo::Process) -> Self { + Self { + pid: value.pid().into(), + accumulated_cpu_time: value.accumulated_cpu_time(), + cpu_usage: value.cpu_usage(), + + name: value.name().to_string_lossy().into_owned(), + } + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..622bff1 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,10 @@ +#[cfg_attr(not(feature = "app"), allow(dead_code))] +pub trait Toggle { + fn toggle(&mut self); +} + +impl Toggle for bool { + fn toggle(&mut self) { + *self = !*self + } +}