syswatch 0.6.1

Single-host, read-only system diagnostics TUI. Twelve tabs covering CPU, memory, disks, processes, GPU, power, services, network, plus a Timeline scrubber and an Insights anomaly engine. Sibling to netwatch.
use ratatui::{
    layout::{Constraint, Direction, Layout, Rect},
    style::{Modifier, Style},
    text::{Line, Span},
    widgets::Paragraph,
    Frame,
};

use crate::app::{App, Snapshot};
use crate::ui::{
    graph::GraphStyle,
    palette as p,
    widgets::{block_bar_styled, human_bytes, panel},
};

pub fn draw(f: &mut Frame, area: Rect, app: &App, snap: &Snapshot) {
    let v = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(7),
            Constraint::Length(7),
            Constraint::Min(0),
        ])
        .split(area);

    draw_ram_bar(f, v[0], snap, app.graph_style);
    draw_swap(f, v[1], snap, app.graph_style);
    draw_top_rss(f, v[2], app, snap);
}

fn draw_ram_bar(f: &mut Frame, area: Rect, snap: &Snapshot, style: GraphStyle) {
    let block = panel("RAM");
    let inner = block.inner(area);
    f.render_widget(block, area);

    let total = snap.mem.total_bytes.max(1);
    let used = snap.mem.used_bytes;
    let avail = snap.mem.available_bytes;
    let pct = used as f32 / total as f32;
    let color = if pct >= 0.9 {
        p::status_error()
    } else if pct >= 0.7 {
        p::status_warn()
    } else {
        p::status_good()
    };

    let header = Line::from(vec![
        Span::styled("used ", Style::default().fg(p::text_muted())),
        Span::styled(
            human_bytes(used),
            Style::default().fg(color).add_modifier(Modifier::BOLD),
        ),
        Span::styled(" / ", Style::default().fg(p::text_muted())),
        Span::styled(human_bytes(total), Style::default().fg(p::text_primary())),
        Span::styled(
            format!("   ({:>4.1}%)", pct * 100.0),
            Style::default().fg(p::text_muted()),
        ),
        Span::styled("    available ", Style::default().fg(p::text_muted())),
        Span::styled(human_bytes(avail), Style::default().fg(p::text_primary())),
    ]);
    let bar = block_bar_styled(pct, inner.width, color, style);
    f.render_widget(
        Paragraph::new(vec![header, Line::from(""), bar]).style(Style::default().bg(p::bg())),
        inner,
    );
}

fn draw_swap(f: &mut Frame, area: Rect, snap: &Snapshot, style: GraphStyle) {
    let block = panel("Swap");
    let inner = block.inner(area);
    f.render_widget(block, area);

    let total = snap.mem.swap_total_bytes;
    let used = snap.mem.swap_used_bytes;
    let pct = if total > 0 {
        used as f32 / total as f32
    } else {
        0.0
    };
    let color = if pct >= 0.75 {
        p::status_error()
    } else if pct >= 0.25 {
        p::status_warn()
    } else {
        p::status_good()
    };

    let header = if total == 0 {
        Line::from(vec![Span::styled(
            "no swap configured",
            Style::default().fg(p::text_muted()),
        )])
    } else {
        Line::from(vec![
            Span::styled("used ", Style::default().fg(p::text_muted())),
            Span::styled(
                human_bytes(used),
                Style::default().fg(color).add_modifier(Modifier::BOLD),
            ),
            Span::styled(" / ", Style::default().fg(p::text_muted())),
            Span::styled(human_bytes(total), Style::default().fg(p::text_primary())),
            Span::styled(
                format!("   ({:>4.1}%)", pct * 100.0),
                Style::default().fg(p::text_muted()),
            ),
        ])
    };
    let bar = block_bar_styled(pct, inner.width, color, style);
    f.render_widget(
        Paragraph::new(vec![header, Line::from(""), bar]).style(Style::default().bg(p::bg())),
        inner,
    );
}

fn draw_top_rss(f: &mut Frame, area: Rect, app: &App, snap: &Snapshot) {
    let block = panel("Top processes (by RSS)");
    let inner = block.inner(area);
    f.render_widget(block, area);

    let mut sorted = snap.procs.clone();
    sorted.sort_by(|a, b| b.mem_rss.cmp(&a.mem_rss));
    let take = inner.height.saturating_sub(1) as usize;

    let mut lines: Vec<Line> = vec![Line::from(vec![
        Span::styled(
            format!("{:>7} ", "PID"),
            Style::default()
                .fg(p::text_muted())
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            format!("{:<10} ", "USER"),
            Style::default()
                .fg(p::text_muted())
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            format!("{:>10} ", "RSS"),
            Style::default()
                .fg(p::text_muted())
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            format!("{:>10} ", "VIRT"),
            Style::default()
                .fg(p::text_muted())
                .add_modifier(Modifier::BOLD),
        ),
        Span::styled(
            "COMMAND",
            Style::default()
                .fg(p::text_muted())
                .add_modifier(Modifier::BOLD),
        ),
    ])];
    let rendered_rows = sorted.iter().take(take).count();
    for (i, proc_) in sorted.iter().take(take).enumerate() {
        let row_alpha = if app.user_config.graph_fade {
            crate::ui::graph::row_fade_alpha(i, rendered_rows)
        } else {
            1.0
        };
        let spans = vec![
            Span::styled(
                format!("{:>7} ", proc_.pid),
                Style::default().fg(p::text_primary()),
            ),
            Span::styled(
                format!("{:<10.10} ", proc_.user),
                Style::default().fg(p::text_muted()),
            ),
            Span::styled(
                format!("{:>10} ", human_bytes(proc_.mem_rss)),
                Style::default().fg(p::brand()),
            ),
            Span::styled(
                format!("{:>10} ", human_bytes(proc_.mem_virt)),
                Style::default().fg(p::text_muted()),
            ),
            Span::styled(proc_.name.clone(), Style::default().fg(p::text_primary())),
        ];
        let spans = if (row_alpha - 1.0).abs() < f32::EPSILON {
            spans
        } else {
            crate::ui::graph::fade_spans_fg(spans, p::bg(), row_alpha)
        };
        lines.push(Line::from(spans));
    }
    f.render_widget(
        Paragraph::new(lines).style(Style::default().bg(p::bg())),
        inner,
    );
}