Compare commits

..
Author SHA1 Message Date
c1d46a4be0
build: update deps 2026-10-07 20:35:52 -07:00
ba96aa4d6b
feat: reduce parrelization, maximize LTO
this reduces the binary size from 22MB to 15MB
(via `nix build`, as reported by `eza`)
2026-09-25 11:30:21 -07:00
3fa2163366
refactor: converge naming from main branch 2026-08-18 14:22:37 -07:00
12 changed files with 350 additions and 757 deletions

599
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,23 +5,14 @@ license = "AGPL-3.0-or-later"
version = "0.1.0"
edition = "2024"
[features]
default = [ "app" ]
app = [ "dep:iced", "dep:smol" ]
[dependencies]
ron = "0.12.2"
smol = "2.0.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" ]
[profile.release]
lto = true
codegen-units = 1

23
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1785782307,
"narHash": "sha256-MPaRdVkf6zZP5fCPxYCi8Dr4pZzgmXzg8T9nVEbp3Mw=",
"lastModified": 1790899423,
"narHash": "sha256-THGtz65UGJU+sonZidRboyCMk+9DclpMF7pwL4uDV4M=",
"owner": "ipetkov",
"repo": "crane",
"rev": "2c71e194474d13de031d729b729c968ddbe3507f",
"rev": "47b6b27ed9a3a9181415e4367d0c30ab2a0e0250",
"type": "github"
},
"original": {
@ -35,18 +35,15 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1785747939,
"narHash": "sha256-D740uKsMbgsfK2oaDenJLLPIZfq7W0/g4KN/Fls8eKs=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "104240a772428cc2e20d8fd86c9ddbb886bbaff2",
"type": "github"
"lastModified": 1791260994,
"narHash": "sha256-MFBjw66d4h06MvpW6lIl7RwarLteD0sk859CncGwb1Q=",
"rev": "151fa4e8ddfdd8dd25d945ad94ed54a13de9f6e4",
"type": "tarball",
"url": "https://releases.nixos.org/nixos/unstable/nixos-26.11pre1086391.151fa4e8ddfd/nixexprs.tar.zst?lastModified=1791260994&rev=151fa4e8ddfdd8dd25d945ad94ed54a13de9f6e4"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
"type": "tarball",
"url": "https://channels.nixos.org/nixos-unstable/nixexprs.tar.zst"
}
},
"root": {

View file

@ -1,6 +1,6 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixpkgs-unstable";
nixpkgs.url = "https://channels.nixos.org/nixos-unstable/nixexprs.tar.zst";
crane.url = "github:ipetkov/crane";
@ -58,6 +58,7 @@
desktopItem = pkgs.makeDesktopItem {
inherit name;
desktopName = name;
icon = name;
exec = name;
};
in
@ -88,13 +89,6 @@
{
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

View file

@ -6,42 +6,18 @@ use crate::app;
const CYAN: Color = Color::from_rgb8(0, 255, 255);
#[derive(Default)]
pub struct Graph {
timeline: VecDeque<f32>,
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(),
}
}
timeline: VecDeque<f32>,
}
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() > 500 {
if self.timeline.len() > 1000 {
self.timeline.pop_front();
}
self.cache.clear();
}
}
@ -56,19 +32,11 @@ impl canvas::Program<app::Message, Theme, Renderer> for Graph {
_cursor: mouse::Cursor,
) -> Vec<canvas::Geometry<Renderer>> {
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)
.clamp(1., bounds.height - 1.);
let y = bounds.height - value / 100. * bounds.height;
builder.line_to(Point::new(x, y))
}
}),

View file

@ -1,64 +0,0 @@
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<app::Message> {
if env::args()
.nth(1)
.is_some_and(|command| command == "client")
{
Task::run(
stream::channel(
1,
async |mut sender: futures::channel::mpsc::Sender<SysinfoSnapshot>| {
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<SysinfoSnapshot>| {
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,
)
}
}

View file

@ -1,198 +1,66 @@
use std::time::Duration;
use iced::{
Color, Error, Event, Length, Renderer, Subscription, Task, Theme, application, event,
keyboard::{self, Key},
theme, widget,
Color, Element, Length, Renderer, Task, Theme, application, futures::SinkExt, stream, theme,
widget,
};
use smol::Timer;
use crate::{
app::{graph::Graph, utils::maybe_widget},
sysinfo_snapshot::{ProcessSnapshot, SysinfoSnapshot},
utils::Toggle,
};
use crate::app::graph::Graph;
mod graph;
mod info_stream;
mod utils;
type Element<'a> = iced::Element<'a, Message, Theme, Renderer>;
enum Message {
ToggleMain,
ToggleCpu,
ToggleMemory,
ToggleProcesses,
Info(SysinfoSnapshot),
Cpu(f32),
}
#[derive(Default)]
struct State {
uptime: Duration,
is_main_section_shown: bool,
total_cpu_graph: Graph,
is_cpu_section_shown: bool,
cpu_graphs: Vec<Graph>,
is_memory_section_shown: bool,
memory_graph: Graph,
swap_graph: Graph,
is_processes_section_shown: bool,
processes: Vec<ProcessSnapshot>,
cpu_graph: Graph,
}
impl State {
fn new() -> (Self, Task<Message>) {
let mut state = Self {
is_main_section_shown: true,
..Default::default()
};
state.total_cpu_graph.show_borders = false;
(
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());
(state, info_stream::create())
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,
),
)
}
fn update(&mut self, message: Message) -> Task<Message> {
match message {
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));
Message::Cpu(value) => {
self.cpu_graph.update(value);
self.cpu_graph.cache.clear();
}
}
Task::none()
}
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<Message> {
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;
fn view(&self) -> Element<'_, Message, Theme, Renderer> {
widget::Canvas::new(&self.cpu_graph)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
}
pub fn run() -> Result<(), Error> {
pub fn run() -> Result<(), impl std::error::Error> {
application(State::new, State::update, State::view)
.subscription(State::subscription)
.title(State::title)
.theme(theme::Theme::custom(
"high-contrast-dark",
theme::Palette {

View file

@ -1,14 +0,0 @@
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()
}
}

View file

@ -1,19 +1,5 @@
use std::env;
#[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}"),
}
fn main() -> Result<(), impl std::error::Error> {
app::run()
}

View file

@ -1,21 +0,0 @@
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);
}
}

View file

@ -1,87 +0,0 @@
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<f32>,
pub memory: u64,
pub memory_total: u64,
pub swap: u64,
pub swap_total: u64,
pub processes: Vec<ProcessSnapshot>,
}
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(),
}
}
}

View file

@ -1,10 +0,0 @@
#[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
}
}