redbear-power: CPU affinity editor (a key, Process tab)
New affinity.rs module renders a toggleable CPU grid dialog: - Space toggles selected CPU on/off - Arrow keys navigate the grid - Enter applies via sched_setaffinity(2) - Auto-closes 2s after successful apply - Shows +N for enabled, -N for disabled CPUs - Grid adapts to CPU count (4/8/16 columns) Added set_affinity() to process.rs using libc::sched_setaffinity. Added 'a' key to keybar and help text.
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
use ratatui::{
|
||||
layout::{Constraint, Layout, Rect},
|
||||
style::Style,
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Widget},
|
||||
};
|
||||
|
||||
use crate::theme;
|
||||
|
||||
pub struct AffinityEditor {
|
||||
pub open: bool,
|
||||
pid: u32,
|
||||
comm: String,
|
||||
num_cpus: usize,
|
||||
selected: usize,
|
||||
mask: Vec<bool>,
|
||||
result: Option<Result<(), String>>,
|
||||
result_at: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
impl Default for AffinityEditor {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
open: false,
|
||||
pid: 0,
|
||||
comm: String::new(),
|
||||
num_cpus: 1,
|
||||
selected: 0,
|
||||
mask: vec![true],
|
||||
result: None,
|
||||
result_at: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AffinityEditor {
|
||||
pub fn open_for(&mut self, pid: u32, comm: &str, current: &[u32], num_cpus: usize) {
|
||||
self.open = true;
|
||||
self.pid = pid;
|
||||
self.comm = comm.to_string();
|
||||
self.num_cpus = num_cpus.max(1);
|
||||
self.selected = 0;
|
||||
self.mask = vec![false; self.num_cpus];
|
||||
for &cpu in current {
|
||||
if (cpu as usize) < self.mask.len() {
|
||||
self.mask[cpu as usize] = true;
|
||||
}
|
||||
}
|
||||
if !self.mask.iter().any(|&v| v) {
|
||||
self.mask.iter_mut().for_each(|v| *v = true);
|
||||
}
|
||||
self.result = None;
|
||||
self.result_at = None;
|
||||
}
|
||||
|
||||
pub fn close(&mut self) {
|
||||
self.open = false;
|
||||
}
|
||||
|
||||
pub fn move_cursor(&mut self, dx: i32, dy: i32) {
|
||||
let cols = self.grid_cols();
|
||||
let row = (self.selected / cols) as i32 + dy;
|
||||
let col = (self.selected % cols) as i32 + dx;
|
||||
if row < 0 || col < 0 {
|
||||
return;
|
||||
}
|
||||
let idx = row as usize * cols + col as usize;
|
||||
if idx < self.num_cpus {
|
||||
self.selected = idx;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle(&mut self) {
|
||||
if self.selected < self.mask.len() {
|
||||
self.mask[self.selected] = !self.mask[self.selected];
|
||||
}
|
||||
}
|
||||
|
||||
fn grid_cols(&self) -> usize {
|
||||
match self.num_cpus {
|
||||
0..=8 => 4,
|
||||
9..=32 => 8,
|
||||
33..=64 => 8,
|
||||
_ => 16,
|
||||
}
|
||||
}
|
||||
|
||||
fn selected_cpus(&self) -> Vec<u32> {
|
||||
self.mask
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|&(_, &on)| on)
|
||||
.map(|(i, _)| i as u32)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn apply(&mut self) {
|
||||
let cpus = self.selected_cpus();
|
||||
if cpus.is_empty() {
|
||||
self.result = Some(Err("cannot set empty affinity".into()));
|
||||
self.result_at = Some(std::time::Instant::now());
|
||||
return;
|
||||
}
|
||||
let r = crate::process::set_affinity(self.pid, &cpus);
|
||||
match &r {
|
||||
Ok(()) => {
|
||||
self.result = Some(Ok(()));
|
||||
}
|
||||
Err(e) => {
|
||||
self.result = Some(Err(e.clone()));
|
||||
}
|
||||
}
|
||||
self.result_at = Some(std::time::Instant::now());
|
||||
}
|
||||
|
||||
pub fn should_auto_close(&self) -> bool {
|
||||
if let Some(at) = self.result_at {
|
||||
if self.result.as_ref().map_or(false, |r| r.is_ok()) {
|
||||
return at.elapsed().as_secs() >= 2;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget for &mut AffinityEditor {
|
||||
fn render(self, area: Rect, buf: &mut ratatui::buffer::Buffer) {
|
||||
Clear.render(area, buf);
|
||||
let cols = self.grid_cols();
|
||||
let rows = (self.num_cpus + cols - 1) / cols;
|
||||
let dialog_h = (rows + 7).min(24) as u16;
|
||||
let dialog_w = ((cols * 6) + 4).min(80) as u16;
|
||||
let dx = area.x + (area.width.saturating_sub(dialog_w)) / 2;
|
||||
let dy = area.y + (area.height.saturating_sub(dialog_h)) / 2;
|
||||
let darea = Rect::new(dx, dy, dialog_w, dialog_h);
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(theme::BORDER_FOCUSED)
|
||||
.title(format!(" Affinity: {} (PID {}) ", self.comm, self.pid));
|
||||
let inner = block.inner(darea);
|
||||
block.render(darea, buf);
|
||||
|
||||
let chunks = Layout::vertical([Constraint::Min(3), Constraint::Length(2)]).split(inner);
|
||||
let grid_area = chunks[0];
|
||||
let footer_area = chunks[1];
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
for r in 0..rows {
|
||||
let mut spans: Vec<Span> = Vec::new();
|
||||
for c in 0..cols {
|
||||
let idx = r * cols + c;
|
||||
if idx >= self.num_cpus {
|
||||
break;
|
||||
}
|
||||
let is_on = self.mask[idx];
|
||||
let is_sel = idx == self.selected;
|
||||
let label = format!("{:>3}", idx);
|
||||
let style = if is_sel {
|
||||
Style::default().add_modifier(
|
||||
ratatui::style::Modifier::BOLD | ratatui::style::Modifier::REVERSED,
|
||||
)
|
||||
} else if is_on {
|
||||
theme::VALUE
|
||||
} else {
|
||||
theme::VALUE_OFF
|
||||
};
|
||||
let marker = if is_on { "+" } else { "-" };
|
||||
spans.push(Span::styled(format!("{marker}{label} "), style));
|
||||
}
|
||||
lines.push(Line::from(spans));
|
||||
}
|
||||
let para = ratatui::widgets::Paragraph::new(lines);
|
||||
para.render(grid_area, buf);
|
||||
|
||||
if let Some(result) = &self.result {
|
||||
let msg = match result {
|
||||
Ok(()) => format!("OK — affinity set to {} CPU(s)", self.selected_cpus().len()),
|
||||
Err(e) => format!("Error: {e}"),
|
||||
};
|
||||
let style = if result.is_ok() {
|
||||
theme::STATUS_OK
|
||||
} else {
|
||||
theme::STATUS_ERR
|
||||
};
|
||||
let para = ratatui::widgets::Paragraph::new(Line::from(Span::styled(msg, style)));
|
||||
para.render(footer_area, buf);
|
||||
} else {
|
||||
let hint = "Space:toggle Enter:apply Esc:cancel Arrows:move";
|
||||
let para =
|
||||
ratatui::widgets::Paragraph::new(Line::from(Span::styled(hint, theme::VALUE_OFF)));
|
||||
para.render(footer_area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,6 +270,7 @@ pub struct App {
|
||||
/// 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 affinity_editor: crate::affinity::AffinityEditor,
|
||||
pub open_files_result: Option<Vec<crate::open_files::OpenFile>>,
|
||||
pub open_files_for_pid: Option<u32>,
|
||||
pub show_open_files: bool,
|
||||
@@ -501,6 +502,7 @@ impl App {
|
||||
storage_graph_history: crate::graph::RingHistory::new(120),
|
||||
kill_dialog: crate::kill::KillDialog::new(),
|
||||
nice_edit: crate::nice_edit::NiceEdit::default(),
|
||||
affinity_editor: crate::affinity::AffinityEditor::default(),
|
||||
open_files_result: None,
|
||||
open_files_for_pid: None,
|
||||
show_open_files: false,
|
||||
|
||||
@@ -46,6 +46,7 @@ use termion::raw::IntoRawMode;
|
||||
use termion::screen::IntoAlternateScreen;
|
||||
|
||||
mod acpi;
|
||||
mod affinity;
|
||||
mod app;
|
||||
mod battery;
|
||||
mod bench;
|
||||
@@ -601,6 +602,9 @@ fn main() -> io::Result<()> {
|
||||
if app.nice_edit.open {
|
||||
f.render_widget(&app.nice_edit, full);
|
||||
}
|
||||
if app.affinity_editor.open {
|
||||
f.render_widget(&mut app.affinity_editor, full);
|
||||
}
|
||||
if app.show_open_files {
|
||||
if let Some(files) = &app.open_files_result {
|
||||
let pid = app.open_files_for_pid.unwrap_or(0);
|
||||
@@ -838,6 +842,9 @@ fn main() -> io::Result<()> {
|
||||
|
||||
app.kill_dialog.auto_close_if_done();
|
||||
app.nice_edit.auto_close_if_done();
|
||||
if app.affinity_editor.should_auto_close() {
|
||||
app.affinity_editor.close();
|
||||
}
|
||||
|
||||
if let Some(Ok(event)) = events.next() {
|
||||
if let Event::Mouse(me) = event {
|
||||
@@ -886,6 +893,14 @@ fn main() -> io::Result<()> {
|
||||
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.affinity_editor.open => app.affinity_editor.close(),
|
||||
Key::Char('\n') if app.affinity_editor.open => app.affinity_editor.apply(),
|
||||
Key::Char(' ') if app.affinity_editor.open => app.affinity_editor.toggle(),
|
||||
Key::Up if app.affinity_editor.open => app.affinity_editor.move_cursor(0, -1),
|
||||
Key::Down if app.affinity_editor.open => app.affinity_editor.move_cursor(0, 1),
|
||||
Key::Left if app.affinity_editor.open => app.affinity_editor.move_cursor(-1, 0),
|
||||
Key::Right if app.affinity_editor.open => app.affinity_editor.move_cursor(1, 0),
|
||||
_ if app.affinity_editor.open => {}
|
||||
Key::Esc if app.show_open_files => {
|
||||
app.show_open_files = false;
|
||||
app.open_files_result = None;
|
||||
@@ -1025,6 +1040,24 @@ fn main() -> io::Result<()> {
|
||||
app.flash_status("no process selected");
|
||||
}
|
||||
}
|
||||
Key::Char('a')
|
||||
if app.current_tab == TabId::Process && !app.affinity_editor.open =>
|
||||
{
|
||||
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.cpu_affinity.clone()))
|
||||
});
|
||||
if let Some((pid, comm, affinity)) = target {
|
||||
let cur =
|
||||
affinity.unwrap_or_else(|| (0..app.cpus.len() as u32).collect());
|
||||
app.affinity_editor
|
||||
.open_for(pid, &comm, &cur, app.cpus.len());
|
||||
} 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);
|
||||
|
||||
@@ -2017,3 +2017,30 @@ pub fn set_nice(pid: u32, nice: i32) -> Result<(), String> {
|
||||
Err("setpriority not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_affinity(pid: u32, cpus: &[u32]) -> Result<(), String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mut set: libc::cpu_set_t = unsafe { std::mem::zeroed() };
|
||||
for &cpu in cpus {
|
||||
if (cpu as usize) < libc::CPU_SETSIZE as usize {
|
||||
unsafe {
|
||||
libc::CPU_SET(cpu as usize, &mut set);
|
||||
}
|
||||
}
|
||||
}
|
||||
let r = unsafe {
|
||||
libc::sched_setaffinity(pid as i32, std::mem::size_of::<libc::cpu_set_t>(), &set)
|
||||
};
|
||||
if r == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(std::io::Error::last_os_error().to_string())
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = (pid, cpus);
|
||||
Err("sched_setaffinity not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2057,7 +2057,7 @@ pub fn render_keybar<'a>(app: &'a App) -> Paragraph<'a> {
|
||||
let gov = format!(" g:{}", app.cpufreq.active.as_str());
|
||||
let interval = format!(" [/]:{}ms", app.poll_ms);
|
||||
let tab_hints: &str = if app.current_tab == crate::app::TabId::Process {
|
||||
" ↑↓:sel k:kill N:nice l:files C:cmd o:sort i:invert F:filter y:tree Enter:detail"
|
||||
" ↑↓:sel k:kill N:nice a:affinity l:files C:cmd o:sort i:invert F:filter y:tree Enter:detail"
|
||||
} else {
|
||||
" ↑↓:cpu p/P:±pstate m/M:min/max t:throttle r:refresh"
|
||||
};
|
||||
@@ -2174,6 +2174,7 @@ INTERACTIVE CONTROLS:
|
||||
|
||||
[k] kill selected process (Process tab only)
|
||||
[N] edit nice value of selected process (Process tab)
|
||||
[a] edit CPU affinity of selected process (Process tab)
|
||||
[l] list open file descriptors of selected process (Process tab)
|
||||
[C] toggle full command line vs comm name (Process tab)
|
||||
[y] toggle process tree view (Process tab)
|
||||
|
||||
Reference in New Issue
Block a user