Vectrum
Sign In
248 lines · 10.0 KB · Rust
//! The Diagnostics page (General → Diagnostics), a slice of T3 Code's: what
//! the app and the agents it runs use right now, and every process the app
//! started, with buttons to interrupt or kill one. Read from `/proc`.

use std::{
    collections::HashMap,
    time::{Duration, Instant, SystemTime},
};

use gpui_kit::component::{IconName, h_flex, v_flex};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;

use super::widgets::{Section, ago, empty_row};
use super::{Page, SettingsView};
use crate::ui::metal::{self, MetalButton, MetalStyled as _, Palette};

/// One process under the app, as `/proc` describes it.
#[derive(Clone, Debug)]
pub(super) struct Process {
    pub pid: u32,
    pub parent: u32,
    pub name: String,
    pub command: String,
    /// CPU time so far, in clock ticks.
    pub ticks: u64,
    pub rss_bytes: u64,
    /// Share of one core over the last interval, in percent.
    pub cpu: f32,
}

#[derive(Default)]
pub(super) struct DiagnosticsState {
    processes: Vec<Process>,
    checked: Option<SystemTime>,
    sampled: Option<Instant>,
    /// Keeps refreshing while the page is open.
    polling: bool,
}

/// Every process descending from this one, this one first.
fn read_processes() -> Vec<Process> {
    let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }.max(1) as u64;
    let mut all: HashMap<u32, Process> = HashMap::new();
    for entry in std::fs::read_dir("/proc").into_iter().flatten().flatten() {
        let Some(pid) = entry.file_name().to_str().and_then(|name| name.parse::<u32>().ok()) else { continue };
        let Ok(stat) = std::fs::read_to_string(entry.path().join("stat")) else { continue };
        // `pid (name) state ppid …`; the name may hold spaces and parens.
        let (Some(open), Some(close)) = (stat.find('('), stat.rfind(')')) else { continue };
        let name = stat[open + 1..close].to_string();
        let fields: Vec<&str> = stat[close + 2..].split_whitespace().collect();
        let field = |ix: usize| fields.get(ix).and_then(|value| value.parse::<u64>().ok()).unwrap_or(0);
        // After the name: state(0) ppid(1) … utime(11) stime(12) … rss(21).
        let command = std::fs::read(entry.path().join("cmdline"))
            .map(|bytes| String::from_utf8_lossy(&bytes).replace('\0', " ").trim().to_string())
            .unwrap_or_default();
        all.insert(pid, Process {
            pid,
            parent: field(1) as u32,
            name,
            command,
            ticks: field(11) + field(12),
            rss_bytes: field(21) * page_size,
            cpu: 0.,
        });
    }
    let root = std::process::id();
    let mut ours = Vec::new();
    let mut queue = vec![root];
    while let Some(pid) = queue.pop() {
        if let Some(process) = all.get(&pid) {
            ours.push(process.clone());
        }
        queue.extend(all.values().filter(|process| process.parent == pid).map(|process| process.pid));
    }
    ours
}

fn memory(bytes: u64) -> String {
    match bytes {
        0..1_048_576 => format!("{} KB", bytes / 1024),
        1_048_576..1_073_741_824 => format!("{:.0} MB", bytes as f64 / 1_048_576.),
        _ => format!("{:.1} GB", bytes as f64 / 1_073_741_824.),
    }
}

impl SettingsView {
    /// Reads the processes again, working out CPU use since the last read.
    pub(super) fn refresh_diagnostics(&mut self, cx: &mut Context<Self>) {
        let ticks_per_sec = unsafe { libc::sysconf(libc::_SC_CLK_TCK) }.max(1) as f32;
        let previous: HashMap<u32, u64> = self.diagnostics.processes.iter().map(|process| (process.pid, process.ticks)).collect();
        let elapsed = self.diagnostics.sampled.map(|at| at.elapsed().as_secs_f32()).filter(|secs| *secs > 0.05);
        let mut processes = read_processes();
        if let Some(elapsed) = elapsed {
            for process in &mut processes {
                if let Some(before) = previous.get(&process.pid) {
                    process.cpu = (process.ticks.saturating_sub(*before) as f32 / ticks_per_sec) / elapsed * 100.;
                }
            }
        }
        self.diagnostics.processes = processes;
        self.diagnostics.checked = Some(SystemTime::now());
        self.diagnostics.sampled = Some(Instant::now());
        if !self.diagnostics.polling {
            self.diagnostics.polling = true;
            cx.spawn(async move |this, cx| {
                loop {
                    cx.background_executor().timer(Duration::from_secs(2)).await;
                    let open = this
                        .update(cx, |this, cx| {
                            let open = this.page == Page::Diagnostics;
                            if open {
                                this.refresh_diagnostics(cx);
                            } else {
                                this.diagnostics.polling = false;
                            }
                            open
                        })
                        .unwrap_or(false);
                    if !open {
                        break;
                    }
                }
            })
            .detach();
        }
        cx.notify();
    }

    pub(super) fn render_diagnostics(&mut self, cx: &mut Context<Self>) -> Vec<AnyElement> {
        let p = metal::palette(cx);
        let processes = self.diagnostics.processes.clone();
        let checked = self.diagnostics.checked.map(|time| format!("Checked {}", ago(time))).unwrap_or_default();
        let refresh = |id: &'static str, cx: &mut Context<Self>| {
            h_flex()
                .gap(px(8.))
                .child(div().text_size(px(12.)).text_color(p.ink3).child(checked.clone()))
                .child(
                    MetalButton::new(id)
                        .ghost()
                        .mini()
                        .icon(IconName::RefreshCw)
                        .tooltip("Refresh")
                        .on_click(cx.listener(|this, _, _, cx| this.refresh_diagnostics(cx))),
                )
        };
        let cpu: f32 = processes.iter().map(|process| process.cpu).sum();
        let rss: u64 = processes.iter().map(|process| process.rss_bytes).sum();
        let app_rss = processes.first().map_or(0, |process| process.rss_bytes);
        let stats = div()
            .grid()
            .grid_cols(4)
            .gap(px(8.))
            .p(px(12.))
            .child(stat("Current CPU", format!("{cpu:.1}%"), p))
            .child(stat("Resident memory", memory(rss), p))
            .child(stat("Process count", processes.len().to_string(), p))
            .child(stat("App memory", memory(app_rss), p));
        let monitor = Section::new("Resource monitor")
            .title_action(metal::tag("Native", p))
            .header_action(refresh("refresh-monitor", cx))
            .row(stats)
            .render(cx);

        let header = h_flex()
            .px(px(16.))
            .py(px(8.))
            .gap(px(12.))
            .text_size(px(11.))
            .font_weight(FontWeight::MEDIUM)
            .text_color(p.ink3)
            .child(div().w(px(150.)).child("Name"))
            .child(div().w(px(60.)).child("CPU"))
            .child(div().w(px(70.)).child("Memory"))
            .child(div().flex_1().child("Command"))
            .child(div().w(px(64.)).child("PID"))
            .child(div().w(px(72.)).child("Kill"))
            .into_any_element();
        let mut rows = vec![header];
        if processes.is_empty() {
            rows.push(empty_row("Reading processes…", p));
        }
        let own = std::process::id();
        for process in processes {
            let pid = process.pid;
            rows.push(
                h_flex()
                    .px(px(16.))
                    .py(px(8.))
                    .gap(px(12.))
                    .text_size(px(12.))
                    .text_color(p.ink)
                    .child(div().w(px(150.)).truncate().font_weight(FontWeight::MEDIUM).child(process.name.clone()))
                    .child(div().w(px(60.)).font_family(metal::MONO).child(format!("{:.1}%", process.cpu)))
                    .child(div().w(px(70.)).font_family(metal::MONO).child(memory(process.rss_bytes)))
                    .child(
                        div()
                            .flex_1()
                            .min_w_0()
                            .truncate()
                            .font_family(metal::MONO)
                            .text_size(px(11.))
                            .text_color(p.ink3)
                            .child(process.command.clone()),
                    )
                    .child(div().w(px(64.)).font_family(metal::MONO).text_color(p.ink2).child(pid.to_string()))
                    .child(
                        h_flex()
                            .w(px(72.))
                            .gap(px(2.))
                            .when(pid != own, |this| {
                                this.child(signal_button("INT", pid, libc::SIGINT, "Send SIGINT", cx))
                                    .child(signal_button("KILL", pid, libc::SIGKILL, "Send SIGKILL", cx))
                            }),
                    )
                    .into_any_element(),
            );
        }
        let live = Section::new("Live Processes").header_action(refresh("refresh-processes", cx)).rows(rows).render(cx);
        vec![monitor, live]
    }
}

fn stat(label: &'static str, value: String, p: &Palette) -> AnyElement {
    v_flex()
        .px(px(12.))
        .py(px(10.))
        .gap(px(2.))
        .rounded(px(12.))
        .well(p)
        .child(div().text_size(px(11.)).text_color(p.ink3).child(label))
        .child(div().font_family(metal::PIXEL).text_size(px(18.)).text_color(p.ink).child(value))
        .into_any_element()
}

fn signal_button(label: &'static str, pid: u32, signal: i32, tooltip: &'static str, cx: &mut Context<SettingsView>) -> MetalButton {
    MetalButton::new(SharedString::from(format!("signal-{label}-{pid}")))
        .ghost()
        .mini()
        .label(label)
        .tooltip(tooltip)
        .on_click(cx.listener(move |this, _, _, cx| {
            unsafe {
                libc::kill(pid as i32, signal);
            }
            this.refresh_diagnostics(cx);
        }))
}