net-deck 0.19.0

Operator cyberdeck — terminal UI for the Net mesh
use net_sdk::deck::{LogLevel, LogRecord};
use ratatui::{
    layout::{Alignment, Constraint, Direction, Layout, Rect},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph},
    Frame,
};

use crate::theme;

/// LOGS tab view state — the filter / search / pause knobs the
/// operator twiddles. Grouped into a struct so `render` doesn't
/// take a 7-argument bag of bools and strings; the App stamps
/// one of these per frame from its own state.
pub struct LogsView<'a> {
    pub min_level: LogLevel,
    pub paused: bool,
    pub search: &'a str,
    pub search_editing: bool,
}

pub fn render(
    frame: &mut Frame<'_>,
    area: Rect,
    tick: u64,
    records: &[LogRecord],
    view: LogsView<'_>,
) {
    // Status bar gets two rows: the chip line on top and a
    // blank spacer below so the search/filter/pause chips
    // don't visually collide with the global footer's
    // tab/jump/cursor row.
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(0),
            Constraint::Length(2),
        ])
        .split(area);
    render_filter_bar(
        frame,
        rows[0],
        view.min_level,
        view.paused,
        view.search,
        view.search_editing,
    );
    render_log_grid(frame, rows[1], tick, records, view.min_level, view.search);
    let status_row = Rect {
        height: 1,
        ..rows[2]
    };
    render_status(
        frame,
        status_row,
        records,
        view.min_level,
        view.paused,
        view.search,
    );
}

fn render_filter_bar(
    frame: &mut Frame<'_>,
    area: Rect,
    min_level: LogLevel,
    paused: bool,
    search: &str,
    search_editing: bool,
) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(theme::rule())
        .title(Line::from(vec![
            Span::styled(format!("{} ", theme::SECTION_PREFIX), theme::green()),
            Span::styled("LOG.MATRIX", theme::green_hi()),
        ]));
    let inner = block.inner(area);
    frame.render_widget(block, area);

    // While editing, the chip row is replaced with the search
    // prompt — the operator's full attention is on the buffer.
    if search_editing {
        let line = Line::from(vec![
            Span::styled("/ ", theme::amber()),
            Span::styled(search.to_string(), theme::green_hi()),
            Span::styled("_", theme::amber()),
            Span::styled("    [Enter] commit  [Esc] cancel", theme::dim()),
        ]);
        frame.render_widget(Paragraph::new(line), inner);
        return;
    }

    // Active level threshold gets the amber accent when it's
    // suppressing rows; default Info is rendered green to read
    // as "open / unfiltered."
    let (level_text, level_style) = level_chip(min_level);
    let (follow_text, follow_style) = if paused {
        ("PAUSED", theme::amber())
    } else {
        ("ON", theme::green_hi())
    };

    let mut spans = vec![
        Span::styled("level ", theme::chrome()),
        Span::styled("[", theme::chrome()),
        Span::styled(level_text, level_style),
        Span::styled("]   ", theme::chrome()),
        Span::styled("match ", theme::chrome()),
        Span::styled("[", theme::chrome()),
    ];
    if search.is_empty() {
        spans.push(Span::styled("*", theme::green_hi()));
    } else {
        spans.push(Span::styled(format!("/{search}/"), theme::amber()));
    }
    spans.extend([
        Span::styled("]   ", theme::chrome()),
        Span::styled("kind ", theme::chrome()),
        Span::styled("[", theme::chrome()),
        Span::styled("*", theme::green_hi()),
        Span::styled("]   ", theme::chrome()),
        Span::styled("follow ", theme::chrome()),
        Span::styled("[", theme::chrome()),
        Span::styled(follow_text, follow_style),
        Span::styled("]", theme::chrome()),
    ]);
    frame.render_widget(Paragraph::new(Line::from(spans)), inner);
}

fn level_chip(min_level: LogLevel) -> (&'static str, ratatui::style::Style) {
    match min_level {
        LogLevel::Debug => ("DEBUG+", theme::green_hi()),
        LogLevel::Info => ("INFO+", theme::green_hi()),
        LogLevel::Warn => ("WARN+", theme::amber()),
        LogLevel::Error => ("ERR", theme::red()),
        _ => ("?", theme::chrome()),
    }
}

/// Numeric rank used for "min level" comparisons. Higher means
/// more severe. The fallback `0` (treats an unknown future
/// variant as the most verbose, NOT as Info) means a new
/// variant lands in the operator's view by default; the
/// previous `1` fallback silently let unknown variants past
/// the Info filter, which is the wrong direction for a
/// `#[non_exhaustive]` enum.
pub(crate) fn level_rank(l: LogLevel) -> u8 {
    match l {
        LogLevel::Debug => 0,
        LogLevel::Info => 1,
        LogLevel::Warn => 2,
        LogLevel::Error => 3,
        _ => 0,
    }
}

fn render_log_grid(
    frame: &mut Frame<'_>,
    area: Rect,
    tick: u64,
    records: &[LogRecord],
    min_level: LogLevel,
    search: &str,
) {
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(theme::rule());
    let inner = block.inner(area);
    frame.render_widget(block, area);

    let total_rows = inner.height as usize;
    // Empty ring (fresh runtime, no source publishing yet)
    // surfaces a centered "waiting" placeholder so the user
    // sees the tab is wired but idle.
    let _ = tick;
    let lines: Vec<Line> = if records.is_empty() {
        Vec::new()
    } else {
        project_log_records(records, total_rows, min_level, search)
    };

    if lines.is_empty() {
        // Distinguish "no logs at all" from "filters hiding
        // everything" — the latter is easy to miss and a common
        // operator confusion. When a search is active the hint
        // points at `[/]` first, because that's usually the
        // narrowest filter.
        let (head, hint) = if records.is_empty() {
            (
                "no log lines yet",
                "publish_log() on any registered daemon will appear here",
            )
        } else if !search.is_empty() {
            (
                "no log lines match the current filters",
                "press [/] to edit the search or [f] to lower the level",
            )
        } else {
            (
                "no log lines at this threshold",
                "press [f] to lower the level filter",
            )
        };
        crate::widgets::empty::render(frame, inner, head, hint);
        return;
    }

    let start = lines.len().saturating_sub(total_rows);
    let visible = &lines[start..];
    frame.render_widget(Paragraph::new(visible.to_vec()), inner);
}

fn render_status(
    frame: &mut Frame<'_>,
    area: Rect,
    records: &[LogRecord],
    min_level: LogLevel,
    paused: bool,
    search: &str,
) {
    let cols = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Min(0), Constraint::Length(36)])
        .split(area);

    let total = records.len();
    let needle = search.to_ascii_lowercase();
    let shown = records
        .iter()
        .filter(|r| level_rank(r.level) >= level_rank(min_level))
        .filter(|r| needle.is_empty() || record_matches(r, &needle))
        .count();
    let (source_text, source_style) = if paused {
        ("frozen snapshot", theme::amber())
    } else {
        ("live snapshot", theme::dim())
    };
    let left = Line::from(vec![
        Span::styled(format!("{shown}/{total} lines  ·  "), theme::chrome()),
        Span::styled(source_text, source_style),
        Span::styled("  ·  ", theme::chrome()),
        Span::styled("0 dropped", theme::green_hi()),
    ]);
    frame.render_widget(Paragraph::new(left), cols[0]);

    let right = Line::from(vec![
        Span::styled("[/] ", theme::green_hi()),
        Span::styled("search   ", theme::dim()),
        Span::styled("[f] ", theme::green_hi()),
        Span::styled("filter   ", theme::dim()),
        Span::styled("[p] ", theme::green_hi()),
        Span::styled("pause", theme::dim()),
    ]);
    frame.render_widget(Paragraph::new(right).alignment(Alignment::Right), cols[1]);
}

/// Project a slice of `LogRecord` into renderable Lines.
/// Each record carries `seq`, `ts_ms`, `level`, `daemon_id`,
/// `node_id`, `message`. Older entries are at the front of the
/// slice; we keep that order and let the caller pick the tail.
fn project_log_records(
    records: &[LogRecord],
    capacity: usize,
    min_level: LogLevel,
    search: &str,
) -> Vec<Line<'static>> {
    let mut out: Vec<Line> = Vec::with_capacity(records.len().min(capacity));
    let min = level_rank(min_level);
    let needle = search.to_ascii_lowercase();
    for rec in records
        .iter()
        .filter(|r| level_rank(r.level) >= min)
        .filter(|r| needle.is_empty() || record_matches(r, &needle))
    {
        // LOGS-specific layout: `TS LEVEL ICON source message`.
        // The NET.MAP MESH.EVENTS panel still uses the shared
        // icon-only `render_event_line` — LOGS gets the
        // explicit LEVEL chip back because the export format
        // includes it and operators search by level threshold
        // here, so the column doubles as a filter readout.
        let (level_text, level_style) = match rec.level {
            LogLevel::Debug => ("DEBUG", theme::chrome()),
            LogLevel::Info => ("INFO ", theme::dim()),
            LogLevel::Warn => ("WARN ", theme::amber()),
            LogLevel::Error => ("ERROR", theme::red()),
            _ => ("?    ", theme::text()),
        };
        let (icon, icon_style) = super::event_icon(rec);
        let source = super::event_source(rec);
        const SOURCE_PAD: usize = 19;
        out.push(Line::from(vec![
            Span::styled(
                format!("  {}  ", super::fmt_ts_hms_ms(rec.ts_ms)),
                theme::chrome(),
            ),
            Span::styled(format!("{level_text}  "), level_style),
            Span::styled(format!("{icon} "), icon_style),
            Span::styled(format!("{source:<SOURCE_PAD$}  "), icon_style),
            Span::styled(rec.message.clone(), theme::text()),
        ]));
    }
    out
}

/// Match a log record against the search needle. Covers the
/// message column plus the structured `daemon_id` / `node_id`
/// fields rendered as `0x…` so operators can grep by daemon
/// hex directly — even when the message text doesn't repeat
/// the id (e.g. an auto-generated daemon log emitted via
/// `publish_log` without echoing the id into the message).
/// Allocation-free — reuses `audit::ascii_icontains` so the
/// per-render filter doesn't lowercase the haystack into a
/// fresh `String` for every record × every frame.
pub(crate) fn record_matches(rec: &LogRecord, needle_lower: &str) -> bool {
    if needle_lower.is_empty() {
        return true;
    }
    if super::audit::ascii_icontains(&rec.message, needle_lower) {
        return true;
    }
    use std::fmt::Write;
    let mut buf = String::with_capacity(18);
    if let Some(d) = rec.daemon_id {
        buf.clear();
        let _ = write!(&mut buf, "0x{d:x}");
        if super::audit::ascii_icontains(&buf, needle_lower) {
            return true;
        }
    }
    if let Some(n) = rec.node_id {
        buf.clear();
        let _ = write!(&mut buf, "0x{n:x}");
        if super::audit::ascii_icontains(&buf, needle_lower) {
            return true;
        }
    }
    false
}