redbear-power: add nice editor (N), open files (l), remove dead event.rs, bench auto-stop toast
- nice_edit.rs: modal dialog for adjusting process nice (-20..19), slider visualization - open_files.rs: reads /proc/[pid]/fd, classifies file descriptors by type - Removed dead event.rs module (never wired, unused Event enum) - Benchmark auto-completion: detects elapsed >= duration, calls stop(), flashes toast - PROCHOT toast trigger wired in app.rs - Help text updated with N and l keybindings - 217 tests pass, 0 warnings, 4.1 MB LTO release binary
This commit is contained in:
@@ -265,6 +265,10 @@ pub struct App {
|
||||
pub storage_graph_history: crate::graph::RingHistory,
|
||||
/// Process kill dialog. Opened by `k` key in the Process tab.
|
||||
pub kill_dialog: crate::kill::KillDialog,
|
||||
pub nice_edit: crate::nice_edit::NiceEdit,
|
||||
pub open_files_result: Option<Vec<crate::open_files::OpenFile>>,
|
||||
pub open_files_for_pid: Option<u32>,
|
||||
pub show_open_files: bool,
|
||||
/// Help dialog scroll offset (lines from top).
|
||||
pub help_scroll: usize,
|
||||
/// When true, the current tab is rendered full-screen (widget
|
||||
@@ -477,6 +481,10 @@ impl App {
|
||||
net_graph_history: crate::graph::RingHistory::new(120),
|
||||
storage_graph_history: crate::graph::RingHistory::new(120),
|
||||
kill_dialog: crate::kill::KillDialog::new(),
|
||||
nice_edit: crate::nice_edit::NiceEdit::default(),
|
||||
open_files_result: None,
|
||||
open_files_for_pid: None,
|
||||
show_open_files: false,
|
||||
help_scroll: 0,
|
||||
expanded: false,
|
||||
};
|
||||
|
||||
@@ -55,12 +55,13 @@ mod cpufreq;
|
||||
mod cpuid;
|
||||
mod dbus;
|
||||
mod dmi;
|
||||
mod event;
|
||||
mod graph;
|
||||
mod kill;
|
||||
mod meminfo;
|
||||
mod msr;
|
||||
mod network;
|
||||
mod nice_edit;
|
||||
mod open_files;
|
||||
mod pid_detail;
|
||||
mod platform;
|
||||
mod process;
|
||||
@@ -383,6 +384,20 @@ fn main() -> io::Result<()> {
|
||||
app.refresh();
|
||||
last_refresh = Instant::now();
|
||||
app.bench_line = bench.status_line(app.cpus.len());
|
||||
if bench.running {
|
||||
if let Some((elapsed, _)) = bench.progress() {
|
||||
let dur = bench.duration.as_secs() as u32;
|
||||
if dur > 0 && elapsed >= dur {
|
||||
bench.stop();
|
||||
app.flash_toast(format!(
|
||||
"Benchmark done: {} {} in {}s",
|
||||
bench.last_score,
|
||||
bench.unit_name(),
|
||||
bench.last_duration_s
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(server) = dbus_server.as_ref() {
|
||||
server.publish(dbus::PowerSnapshot::from_app(&app));
|
||||
}
|
||||
@@ -568,6 +583,34 @@ fn main() -> io::Result<()> {
|
||||
f.render_widget(render_pid_detail(detail, pid, &app.theme), area);
|
||||
}
|
||||
f.render_widget(&app.kill_dialog, full);
|
||||
if app.nice_edit.open {
|
||||
f.render_widget(Clear, full);
|
||||
f.render_widget(&app.nice_edit, full);
|
||||
}
|
||||
if app.show_open_files {
|
||||
if let Some(files) = &app.open_files_result {
|
||||
let pid = app.open_files_for_pid.unwrap_or(0);
|
||||
let area =
|
||||
full.centered(Constraint::Percentage(70), Constraint::Percentage(60));
|
||||
f.render_widget(Clear, area);
|
||||
let lines: Vec<ratatui::text::Line> =
|
||||
crate::open_files::format_files(files)
|
||||
.into_iter()
|
||||
.map(ratatui::text::Line::from)
|
||||
.collect();
|
||||
let block = ratatui::widgets::Block::default()
|
||||
.borders(ratatui::widgets::Borders::ALL)
|
||||
.title(format!(
|
||||
" Open FDs: PID {} ({}) \u{2014} Esc to close ",
|
||||
pid,
|
||||
files.len()
|
||||
));
|
||||
let para = ratatui::widgets::Paragraph::new(lines)
|
||||
.block(block)
|
||||
.scroll((0, 0));
|
||||
f.render_widget(para, area);
|
||||
}
|
||||
}
|
||||
// Hint bar: remind user how to exit expand mode.
|
||||
let hint = ratatui::widgets::Paragraph::new(ratatui::text::Line::styled(
|
||||
" Press e to restore normal view ",
|
||||
@@ -777,6 +820,7 @@ fn main() -> io::Result<()> {
|
||||
}
|
||||
|
||||
app.kill_dialog.auto_close_if_done();
|
||||
app.nice_edit.auto_close_if_done();
|
||||
|
||||
if let Some(Ok(event)) = events.next() {
|
||||
if let Event::Mouse(me) = event {
|
||||
@@ -807,6 +851,29 @@ fn main() -> io::Result<()> {
|
||||
app.kill_dialog.move_selection(1);
|
||||
}
|
||||
_ if app.kill_dialog.open => {} // eat all other keys
|
||||
Key::Esc if app.nice_edit.open => {
|
||||
app.nice_edit.close();
|
||||
app.flash_status("nice edit cancelled");
|
||||
}
|
||||
Key::Char('\n') if app.nice_edit.open => {
|
||||
app.nice_edit.apply();
|
||||
if let Some(Ok(())) = &app.nice_edit.result {
|
||||
app.flash_toast(format!(
|
||||
"Nice set to {} for PID {}",
|
||||
app.nice_edit.value, app.nice_edit.pid
|
||||
));
|
||||
}
|
||||
}
|
||||
Key::Up if app.nice_edit.open => app.nice_edit.adjust(1),
|
||||
Key::Down if app.nice_edit.open => app.nice_edit.adjust(-1),
|
||||
Key::Char('+') if app.nice_edit.open => app.nice_edit.adjust(1),
|
||||
Key::Char('-') if app.nice_edit.open => app.nice_edit.adjust(-1),
|
||||
_ if app.nice_edit.open => {}
|
||||
Key::Esc if app.show_open_files => {
|
||||
app.show_open_files = false;
|
||||
app.open_files_result = None;
|
||||
app.open_files_for_pid = None;
|
||||
}
|
||||
// Esc closes PID detail first, then quits.
|
||||
Key::Esc if app.pid_detail.is_some() => {
|
||||
app.pid_detail = None;
|
||||
@@ -914,6 +981,31 @@ fn main() -> io::Result<()> {
|
||||
app.flash_status("no process selected (Up/Down to select)");
|
||||
}
|
||||
}
|
||||
Key::Char('N') if app.current_tab == TabId::Process => {
|
||||
let target = app.selected_pid().and_then(|pid| {
|
||||
app.visible_processes()
|
||||
.iter()
|
||||
.find(|p| p.pid == pid)
|
||||
.map(|p| (p.pid, p.comm.clone(), p.nice as i32))
|
||||
});
|
||||
if let Some((pid, comm, nice)) = target {
|
||||
app.nice_edit.open_for(pid, &comm, nice);
|
||||
} else {
|
||||
app.flash_status("no process selected");
|
||||
}
|
||||
}
|
||||
Key::Char('l') if app.current_tab == TabId::Process => {
|
||||
if let Some(pid) = app.selected_pid() {
|
||||
let files = crate::open_files::read(pid);
|
||||
let count = files.len();
|
||||
app.open_files_result = Some(files);
|
||||
app.open_files_for_pid = Some(pid);
|
||||
app.show_open_files = true;
|
||||
app.flash_toast(format!("PID {} has {} open fd", pid, count));
|
||||
} else {
|
||||
app.flash_status("no process selected");
|
||||
}
|
||||
}
|
||||
Key::Char('r') => {
|
||||
app.refresh();
|
||||
last_refresh = Instant::now();
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
use ratatui::{
|
||||
layout::{Alignment, Constraint, Layout, Rect},
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph, Widget},
|
||||
};
|
||||
|
||||
use crate::theme;
|
||||
|
||||
const MIN_NICE: i32 = -20;
|
||||
const MAX_NICE: i32 = 19;
|
||||
|
||||
pub struct NiceEdit {
|
||||
pub open: bool,
|
||||
pub pid: u32,
|
||||
pub comm: String,
|
||||
pub value: i32,
|
||||
pub result: Option<Result<(), String>>,
|
||||
pub result_timer: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl Default for NiceEdit {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
open: false,
|
||||
pid: 0,
|
||||
comm: String::new(),
|
||||
value: 0,
|
||||
result: None,
|
||||
result_timer: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NiceEdit {
|
||||
pub fn open_for(&mut self, pid: u32, comm: &str, current_nice: i32) {
|
||||
self.pid = pid;
|
||||
self.comm = comm.to_string();
|
||||
self.value = current_nice.clamp(MIN_NICE, MAX_NICE) as i32;
|
||||
self.open = true;
|
||||
self.result = None;
|
||||
self.result_timer = None;
|
||||
}
|
||||
|
||||
pub fn close(&mut self) {
|
||||
self.open = false;
|
||||
}
|
||||
|
||||
pub fn adjust(&mut self, delta: i32) {
|
||||
self.value = (self.value + delta).clamp(MIN_NICE, MAX_NICE);
|
||||
}
|
||||
|
||||
pub fn apply(&mut self) {
|
||||
let pid = self.pid;
|
||||
let nice = self.value;
|
||||
let result = crate::process::set_nice(pid, nice);
|
||||
self.result = Some(result);
|
||||
self.result_timer = Some(std::time::Instant::now());
|
||||
}
|
||||
|
||||
pub fn auto_close_if_done(&mut self) {
|
||||
if let Some(t) = self.result_timer {
|
||||
if t.elapsed().as_secs() >= 2 {
|
||||
self.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for &NiceEdit {
|
||||
fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) {
|
||||
let width = 48u16.min(area.width);
|
||||
let height = 9u16.min(area.height);
|
||||
let x = area.x + (area.width - width) / 2;
|
||||
let y = area.y + (area.height - height) / 2;
|
||||
let dialog = Rect::new(x, y, width, height);
|
||||
|
||||
Clear.render(dialog, buf);
|
||||
|
||||
let block_style = Style::default().add_modifier(Modifier::BOLD);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(Span::styled(
|
||||
" Adjust Nice ",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
))
|
||||
.style(block_style);
|
||||
let inner = block.inner(dialog);
|
||||
block.render(dialog, buf);
|
||||
|
||||
let [info_area, slider_area, msg_area] = Layout::vertical([
|
||||
Constraint::Length(2),
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(1),
|
||||
])
|
||||
.spacing(0)
|
||||
.areas(inner);
|
||||
|
||||
let info = Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
format!(" PID {} {}", self.pid, self.comm),
|
||||
theme::VALUE,
|
||||
)),
|
||||
Line::from(""),
|
||||
]);
|
||||
Widget::render(&info, info_area, buf);
|
||||
|
||||
let bar_width = slider_area.width.saturating_sub(2) as usize;
|
||||
let range = (MAX_NICE - MIN_NICE) as usize;
|
||||
let pos = ((self.value - MIN_NICE) as usize).min(range);
|
||||
let filled = if bar_width > 0 {
|
||||
pos * bar_width / range.max(1)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let bar: String =
|
||||
"\u{2588}".repeat(filled) + &"\u{2591}".repeat(bar_width.saturating_sub(filled));
|
||||
let slider_line = Line::from(vec![
|
||||
Span::raw(format!("{:+4} [", self.value)),
|
||||
Span::styled(
|
||||
bar,
|
||||
if self.value < 0 {
|
||||
Style::default().fg(theme::STATUS_OK.fg.unwrap_or_default())
|
||||
} else if self.value > 0 {
|
||||
Style::default().fg(theme::STATUS_WARN.fg.unwrap_or_default())
|
||||
} else {
|
||||
theme::VALUE
|
||||
},
|
||||
),
|
||||
Span::raw("]"),
|
||||
]);
|
||||
Widget::render(
|
||||
&Paragraph::new(slider_line).alignment(Alignment::Center),
|
||||
slider_area,
|
||||
buf,
|
||||
);
|
||||
|
||||
let msg = if let Some(res) = &self.result {
|
||||
match res {
|
||||
Ok(()) => Line::from(Span::styled(
|
||||
" Applied \u{2014} press Esc to close",
|
||||
theme::STATUS_OK,
|
||||
)),
|
||||
Err(e) => Line::from(Span::styled(format!(" Error: {}", e), theme::STATUS_ERR)),
|
||||
}
|
||||
} else {
|
||||
Line::from(Span::styled(
|
||||
" \u{2191}\u{2193}/+- adjust, Enter=apply, Esc=close",
|
||||
theme::VALUE_OFF,
|
||||
))
|
||||
};
|
||||
Widget::render(&Paragraph::new(msg), msg_area, buf);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
use std::fs;
|
||||
use std::os::unix::fs::FileTypeExt;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum FileKind {
|
||||
Regular,
|
||||
Directory,
|
||||
Socket,
|
||||
Pipe,
|
||||
Character,
|
||||
Block,
|
||||
Symlink,
|
||||
AnonInode,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl FileKind {
|
||||
fn icon(&self) -> &str {
|
||||
match self {
|
||||
Self::Regular => "\u{1F4C4}",
|
||||
Self::Directory => "\u{1F4C1}",
|
||||
Self::Socket => "\u{1F310}",
|
||||
Self::Pipe => "\u{1F6BF}",
|
||||
Self::Character => "\u{1F5A5}",
|
||||
Self::Block => "\u{1F4BE}",
|
||||
Self::Symlink => "\u{1F517}",
|
||||
Self::AnonInode => "\u{2699}",
|
||||
Self::Other => "\u{2753}",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct OpenFile {
|
||||
pub fd: u32,
|
||||
pub target: String,
|
||||
pub kind: FileKind,
|
||||
pub size: u64,
|
||||
pub inode: u64,
|
||||
}
|
||||
|
||||
pub fn read(pid: u32) -> Vec<OpenFile> {
|
||||
let fd_dir = format!("/proc/{}/fd", pid);
|
||||
let entries = match fs::read_dir(&fd_dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
|
||||
let mut files = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let name_str = match name.to_str() {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
let fd: u32 = match name_str.parse() {
|
||||
Ok(n) => n,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let link_path = entry.path();
|
||||
let target = fs::read_link(&link_path)
|
||||
.ok()
|
||||
.and_then(|p| p.to_str().map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
|
||||
let (kind, size, inode) = if target.starts_with("socket:[") {
|
||||
(FileKind::Socket, 0, 0)
|
||||
} else if target.starts_with("pipe:[") {
|
||||
(FileKind::Pipe, 0, 0)
|
||||
} else if target.starts_with("anon_inode:") {
|
||||
(FileKind::AnonInode, 0, 0)
|
||||
} else if !target.starts_with('/') {
|
||||
(FileKind::Other, 0, 0)
|
||||
} else {
|
||||
match fs::metadata(&target) {
|
||||
Ok(meta) => {
|
||||
let kind = meta.file_type();
|
||||
let k = if kind.is_dir() {
|
||||
FileKind::Directory
|
||||
} else if kind.is_file() {
|
||||
FileKind::Regular
|
||||
} else if kind.is_symlink() {
|
||||
FileKind::Symlink
|
||||
} else if kind.is_fifo() {
|
||||
FileKind::Pipe
|
||||
} else {
|
||||
FileKind::Other
|
||||
};
|
||||
(k, meta.len(), meta.ino())
|
||||
}
|
||||
Err(_) => (FileKind::Other, 0, 0),
|
||||
}
|
||||
};
|
||||
|
||||
files.push(OpenFile {
|
||||
fd,
|
||||
target,
|
||||
kind,
|
||||
size,
|
||||
inode,
|
||||
});
|
||||
}
|
||||
|
||||
files.sort_by_key(|f| f.fd);
|
||||
files
|
||||
}
|
||||
|
||||
pub fn format_files(files: &[OpenFile]) -> Vec<String> {
|
||||
files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let size_str = if f.size > 0 {
|
||||
format!(" ({} bytes)", f.size)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
format!("{} {:>3} {}{}", f.kind.icon(), f.fd, f.target, size_str)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -2020,6 +2020,8 @@ TABS:
|
||||
[e] expand current tab to full-screen (toggle)
|
||||
[f] freeze / unfreeze data updates
|
||||
[k] kill selected process (Process tab only)
|
||||
[N] edit nice value of selected process (Process tab)
|
||||
[l] list open file descriptors of selected process (Process tab)
|
||||
[y] toggle process tree view (Process tab)
|
||||
[F] filter processes by name (Process tab)
|
||||
[o] cycle process sort mode
|
||||
|
||||
Reference in New Issue
Block a user