use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
Frame,
};
use super::state::{
AppState, BuildScope, FocusedPane, InfraViewState, LogSource, ShutdownPhase,
};
fn focus_border_style(focused: bool) -> Style {
if focused {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
}
}
pub fn draw(frame: &mut Frame, app: &mut AppState) {
match app.view {
super::state::TuiView::Dev => draw_dev(frame, app),
super::state::TuiView::Infra => draw_infra(frame, app),
}
}
fn draw_dev(frame: &mut Frame, app: &mut AppState) {
let area = frame.area();
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(area);
render_body(frame, rows[0], app);
render_footer(frame, rows[1], app);
if app.build_dialog.is_some() {
render_build_dialog(frame, area, app);
}
}
fn render_build_dialog(frame: &mut Frame, area: Rect, app: &AppState) {
let Some(dialog) = app.build_dialog.as_ref() else {
return;
};
let width = 60.min(area.width.saturating_sub(4));
let height = (BuildScope::ALL.len() as u16 + 6).min(area.height.saturating_sub(2));
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + area.height.saturating_sub(height) / 2;
let popup = Rect { x, y, width, height };
frame.render_widget(Clear, popup);
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
.title(Span::styled(
" Rebuild — choose scope ",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
));
let mut lines: Vec<Line> = Vec::with_capacity(BuildScope::ALL.len() + 3);
for (i, scope) in BuildScope::ALL.iter().enumerate() {
let selected = i == dialog.selected;
let marker = if selected { "▶ " } else { " " };
let hint = format!("[{}]", scope.key_hint());
let row_style = if selected {
Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
lines.push(Line::from(vec![
Span::styled(marker, row_style),
Span::styled(format!("{:<4}", hint), row_style),
Span::styled(scope.label().to_string(), row_style),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("↑↓", Style::default().fg(Color::Cyan)),
Span::raw(" move "),
Span::styled("Enter", Style::default().fg(Color::Cyan)),
Span::raw(" rebuild "),
Span::styled("A/s/a/u", Style::default().fg(Color::Cyan)),
Span::raw(" shortcut "),
Span::styled("Esc", Style::default().fg(Color::Cyan)),
Span::raw(" cancel"),
]));
let para = Paragraph::new(lines).block(block);
frame.render_widget(para, popup);
}
fn render_body(frame: &mut Frame, area: Rect, app: &mut AppState) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(area);
let services_rect = rows[0];
let body_rect = rows[1];
app.layout.service_list = services_rect;
render_services_tabs(frame, services_rect, app);
let has_apps = !app.app_list.is_empty();
let has_nodes = !app.node_list.is_empty();
if !has_apps && !has_nodes {
app.layout.app_list = Rect::default();
app.layout.node_list = Rect::default();
render_log_pane(frame, body_rect, app);
return;
}
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(30), Constraint::Min(0)])
.split(body_rect);
let sidebar_rect = cols[0];
let (apps_rect, nodes_rect) = match (has_apps, has_nodes) {
(true, true) => {
let halves = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(node_panel_height(app))])
.split(sidebar_rect);
(halves[0], halves[1])
}
(true, false) => (sidebar_rect, Rect::default()),
(false, true) => (Rect::default(), sidebar_rect),
(false, false) => (Rect::default(), Rect::default()),
};
app.layout.app_list = apps_rect;
app.layout.node_list = nodes_rect;
if has_apps {
render_app_list(frame, apps_rect, app);
}
if has_nodes {
render_node_list(frame, nodes_rect, app);
}
render_log_pane(frame, cols[1], app);
}
fn render_services_tabs(frame: &mut Frame, area: Rect, app: &AppState) {
let focused = app.focused_pane == FocusedPane::Services;
let hide_app_row = !app.app_list.is_empty();
let mut spans: Vec<Span> = Vec::new();
let mut number = 1u8;
for (source, svc) in app.active_services().iter() {
if hide_app_row && *source == LogSource::App {
continue;
}
let is_active = *source == app.active_pane;
let label = format!("{}:{}", number, svc.label);
spans.push(Span::raw(" "));
spans.push(Span::styled(
label,
if is_active {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
},
));
spans.push(Span::styled(
format!(" {}", svc.status.indicator()),
Style::default().fg(svc.status.color()),
));
spans.push(Span::raw(" "));
number += 1;
}
let version_part = if app.app_version.is_empty() {
String::new()
} else {
format!(" v{}", app.app_version)
};
let env_part = app
.active_daemon_env_file()
.map(|p| format!(" env={}", compact_path(p)))
.unwrap_or_default();
let title = format!(" {}{}{} ", app.app_name, version_part, env_part);
let block = Block::default()
.borders(Borders::ALL)
.border_style(focus_border_style(focused))
.title(title);
frame.render_widget(Paragraph::new(Line::from(spans)).block(block), area);
}
fn node_panel_height(app: &AppState) -> u16 {
let n = app.node_list.len() as u16;
(n.saturating_mul(3)).saturating_add(2).max(5)
}
fn render_node_list(frame: &mut Frame, area: Rect, app: &AppState) {
let focused = app.focused_pane == FocusedPane::Nodes;
let active_name = app.focused_node_name().map(str::to_owned);
let items: Vec<ListItem> = app
.node_list
.iter()
.map(|n| {
let active = active_name.as_deref() == Some(n.name.as_str());
let name_style = if active {
Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let dim = Style::default().fg(Color::DarkGray);
let line1 = Line::from(vec![
Span::styled(
if active { "▶" } else { " " },
Style::default().fg(Color::Cyan),
),
Span::styled(
format!("{} ", n.status.indicator()),
Style::default().fg(n.status.color()),
),
Span::styled(format!("{:<14}", n.name), name_style),
Span::styled(n.status.summary(), Style::default().fg(n.status.color())),
]);
let line2 = if n.detail.is_empty() {
Line::raw("")
} else {
Line::from(Span::styled(format!(" {}", n.detail), dim))
};
let line3 = match app.daemon_env_files.get(&n.name) {
Some(p) => Line::from(Span::styled(
format!(" env={}", compact_path(p)),
dim,
)),
None => Line::raw(""),
};
ListItem::new(vec![line1, line2, line3])
})
.collect();
let block = Block::default()
.borders(Borders::ALL)
.border_style(focus_border_style(focused))
.title(" nodes ");
let list = List::new(items).block(block);
frame.render_widget(list, area);
}
fn render_app_list(frame: &mut Frame, area: Rect, app: &AppState) {
let active_name = app.instance_filter_label().map(str::to_owned);
let items: Vec<ListItem> = app
.app_list
.iter()
.map(|a| {
let active = active_name.as_deref() == Some(a.name.as_str())
&& app.active_pane == LogSource::App;
let name_style = if active {
Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Gray)
};
let dim = Style::default().fg(Color::DarkGray);
let count = app.app_log_count(&a.name);
let line1 = Line::from(vec![
Span::styled(
if active { "▶" } else { " " },
Style::default().fg(Color::Cyan),
),
Span::styled(
format!("{} ", a.status.indicator()),
Style::default().fg(a.status.color()),
),
Span::styled(format!("{:<14}", short_app_name(&a.name)), name_style),
Span::styled(
if count > 0 { format!("{}", count) } else { "·".into() },
dim,
),
]);
let line2 = if a.detail.is_empty() {
Line::from(Span::styled(
format!(" {}", a.status.summary()),
Style::default().fg(a.status.color()),
))
} else {
Line::from(Span::styled(format!(" {}", a.detail), dim))
};
let line3 = match &a.path {
Some(p) => Line::from(Span::styled(format!(" {}", compact_path(p)), dim)),
None => Line::raw(""),
};
ListItem::new(vec![line1, line2, line3])
})
.collect();
let focused = app.focused_pane == FocusedPane::Apps;
let block = Block::default()
.borders(Borders::ALL)
.border_style(focus_border_style(focused))
.title(" apps ");
let list = List::new(items).block(block);
frame.render_widget(list, area);
}
fn short_app_name(name: &str) -> String {
if name.chars().count() <= 14 {
name.to_string()
} else {
let mut s: String = name.chars().take(13).collect();
s.push('…');
s
}
}
fn compact_path(p: &std::path::Path) -> String {
const MAX: usize = 24;
let raw = p.to_string_lossy().into_owned();
let cache_root = std::env::var("XDG_CACHE_HOME")
.ok()
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".cache")))
.map(|c| c.join("node-app"));
let shortened = if let Some(cache) = cache_root.as_ref() {
let cache_str = cache.to_string_lossy().into_owned();
if let Some(rest) = raw.strip_prefix(&cache_str) {
format!("%cache%{}", rest)
} else if let Some(home) = std::env::var_os("HOME") {
let home_str = std::path::PathBuf::from(home).to_string_lossy().into_owned();
raw.strip_prefix(&home_str)
.map(|rest| format!("~{}", rest))
.unwrap_or(raw)
} else {
raw
}
} else {
raw
};
if shortened.chars().count() <= MAX {
return shortened;
}
let chars: Vec<char> = shortened.chars().collect();
let tail = chars[chars.len().saturating_sub(MAX - 1)..]
.iter()
.collect::<String>();
format!("…{}", tail)
}
fn build_log_title(app: &AppState, source: LogSource) -> String {
let svc = app
.active_services()
.iter()
.find(|(s, _)| *s == source)
.map(|(_, v)| v);
let timings = app.active_timings();
let mut parts: Vec<String> = vec![app.numbered_label(source)];
if let Some(svc) = svc {
parts.push(svc.status.summary());
}
if source == LogSource::App {
if let Some(inst) = app.instance_filter_label() {
let mut chunk = format!("[{inst}]");
if let Some(p) = app
.app_list
.iter()
.find(|a| a.name == inst)
.and_then(|a| a.path.as_deref())
{
chunk.push_str(&format!(" {}", p.display()));
}
parts.push(chunk);
}
}
if source == LogSource::Daemon {
if let Some(p) = app.active_daemon_env_file() {
parts.push(format!("env={}", p.display()));
}
}
if let Some(svc) = svc {
if !svc.detail.is_empty() && source != LogSource::App {
parts.push(svc.detail.clone());
}
let timing = build_timing_line(source, svc, timings);
if !timing.is_empty() {
parts.push(timing);
}
}
format!(" {} ", parts.join(" · "))
}
fn build_timing_line(
source: LogSource,
svc: &super::state::ServiceState,
t: &super::state::Timings,
) -> String {
match source {
LogSource::Build => {
match &svc.status {
super::state::ServiceStatus::Building => {
let elapsed = t
.build_started_at
.map(|s| s.elapsed().as_millis())
.unwrap_or(0);
let count_hint = if t.build_count > 0 {
format!(" ×{} prev", t.build_count)
} else {
String::new()
};
format!("elapsed {}{}", fmt_ms(elapsed as u64), count_hint)
}
super::state::ServiceStatus::Ready => {
let builtins = t.builtin_apps_build_ms
.map(|ms| format!("builtins {} ", fmt_ms(ms)))
.unwrap_or_default();
match t.last_build_ms {
Some(ms) => format!("{}last {} ×{} builds", builtins, fmt_ms(ms), t.build_count),
None if !builtins.is_empty() => builtins.trim_end().to_string(),
None => String::new(),
}
}
super::state::ServiceStatus::Failed(_) => {
match t.last_build_ms {
Some(ms) => format!("failed {} ×{} total", fmt_ms(ms), t.build_count),
None => String::new(),
}
}
_ => String::new(),
}
}
LogSource::App => {
if let Some(ms) = t.last_reload_ms {
format!("reload {}", fmt_ms(ms))
} else {
String::new()
}
}
LogSource::Daemon => {
let build_part = t.platform_build_ms
.map(|ms| format!("build {} ", fmt_ms(ms)))
.unwrap_or_default();
if let Some(started) = t.daemon_started_at {
format!("{}up {}", build_part, fmt_duration(started.elapsed().as_secs()))
} else if !build_part.is_empty() {
build_part.trim_end().to_string()
} else {
String::new()
}
}
_ => String::new(),
}
}
fn fmt_ms(ms: u64) -> String {
if ms < 1_000 {
format!("{ms}ms")
} else {
format!("{:.1}s", ms as f64 / 1_000.0)
}
}
fn fmt_duration(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else {
format!("{}m {}s", secs / 60, secs % 60)
}
}
fn render_log_pane(frame: &mut Frame, area: Rect, app: &mut AppState) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(area);
app.layout.log_scroll = rows[1];
render_timings_bar(frame, rows[0], app);
render_log_scroll(frame, rows[1], app);
}
fn render_timings_bar(frame: &mut Frame, area: Rect, app: &AppState) {
let t = app.active_timings();
let dim = Style::default().fg(Color::DarkGray);
let val = Style::default().fg(Color::Cyan);
let sep = Style::default().fg(Color::DarkGray);
let mut spans: Vec<Span> = Vec::new();
macro_rules! kv {
($label:expr, $value:expr) => {
if !spans.is_empty() {
spans.push(Span::styled(" ", sep));
}
spans.push(Span::styled(concat!($label, ":"), dim));
spans.push(Span::styled($value, val));
};
}
if let Some(ms) = t.platform_build_ms {
kv!("platform", fmt_ms(ms));
} else if t.platform_build_started_at.is_some() {
let elapsed = t.platform_build_started_at.map(|s| s.elapsed().as_millis()).unwrap_or(0);
kv!("platform", format!("{}…", fmt_ms(elapsed as u64)));
}
if let Some(ms) = t.builtin_apps_build_ms {
kv!("builtins", fmt_ms(ms));
}
match (t.last_build_ms, t.build_count) {
(Some(ms), n) => {
let v = if n > 1 { format!("{} ×{}", fmt_ms(ms), n) } else { fmt_ms(ms) };
kv!("app", v);
}
(None, _) if t.build_started_at.is_some() => {
let elapsed = t.build_started_at.map(|s| s.elapsed().as_millis()).unwrap_or(0);
kv!("app", format!("{}…", fmt_ms(elapsed as u64)));
}
_ => {}
}
if let Some(ms) = t.last_reload_ms {
kv!("reload", fmt_ms(ms));
}
if let Some(started) = t.daemon_started_at {
kv!("up", fmt_duration(started.elapsed().as_secs()));
}
let pane_label = {
let src = app.active_pane;
if !app.search_query.is_empty() {
let total = app.log_lines(src).len();
let matched = app.log_lines(src).iter().filter(|l| app.matches(l)).count();
format!(" {matched}/{total} ")
} else {
format!(" {} ", app.numbered_label(src))
}
};
let mut all_spans: Vec<Span> = vec![Span::raw(" ")];
all_spans.extend(spans);
let content_width: usize = all_spans.iter().map(|s| s.content.len()).sum();
let label_width = pane_label.len();
let area_width = area.width as usize;
if content_width + label_width < area_width {
let pad = area_width - content_width - label_width;
all_spans.push(Span::raw(" ".repeat(pad)));
}
all_spans.push(Span::styled(
pane_label,
Style::default().fg(Color::Black).bg(Color::Cyan),
));
frame.render_widget(Paragraph::new(Line::from(all_spans)), area);
}
fn render_log_scroll(frame: &mut Frame, area: Rect, app: &mut AppState) {
let height = (area.height as usize).saturating_sub(2);
app.last_render_height = height;
let source = app.active_pane;
let search_query = app.search_query.clone();
let selection = app.selection;
let text: Vec<Line> = if search_query.is_empty() {
let total = app.log_lines(source).len();
let start = app.visible_start_for(total, height);
app.log_scroll_start = start;
app.log_lines(source)
.iter()
.enumerate()
.skip(start)
.take(height)
.map(|(abs_idx, l)| {
if selection.is_some_and(|s| s.contains(abs_idx)) {
colorize_selected(l.as_str())
} else {
colorize(l.as_str(), source)
}
})
.collect()
} else {
app.log_scroll_start = 0;
let matched: Vec<(String, Vec<usize>)> = app
.log_lines(source)
.iter()
.filter_map(|l| {
app.match_positions(l.as_str())
.map(|pos| (l.clone(), pos))
})
.collect();
let start = app.visible_start_for(matched.len(), height);
matched
.into_iter()
.skip(start)
.take(height)
.map(|(l, pos)| render_fuzzy_highlighted(&l, &pos, source))
.collect()
};
let focused = app.focused_pane == FocusedPane::Logs;
let title = build_log_title(app, source);
let block = Block::default()
.borders(Borders::ALL)
.border_style(focus_border_style(focused))
.title(title);
frame.render_widget(Paragraph::new(text).block(block), area);
}
fn colorize_selected(line: &str) -> Line<'static> {
Line::from(Span::styled(
line.to_owned(),
Style::default()
.bg(Color::Rgb(50, 80, 130))
.fg(Color::White),
))
}
fn render_fuzzy_highlighted(line: &str, positions: &[usize], source: LogSource) -> Line<'static> {
let base = base_style(line, source);
let hl = Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD);
if positions.is_empty() {
return Line::from(Span::styled(line.to_owned(), base));
}
let chars: Vec<char> = line.chars().collect();
let pos_set: std::collections::HashSet<usize> =
positions.iter().copied().collect();
let mut spans: Vec<Span<'static>> = Vec::new();
let mut segment = String::new();
let mut in_hl = false;
for (i, c) in chars.iter().enumerate() {
let want_hl = pos_set.contains(&i);
if want_hl != in_hl {
if !segment.is_empty() {
spans.push(Span::styled(
segment.clone(),
if in_hl { hl } else { base },
));
segment.clear();
}
in_hl = want_hl;
}
segment.push(*c);
}
if !segment.is_empty() {
spans.push(Span::styled(segment, if in_hl { hl } else { base }));
}
Line::from(spans)
}
fn colorize(line: &str, source: LogSource) -> Line<'static> {
Line::from(Span::styled(line.to_owned(), base_style(line, source)))
}
fn base_style(line: &str, source: LogSource) -> Style {
if line.contains(" ERROR") || line.contains("error[") || line.contains("✗") {
Style::default().fg(Color::Red)
} else if line.contains(" WARN") || line.contains("warning[") || line.contains("⚠") {
Style::default().fg(Color::Yellow)
} else if line.contains("✓") || line.contains(" INFO") {
Style::default().fg(Color::Green)
} else if source == LogSource::Build
&& (line.contains("Compiling") || line.contains("Finished") || line.contains("→"))
{
Style::default().fg(Color::Cyan)
} else {
Style::default().fg(Color::Gray)
}
}
fn render_footer(frame: &mut Frame, area: Rect, app: &AppState) {
if app
.copy_flash
.is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(2))
{
let lines = app.selection.map_or(0, |s| {
let (a, b) = s.range();
b - a + 1
});
let text = Line::from(vec![
Span::styled(
" ✓ copied",
Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {lines} line{}", if lines == 1 { "" } else { "s" }),
Style::default().fg(Color::White),
),
Span::styled(
" to clipboard",
Style::default().fg(Color::DarkGray),
),
]);
frame.render_widget(Paragraph::new(text), area);
return;
}
if app.shutdown_phase == ShutdownPhase::ShuttingDown {
let text = Line::from(vec![
Span::styled(
" ◐ shutting down — ",
Style::default().fg(Color::Yellow),
),
Span::styled(
"stopping daemon, ui-server and cleaning up…",
Style::default().fg(Color::DarkGray),
),
]);
frame.render_widget(Paragraph::new(text), area);
return;
}
if app.shutdown_phase == ShutdownPhase::Done {
let text = Line::from(Span::styled(
" ✓ all processes stopped",
Style::default().fg(Color::Green),
));
frame.render_widget(Paragraph::new(text), area);
return;
}
let text = if app.search_input_active {
Line::from(vec![
Span::styled(" /", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(
app.search_query.clone(),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
),
Span::styled("█", Style::default().fg(Color::Yellow)), Span::styled(
" [Enter/Esc] lock filter [Backspace] delete",
Style::default().fg(Color::DarkGray),
),
])
} else if !app.search_query.is_empty() {
Line::from(vec![
Span::styled(" filter: ", Style::default().fg(Color::Yellow)),
Span::styled(
app.search_query.clone(),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
),
Span::styled(" [/]", Style::default().fg(Color::Cyan)),
Span::raw(" edit "),
Span::styled("[Esc]", Style::default().fg(Color::Cyan)),
Span::raw(" clear "),
Span::styled("[↑↓]", Style::default().fg(Color::Cyan)),
Span::raw(" scroll "),
Span::styled("[q]", Style::default().fg(Color::Cyan)),
Span::raw(" quit "),
])
} else {
let arrows_label = match app.focused_pane {
FocusedPane::Services => " service ",
FocusedPane::Apps => " app ",
FocusedPane::Nodes => " node ",
FocusedPane::Logs => " scroll ",
};
let shift_arrows_label = match app.focused_pane {
FocusedPane::Logs => " page ",
_ => " scroll ",
};
let mut spans = vec![
Span::styled(" [tab]", Style::default().fg(Color::Cyan)),
Span::raw(" focus "),
Span::styled("[1-5/←→]", Style::default().fg(Color::Cyan)),
Span::raw(" service "),
Span::styled("[↑↓]", Style::default().fg(Color::Cyan)),
Span::raw(arrows_label),
Span::styled("[⇧↑↓]", Style::default().fg(Color::Cyan)),
Span::raw(shift_arrows_label),
Span::styled("[/]", Style::default().fg(Color::Cyan)),
Span::raw(" search "),
Span::styled("[r]", Style::default().fg(Color::Cyan)),
Span::raw(" restart "),
Span::styled("[b]", Style::default().fg(Color::Cyan)),
Span::raw(" build… "),
Span::styled("[c]", Style::default().fg(Color::Cyan)),
Span::raw(" clear "),
Span::styled("[g/G]", Style::default().fg(Color::Cyan)),
Span::raw(" top/btm "),
Span::styled("[q]", Style::default().fg(Color::Cyan)),
Span::raw(" quit "),
Span::styled("[drag]", Style::default().fg(Color::Cyan)),
Span::raw(" copy "),
];
if app.infra.is_some() {
spans.push(Span::styled("[I]", Style::default().fg(Color::Magenta)));
spans.push(Span::raw(" infra "));
}
Line::from(spans)
};
frame.render_widget(Paragraph::new(text), area);
}
fn draw_infra(frame: &mut Frame, app: &mut AppState) {
let area = frame.area();
let Some(iv) = &app.infra else {
frame.render_widget(
Paragraph::new(" No infrastructure found.")
.style(Style::default().fg(Color::Red)),
area,
);
return;
};
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(0), Constraint::Length(1), ])
.split(area);
render_infra_tab_bar(frame, rows[0], iv);
match iv.active_tab {
super::state::InfraTab::Status => render_tab_status(frame, rows[1], iv),
super::state::InfraTab::Graph => render_tab_graph(frame, rows[1], iv),
super::state::InfraTab::Snapshot => render_tab_snapshot(frame, rows[1], iv),
super::state::InfraTab::Db => render_tab_db(frame, rows[1], iv),
super::state::InfraTab::Logs => render_tab_logs(frame, rows[1], iv),
}
render_infra_footer(frame, rows[2], iv);
}
fn render_infra_tab_bar(frame: &mut Frame, area: Rect, iv: &InfraViewState) {
use super::state::InfraTab;
let mut spans: Vec<Span> = vec![Span::raw(" ")];
for &tab in InfraTab::all() {
let active = tab == iv.active_tab;
let style = if active {
Style::default()
.fg(Color::Black)
.bg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
spans.push(Span::styled(format!(" {} ", tab.label()), style));
spans.push(Span::raw(" "));
}
let refresh_label = if let Some(t) = iv.last_refresh {
let secs = t.elapsed().as_secs();
format!(" refreshed {}s ago ", secs)
} else {
" polling… ".to_string()
};
let content_w: usize = spans.iter().map(|s| s.content.len()).sum();
let label_w = refresh_label.len();
let area_w = area.width as usize;
if content_w + label_w < area_w {
spans.push(Span::raw(" ".repeat(area_w - content_w - label_w)));
}
spans.push(Span::styled(
refresh_label,
Style::default().fg(Color::DarkGray),
));
frame.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn render_tab_status(frame: &mut Frame, area: Rect, iv: &InfraViewState) {
let mut lines: Vec<Line> = vec![];
lines.push(Line::from(vec![
Span::styled(" rgs: ", Style::default().fg(Color::DarkGray)),
Span::styled(iv.rgs_url.clone(), Style::default().fg(Color::Cyan)),
Span::styled(" network: ", Style::default().fg(Color::DarkGray)),
Span::styled(iv.ln_network.clone(), Style::default().fg(Color::White)),
if iv.rgs_injected {
Span::styled(" [RGS injected]", Style::default().fg(Color::Green))
} else {
Span::styled(" [RGS not injected]", Style::default().fg(Color::DarkGray))
},
]));
lines.push(Line::raw(""));
if iv.services.is_empty() {
lines.push(Line::from(Span::styled(
" Waiting for health data…",
Style::default().fg(Color::DarkGray),
)));
} else {
lines.push(Line::from(Span::styled(
" SERVICE STATUS DETAIL",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::raw(""));
for svc in &iv.services {
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", svc.status.indicator()),
Style::default().fg(svc.status.color()),
),
Span::styled(
format!("{:<20}", svc.name),
Style::default().fg(Color::White),
),
Span::styled(
format!("{:<10}", svc.status.label()),
Style::default().fg(svc.status.color()),
),
Span::styled(svc.detail.clone(), Style::default().fg(Color::DarkGray)),
]));
}
}
frame.render_widget(Paragraph::new(lines).block(Block::default()), area);
}
fn render_tab_graph(frame: &mut Frame, area: Rect, iv: &InfraViewState) {
let mut lines: Vec<Line> = vec![];
if iv.graph_loading {
lines.push(Line::from(Span::styled(
" Loading graph data…",
Style::default().fg(Color::DarkGray),
)));
} else if let Some(ref g) = iv.graph {
lines.push(Line::from(vec![
Span::styled(" Nodes: ", Style::default().fg(Color::DarkGray)),
Span::styled(g.node_count.to_string(), Style::default().fg(Color::Cyan)),
Span::styled(" Channels: ", Style::default().fg(Color::DarkGray)),
Span::styled(
g.channel_count.to_string(),
Style::default().fg(Color::Cyan),
),
]));
lines.push(Line::raw(""));
if g.channels.is_empty() {
lines.push(Line::from(Span::styled(
" No channel data available.",
Style::default().fg(Color::DarkGray),
)));
} else {
lines.push(Line::from(Span::styled(
" SCID NODE1",
Style::default().fg(Color::DarkGray),
)));
lines.push(Line::raw(""));
let height = area.height as usize;
for ch in g.channels.iter().take(height.saturating_sub(4)) {
let node1_short = if ch.node1.len() > 20 {
format!("{}…", &ch.node1[..20])
} else {
ch.node1.clone()
};
lines.push(Line::from(vec![
Span::styled(
format!(" {:<22}", ch.scid),
Style::default().fg(Color::White),
),
Span::styled(node1_short, Style::default().fg(Color::Gray)),
]));
}
if g.channels.len() > height.saturating_sub(4) {
lines.push(Line::from(Span::styled(
format!(
" … and {} more",
g.channels.len() - height.saturating_sub(4)
),
Style::default().fg(Color::DarkGray),
)));
}
}
} else {
lines.push(Line::from(Span::styled(
" No graph data yet — waiting for first poll…",
Style::default().fg(Color::DarkGray),
)));
}
frame.render_widget(Paragraph::new(lines).block(Block::default()), area);
}
fn render_tab_snapshot(frame: &mut Frame, area: Rect, iv: &InfraViewState) {
let mut lines: Vec<Line> = vec![];
if iv.snapshot_loading {
lines.push(Line::from(Span::styled(
" Loading snapshot…",
Style::default().fg(Color::DarkGray),
)));
} else if let Some(ref s) = iv.snapshot {
let kv = |label: &'static str, value: String| -> Line<'static> {
Line::from(vec![
Span::styled(
format!(" {:<20}", label),
Style::default().fg(Color::DarkGray),
),
Span::styled(value, Style::default().fg(Color::White)),
])
};
lines.push(kv("version", s.version.to_string()));
lines.push(kv("chain_hash", s.chain_hash.clone()));
lines.push(kv("timestamp", s.timestamp_str.clone()));
lines.push(kv("nodes", s.node_count.to_string()));
lines.push(kv("channels", s.channel_count.to_string()));
lines.push(kv("updates (est.)", s.update_count.to_string()));
} else {
lines.push(Line::from(Span::styled(
" No snapshot data yet — waiting for first poll…",
Style::default().fg(Color::DarkGray),
)));
}
frame.render_widget(Paragraph::new(lines).block(Block::default()), area);
}
fn render_tab_db(frame: &mut Frame, area: Rect, iv: &InfraViewState) {
let mut lines: Vec<Line> = vec![];
if iv.db_loading {
lines.push(Line::from(Span::styled(
" Loading database summary…",
Style::default().fg(Color::DarkGray),
)));
} else if let Some(ref d) = iv.db {
let kv = |label: &'static str, value: u64| -> Line<'static> {
Line::from(vec![
Span::styled(
format!(" {:<28}", label),
Style::default().fg(Color::DarkGray),
),
Span::styled(value.to_string(), Style::default().fg(Color::Cyan)),
])
};
lines.push(kv("node_announcements", d.node_announcements));
lines.push(kv("channel_announcements", d.channel_announcements));
lines.push(kv("channel_updates", d.channel_updates));
lines.push(kv("config_rows", d.config_rows));
} else {
lines.push(Line::from(Span::styled(
" No database data yet — waiting for first poll…",
Style::default().fg(Color::DarkGray),
)));
}
frame.render_widget(Paragraph::new(lines).block(Block::default()), area);
}
fn render_tab_logs(frame: &mut Frame, area: Rect, iv: &InfraViewState) {
let height = area.height as usize;
let all_lines = iv.visible_lines();
let total = all_lines.len();
let start = if iv.auto_scroll {
total.saturating_sub(height)
} else {
iv.scroll_pos.min(total.saturating_sub(height))
};
let filter_label = match iv.log_filter {
super::state::InfraLogFilter::All => "all",
super::state::InfraLogFilter::Rgs => "rgs",
super::state::InfraLogFilter::Postgres => "postgres",
};
let text: Vec<Line> = std::iter::once(Line::from(vec![
Span::styled(
format!(" filter: {} ", filter_label),
Style::default().fg(Color::DarkGray),
),
Span::styled("[a]all ", Style::default().fg(Color::Cyan)),
Span::styled("[r]rgs ", Style::default().fg(Color::Cyan)),
Span::styled("[p]postgres", Style::default().fg(Color::Cyan)),
Span::styled(
format!(" ({}/{})", total, iv.logs_total()),
Style::default().fg(Color::DarkGray),
),
]))
.chain(
all_lines
.iter()
.skip(start)
.take(height.saturating_sub(1))
.map(|l| colorize_infra_line(l)),
)
.collect();
frame.render_widget(Paragraph::new(text).block(Block::default()), area);
}
fn colorize_infra_line(line: &str) -> Line<'static> {
let style = if line.contains("ERROR") || line.contains("error") || line.contains("FATAL") {
Style::default().fg(Color::Red)
} else if line.contains("WARN") || line.contains("warn") {
Style::default().fg(Color::Yellow)
} else if line.contains("INFO") || line.contains("info") {
Style::default().fg(Color::Green)
} else {
Style::default().fg(Color::Gray)
};
Line::from(Span::styled(line.to_owned(), style))
}
fn render_infra_footer(frame: &mut Frame, area: Rect, iv: &InfraViewState) {
let tab_hints = match iv.active_tab {
super::state::InfraTab::Logs => Line::from(vec![
Span::styled(" [1-5]", Style::default().fg(Color::Cyan)),
Span::raw(" tab "),
Span::styled("[Tab]", Style::default().fg(Color::Cyan)),
Span::raw(" next "),
Span::styled("[↑↓]", Style::default().fg(Color::Cyan)),
Span::raw(" scroll "),
Span::styled("[g/G]", Style::default().fg(Color::Cyan)),
Span::raw(" top/btm "),
Span::styled("[a/r/p]", Style::default().fg(Color::Cyan)),
Span::raw(" filter "),
Span::styled("[I/Esc]", Style::default().fg(Color::Magenta)),
Span::raw(" back "),
]),
_ => Line::from(vec![
Span::styled(" [1-5]", Style::default().fg(Color::Cyan)),
Span::raw(" tab "),
Span::styled("[Tab]", Style::default().fg(Color::Cyan)),
Span::raw(" next "),
Span::styled("[↑↓]", Style::default().fg(Color::Cyan)),
Span::raw(" scroll "),
Span::styled("[I/Esc]", Style::default().fg(Color::Magenta)),
Span::raw(" back "),
]),
};
frame.render_widget(Paragraph::new(tab_hints), area);
}