feat: -i interactive ratatui TUI for redbear-info and redbear-netctl
- redbear-info: -i launches ratatui dashboard (System/Hardware/Network/Integrations/Health tabs) - redbear-netctl: -i wires existing netctl-console ratatui TUI - Same -i switch convention as cub - Feature-gated behind 'tui' feature for both apps
This commit is contained in:
@@ -9,5 +9,11 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
redox-driver-sys = { path = "../../../../recipes/drivers/redox-driver-sys/source" }
|
||||
ratatui = { version = "0.30", default-features = false, features = ["termion"], optional = true }
|
||||
termion = { version = "4", optional = true }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
tui = ["ratatui", "termion"]
|
||||
|
||||
@@ -13,6 +13,9 @@ use toml::Value;
|
||||
#[cfg(test)]
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(feature = "tui")]
|
||||
mod tui;
|
||||
|
||||
const RESET: &str = "\x1b[0m";
|
||||
const GREEN: &str = "\x1b[32m";
|
||||
const YELLOW: &str = "\x1b[33m";
|
||||
@@ -38,6 +41,7 @@ enum OutputMode {
|
||||
Device,
|
||||
Health,
|
||||
Help,
|
||||
Tui,
|
||||
}
|
||||
|
||||
struct Options {
|
||||
@@ -523,6 +527,17 @@ fn main() {
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let options = parse_args(env::args())?;
|
||||
|
||||
if options.mode == OutputMode::Tui {
|
||||
#[cfg(feature = "tui")]
|
||||
{
|
||||
let runtime = Runtime::from_env();
|
||||
return tui::run(&runtime).map_err(|e| format!("TUI error: {e}"));
|
||||
}
|
||||
#[cfg(not(feature = "tui"))]
|
||||
return Err("TUI support not compiled (enable 'tui' feature)".to_string());
|
||||
}
|
||||
|
||||
if options.mode == OutputMode::Help {
|
||||
print_help();
|
||||
return Ok(());
|
||||
@@ -1539,6 +1554,7 @@ where
|
||||
while let Some(arg) = args.next() {
|
||||
match arg.as_str() {
|
||||
"-v" | "--verbose" => verbose = true,
|
||||
"-i" | "--interactive" => set_output_mode(&mut mode, OutputMode::Tui, "-i")?,
|
||||
"--json" => set_output_mode(&mut mode, OutputMode::Json, "--json")?,
|
||||
"--test" => set_output_mode(&mut mode, OutputMode::Test, "--test")?,
|
||||
"--quirks" => set_output_mode(&mut mode, OutputMode::Quirks, "--quirks")?,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
use std::io;
|
||||
|
||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use ratatui::prelude::TermionBackend;
|
||||
use ratatui::style::{Color, Style};
|
||||
use ratatui::text::{Line, Span, Text};
|
||||
use ratatui::widgets::{Block, Borders, Paragraph, Tabs, Wrap};
|
||||
use ratatui::Terminal;
|
||||
use termion::event::Key;
|
||||
use termion::input::TermRead;
|
||||
|
||||
use super::{collect_report, IntegrationStatus, OutputMode, ProbeState, Report, Runtime};
|
||||
|
||||
pub fn run(runtime: &Runtime) -> io::Result<()> {
|
||||
let mut terminal = Terminal::new(TermionBackend::new(io::stdout()))
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
terminal.clear().map_err(|e| io::Error::new(io::ErrorKind::Other, e))?;
|
||||
|
||||
let mut app = TuiApp::new(runtime);
|
||||
let events = termion::async_stdin().keys();
|
||||
|
||||
let _ = terminal.draw(|f| app.draw(f));
|
||||
for key in events {
|
||||
match key.map_err(|e| io::Error::new(io::ErrorKind::Other, e))? {
|
||||
Key::Char('q') | Key::Esc => break,
|
||||
Key::Char('\t') => app.next_tab(),
|
||||
Key::BackTab => app.prev_tab(),
|
||||
Key::Char('r') => app.refresh(runtime),
|
||||
Key::Down | Key::Char('j') => app.scroll_down(),
|
||||
Key::Up | Key::Char('k') => app.scroll_up(),
|
||||
_ => {}
|
||||
}
|
||||
let _ = terminal.draw(|f| app.draw(f));
|
||||
}
|
||||
|
||||
let _ = terminal.show_cursor();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct TuiApp<'a> {
|
||||
tab: usize,
|
||||
scroll: usize,
|
||||
report: Report<'a>,
|
||||
}
|
||||
|
||||
impl<'a> TuiApp<'a> {
|
||||
fn new(runtime: &Runtime) -> Self {
|
||||
Self { tab: 0, scroll: 0, report: collect_report(runtime) }
|
||||
}
|
||||
|
||||
fn refresh(&mut self, runtime: &Runtime) { self.report = collect_report(runtime); self.scroll = 0; }
|
||||
fn next_tab(&mut self) { self.tab = (self.tab + 1) % 5; self.scroll = 0; }
|
||||
fn prev_tab(&mut self) { self.tab = (self.tab + 4) % 5; self.scroll = 0; }
|
||||
fn scroll_down(&mut self) { self.scroll = self.scroll.saturating_add(1); }
|
||||
fn scroll_up(&mut self) { self.scroll = self.scroll.saturating_sub(1); }
|
||||
|
||||
fn draw(&self, frame: &mut ratatui::Frame<'_>) {
|
||||
let area = frame.area();
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)])
|
||||
.split(area);
|
||||
|
||||
let titles = [" System ", " Hardware ", " Network ", " Integrations ", " Health "];
|
||||
let tabs = Tabs::new(titles.iter().map(|t| Line::from(*t)).collect::<Vec<_>>())
|
||||
.block(Block::default().borders(Borders::ALL).title(" redbear-info "))
|
||||
.style(Style::default().fg(Color::White))
|
||||
.highlight_style(Style::default().fg(Color::Red).bg(Color::DarkGray))
|
||||
.select(self.tab)
|
||||
.divider("|");
|
||||
frame.render_widget(tabs, chunks[0]);
|
||||
|
||||
let content = match self.tab {
|
||||
0 => self.system_view(),
|
||||
1 => self.hardware_view(),
|
||||
2 => self.network_view(),
|
||||
3 => self.integrations_view(),
|
||||
4 => self.health_view(),
|
||||
_ => vec!["Unknown tab".into()],
|
||||
};
|
||||
|
||||
let skipped = self.scroll.min(content.len().saturating_sub(chunks[1].height as usize - 2));
|
||||
let visible: Vec<Line> = content.into_iter().skip(skipped).take(chunks[1].height as usize - 2).collect();
|
||||
|
||||
let p = Paragraph::new(Text::from(visible))
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.wrap(Wrap { trim: false });
|
||||
frame.render_widget(p, chunks[1]);
|
||||
|
||||
let help = Line::from(Span::styled(
|
||||
" q/esc quit | Tab next | r refresh | j/k scroll ",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
frame.render_widget(Paragraph::new(help), chunks[2]);
|
||||
}
|
||||
|
||||
fn system_view(&self) -> Vec<Line<'a>> {
|
||||
let mut lines = vec![
|
||||
Line::from(Span::styled("System Information", Style::default().fg(Color::Red))),
|
||||
Line::from(""),
|
||||
];
|
||||
if let Some(name) = &self.report.identity.pretty_name { lines.push(Line::from(format!("OS: {name}"))); }
|
||||
if let Some(ver) = &self.report.identity.version_id { lines.push(Line::from(format!("Version: {ver}"))); }
|
||||
if let Some(host) = &self.report.identity.hostname { lines.push(Line::from(format!("Host: {host}"))); }
|
||||
lines
|
||||
}
|
||||
|
||||
fn hardware_view(&self) -> Vec<Line<'a>> {
|
||||
let h = &self.report.hardware;
|
||||
let mut lines = vec![
|
||||
Line::from(Span::styled("Hardware", Style::default().fg(Color::Red))), Line::from(""),
|
||||
Line::from(format!("PCI devices: {}", h.pci_devices)),
|
||||
Line::from(format!(" IRQ: none={} legacy={} MSI={} MSI-X={}", h.pci_irq_none, h.pci_irq_legacy, h.pci_irq_msi, h.pci_irq_msix)),
|
||||
Line::from(format!("USB controllers: {}", h.usb_controllers)),
|
||||
Line::from(format!("DRM cards: {}", h.drm_cards)),
|
||||
Line::from(format!("ACPI power: {}", if h.acpi_power_surface_present { "yes" } else { "no" })),
|
||||
];
|
||||
for irq in &h.runtime_irq_reports {
|
||||
lines.push(Line::from(format!(" IRQ: {} pid={} {} mode={} reason={}", irq.driver, irq.pid, irq.device, irq.mode, irq.reason)));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn network_view(&self) -> Vec<Line<'a>> {
|
||||
let n = &self.report.network;
|
||||
let mut lines = vec![
|
||||
Line::from(Span::styled("Network", Style::default().fg(Color::Red))), Line::from(""),
|
||||
Line::from(format!("State: {:?}", n.state)),
|
||||
Line::from(format!("Connected: {}", if n.connected { "yes" } else { "no" })),
|
||||
];
|
||||
if let Some(iface) = &n.interface { lines.push(Line::from(format!("Interface: {iface}"))); }
|
||||
if let Some(mac) = &n.mac { lines.push(Line::from(format!("MAC: {mac}"))); }
|
||||
if let Some(addr) = &n.address { lines.push(Line::from(format!("Address: {addr}"))); }
|
||||
if let Some(dns) = &n.dns { lines.push(Line::from(format!("DNS: {dns}"))); }
|
||||
if let Some(route) = &n.default_route { lines.push(Line::from(format!("Route: {route}"))); }
|
||||
if let Some(profile) = &n.active_profile { lines.push(Line::from(format!("Profile: {profile}"))); }
|
||||
lines.push(Line::from(format!("WiFi state: {:?} interfaces: {:?}", n.wifi_control_state, n.wifi_interfaces)));
|
||||
lines.push(Line::from(format!("BT state: {:?} adapters: {:?}", n.bluetooth_control_state, n.bluetooth_adapters)));
|
||||
lines
|
||||
}
|
||||
|
||||
fn integrations_view(&self) -> Vec<Line<'a>> {
|
||||
let mut lines = vec![Line::from(Span::styled("Integrations", Style::default().fg(Color::Red))), Line::from("")];
|
||||
for s in &self.report.integrations {
|
||||
let state = format!("{:?}", s.state);
|
||||
let color = match s.state { ProbeState::Functional => Color::Green, ProbeState::Active => Color::Yellow, _ => Color::Red };
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("[{state:>12}] ",), Style::default().fg(color)),
|
||||
Span::raw(format!("{} ({})", s.check.name, s.check.category)),
|
||||
]));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn health_view(&self) -> Vec<Line<'a>> {
|
||||
vec![
|
||||
Line::from(Span::styled("Health Dashboard", Style::default().fg(Color::Red))), Line::from(""),
|
||||
Line::from(format!("PCI devices: {}", self.report.hardware.pci_devices)),
|
||||
Line::from(format!("Network state: {:?}", self.report.network.state)),
|
||||
Line::from(format!("Connected: {}", if self.report.network.connected { "✓" } else { "✗" })),
|
||||
Line::from(""),
|
||||
Line::from("Run with --health for detailed health check."),
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user