use std::collections::{HashMap, VecDeque};
use std::time::Instant;
use ratatui::layout::Rect;
use super::LogEntry;
const MAX_LOG_LINES: usize = 2000;
const MAX_INFRA_LOG_LINES: usize = 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiView {
Dev,
Infra,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InfraTab {
Status,
Graph,
Snapshot,
Db,
Logs,
}
impl InfraTab {
pub fn all() -> &'static [InfraTab] {
&[
InfraTab::Status,
InfraTab::Graph,
InfraTab::Snapshot,
InfraTab::Db,
InfraTab::Logs,
]
}
pub fn label(self) -> &'static str {
match self {
InfraTab::Status => "1:status",
InfraTab::Graph => "2:graph",
InfraTab::Snapshot => "3:snapshot",
InfraTab::Db => "4:db",
InfraTab::Logs => "5:logs",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum InfraHealthStatus {
Healthy,
Running,
Degraded,
Down,
External,
}
impl InfraHealthStatus {
pub fn indicator(&self) -> &'static str {
match self {
Self::Healthy => "✓",
Self::Running => "◐",
Self::Degraded => "⚠",
Self::Down => "✗",
Self::External => "⚡",
}
}
pub fn label(&self) -> &'static str {
match self {
Self::Healthy => "healthy",
Self::Running => "running",
Self::Degraded => "degraded",
Self::Down => "down",
Self::External => "external",
}
}
pub fn color(&self) -> ratatui::style::Color {
use ratatui::style::Color;
match self {
Self::Healthy => Color::Green,
Self::Running => Color::Yellow,
Self::Degraded => Color::Yellow,
Self::Down => Color::Red,
Self::External => Color::Cyan,
}
}
}
#[derive(Debug, Clone)]
pub struct InfraServiceHealth {
pub name: String,
pub status: InfraHealthStatus,
pub detail: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InfraLogFilter {
All,
Rgs,
Postgres,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum InfraLogService {
Rgs,
Postgres,
Other,
}
#[derive(Debug, Clone)]
pub struct InfraLogLine {
pub line: String,
pub service: InfraLogService,
}
#[derive(Debug, Clone)]
pub struct InfraChannelEntry {
pub scid: String,
pub node1: String,
}
#[derive(Debug, Clone, Default)]
pub struct InfraGraphData {
pub node_count: u64,
pub channel_count: u64,
pub channels: Vec<InfraChannelEntry>,
}
#[derive(Debug, Clone, Default)]
pub struct InfraDbSummary {
pub node_announcements: u64,
pub channel_announcements: u64,
pub channel_updates: u64,
pub config_rows: u64,
}
#[derive(Debug, Clone)]
pub struct InfraSnapshotInfo {
pub version: u8,
pub chain_hash: String,
#[allow(dead_code)]
pub timestamp: u32,
pub timestamp_str: String,
pub node_count: usize,
pub channel_count: usize,
pub update_count: usize,
}
pub struct InfraViewState {
pub active_tab: InfraTab,
pub services: Vec<InfraServiceHealth>,
pub rgs_url: String,
#[allow(dead_code)]
pub ln_peers: Vec<String>,
pub ln_network: String,
pub rgs_injected: bool,
pub graph: Option<InfraGraphData>,
pub snapshot: Option<InfraSnapshotInfo>,
pub db: Option<InfraDbSummary>,
pub graph_loading: bool,
pub snapshot_loading: bool,
pub db_loading: bool,
logs: VecDeque<InfraLogLine>,
pub log_filter: InfraLogFilter,
pub last_refresh: Option<Instant>,
pub scroll_pos: usize,
pub auto_scroll: bool,
}
impl InfraViewState {
pub fn new(
rgs_url: String,
ln_network: String,
ln_peers: Vec<String>,
rgs_injected: bool,
) -> Self {
Self {
active_tab: InfraTab::Status,
services: vec![],
rgs_url,
ln_network,
ln_peers,
rgs_injected,
graph: None,
snapshot: None,
db: None,
graph_loading: false,
snapshot_loading: false,
db_loading: false,
logs: VecDeque::new(),
log_filter: InfraLogFilter::All,
last_refresh: None,
scroll_pos: 0,
auto_scroll: true,
}
}
pub fn push_log(&mut self, entry: InfraLogLine) {
self.logs.push_back(entry);
if self.logs.len() > MAX_INFRA_LOG_LINES {
self.logs.pop_front();
}
if self.auto_scroll {
self.scroll_pos = self.visible_lines().len();
}
}
pub fn visible_lines(&self) -> Vec<&str> {
self.logs
.iter()
.filter(|e| match self.log_filter {
InfraLogFilter::All => true,
InfraLogFilter::Rgs => e.service == InfraLogService::Rgs,
InfraLogFilter::Postgres => e.service == InfraLogService::Postgres,
})
.map(|e| e.line.as_str())
.collect()
}
pub fn set_tab(&mut self, tab: InfraTab) {
self.active_tab = tab;
self.scroll_pos = 0;
self.auto_scroll = true;
}
pub fn cycle_tab(&mut self, delta: i32) {
let tabs = InfraTab::all();
let pos = tabs
.iter()
.position(|&t| t == self.active_tab)
.unwrap_or(0);
self.set_tab(
tabs[((pos as i32 + delta).rem_euclid(tabs.len() as i32)) as usize],
);
}
#[allow(dead_code)]
pub fn cycle_log_filter(&mut self) {
self.log_filter = match self.log_filter {
InfraLogFilter::All => InfraLogFilter::Rgs,
InfraLogFilter::Rgs => InfraLogFilter::Postgres,
InfraLogFilter::Postgres => InfraLogFilter::All,
};
self.scroll_pos = 0;
self.auto_scroll = true;
}
pub fn scroll_up(&mut self) {
if self.auto_scroll {
self.scroll_pos = self.visible_lines().len();
self.auto_scroll = false;
}
self.scroll_pos = self.scroll_pos.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
if !self.auto_scroll {
self.scroll_pos =
(self.scroll_pos + 1).min(self.visible_lines().len());
}
}
pub fn scroll_top(&mut self) {
self.auto_scroll = false;
self.scroll_pos = 0;
}
pub fn scroll_bottom(&mut self) {
self.auto_scroll = true;
}
pub fn logs_total(&self) -> usize {
self.logs.len()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LogSource {
Daemon,
UiServer,
Build,
App,
System,
}
impl LogSource {
pub fn tab_label(self) -> &'static str {
match self {
LogSource::System => "system",
LogSource::Daemon => "daemon",
LogSource::UiServer => "ui",
LogSource::Build => "build",
LogSource::App => "app",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum ServiceStatus {
Pending,
Building,
Starting,
Ready,
Watching,
Changed,
Loaded { reloads: usize },
Failed(String),
Disabled,
}
impl ServiceStatus {
pub fn indicator(&self) -> &'static str {
match self {
ServiceStatus::Ready | ServiceStatus::Loaded { .. } | ServiceStatus::Watching => "●",
ServiceStatus::Building | ServiceStatus::Starting | ServiceStatus::Changed => "◐",
ServiceStatus::Failed(_) => "✗",
ServiceStatus::Pending => "◌",
ServiceStatus::Disabled => "○",
}
}
pub fn color(&self) -> ratatui::style::Color {
use ratatui::style::Color;
match self {
ServiceStatus::Ready | ServiceStatus::Loaded { .. } | ServiceStatus::Watching => {
Color::Green
}
ServiceStatus::Building | ServiceStatus::Starting | ServiceStatus::Changed => {
Color::Yellow
}
ServiceStatus::Failed(_) => Color::Red,
ServiceStatus::Pending | ServiceStatus::Disabled => Color::DarkGray,
}
}
pub fn summary(&self) -> String {
match self {
ServiceStatus::Pending => "pending".into(),
ServiceStatus::Building => "building…".into(),
ServiceStatus::Starting => "starting…".into(),
ServiceStatus::Ready => "ready".into(),
ServiceStatus::Watching => "watching".into(),
ServiceStatus::Changed => "changed!".into(),
ServiceStatus::Loaded { reloads } => format!("loaded (×{reloads})"),
ServiceStatus::Failed(msg) => {
format!("FAILED: {}", msg.chars().take(18).collect::<String>())
}
ServiceStatus::Disabled => "disabled".into(),
}
}
}
#[derive(Clone)]
pub struct ServiceState {
pub label: &'static str,
pub status: ServiceStatus,
pub detail: String,
}
#[derive(Clone)]
pub struct AppStatus {
pub name: String,
pub status: ServiceStatus,
pub detail: String,
pub path: Option<std::path::PathBuf>,
}
pub struct Timings {
pub build_started_at: Option<Instant>,
pub last_build_ms: Option<u64>,
pub build_count: usize,
pub builtin_apps_build_ms: Option<u64>,
pub daemon_started_at: Option<Instant>,
pub platform_build_started_at: Option<Instant>,
pub platform_build_ms: Option<u64>,
pub last_reload_ms: Option<u64>,
}
impl Timings {
fn new() -> Self {
Self {
build_started_at: None,
last_build_ms: None,
build_count: 0,
builtin_apps_build_ms: None,
daemon_started_at: None,
platform_build_started_at: None,
platform_build_ms: None,
last_reload_ms: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusedPane {
Services,
Apps,
Nodes,
Logs,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShutdownPhase {
Running,
ShuttingDown,
Done,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum BuildScope {
Ui = 1,
Apps = 2,
System = 3,
All = 4,
}
impl BuildScope {
pub const ALL: [BuildScope; 4] = [
BuildScope::All,
BuildScope::System,
BuildScope::Apps,
BuildScope::Ui,
];
pub fn label(self) -> &'static str {
match self {
BuildScope::All => "All (apps + ui + system)",
BuildScope::System => "System (cargo build + daemon restart)",
BuildScope::Apps => "Apps (rebuild + restage node-app deps)",
BuildScope::Ui => "UI (restart Vite dev server)",
}
}
pub fn key_hint(self) -> char {
match self {
BuildScope::All => 'A',
BuildScope::System => 's',
BuildScope::Apps => 'a',
BuildScope::Ui => 'u',
}
}
pub fn from_u8(v: u8) -> Option<Self> {
match v {
1 => Some(BuildScope::Ui),
2 => Some(BuildScope::Apps),
3 => Some(BuildScope::System),
4 => Some(BuildScope::All),
_ => None,
}
}
}
#[derive(Debug, Clone)]
pub struct BuildDialog {
pub selected: usize,
}
impl BuildDialog {
pub fn new() -> Self {
Self { selected: 0 }
}
pub fn move_up(&mut self) {
if self.selected == 0 {
self.selected = BuildScope::ALL.len() - 1;
} else {
self.selected -= 1;
}
}
pub fn move_down(&mut self) {
self.selected = (self.selected + 1) % BuildScope::ALL.len();
}
pub fn current(&self) -> BuildScope {
BuildScope::ALL[self.selected.min(BuildScope::ALL.len() - 1)]
}
}
impl Default for BuildDialog {
fn default() -> Self {
Self::new()
}
}
#[derive(Clone, Copy, Default)]
pub struct TuiLayout {
pub service_list: Rect,
pub log_scroll: Rect,
pub app_list: Rect,
pub node_list: Rect,
}
#[derive(Clone, Copy, Debug)]
pub struct Selection {
pub anchor: usize,
pub head: usize,
}
impl Selection {
pub fn range(self) -> (usize, usize) {
(self.anchor.min(self.head), self.anchor.max(self.head))
}
pub fn contains(self, idx: usize) -> bool {
let (s, e) = self.range();
idx >= s && idx <= e
}
}
pub struct AppState {
pub app_name: String,
pub app_version: String,
services: Vec<(LogSource, ServiceState)>,
logs: HashMap<LogSource, VecDeque<String>>,
instance_logs: HashMap<String, HashMap<LogSource, VecDeque<String>>>,
node_logs: HashMap<String, HashMap<LogSource, VecDeque<String>>>,
pub active_pane: LogSource,
pub auto_scroll: bool,
pub scroll_pos: usize,
pub search_query: String,
pub search_input_active: bool,
pub last_render_height: usize,
pub shutdown_phase: ShutdownPhase,
pub timings: Timings,
pub layout: TuiLayout,
pub log_scroll_start: usize,
pub selection: Option<Selection>,
pub copy_flash: Option<std::time::Instant>,
pub instance_names: Vec<String>,
pub instance_filter: Option<usize>,
instance_services: HashMap<String, Vec<(LogSource, ServiceState)>>,
instance_timings: HashMap<String, Timings>,
pub view: TuiView,
pub infra: Option<InfraViewState>,
pub app_list: Vec<AppStatus>,
pub focused_pane: FocusedPane,
pub daemon_env_files: HashMap<String, std::path::PathBuf>,
pub node_list: Vec<AppStatus>,
pub node_filter: Option<usize>,
pub tracked_nodes: Vec<String>,
pub build_dialog: Option<BuildDialog>,
}
impl AppState {
pub fn new(app_name: String, app_version: String, instance_names: Vec<String>) -> Self {
let services = vec![
(
LogSource::Daemon,
ServiceState {
label: "daemon",
status: ServiceStatus::Pending,
detail: String::new(),
},
),
(
LogSource::UiServer,
ServiceState {
label: "ui-server",
status: ServiceStatus::Pending,
detail: String::new(),
},
),
(
LogSource::Build,
ServiceState {
label: "build",
status: ServiceStatus::Pending,
detail: String::new(),
},
),
(
LogSource::App,
ServiceState {
label: "app",
status: ServiceStatus::Pending,
detail: String::new(),
},
),
(
LogSource::System,
ServiceState {
label: "system",
status: ServiceStatus::Ready,
detail: String::new(),
},
),
];
let instance_services: HashMap<String, Vec<(LogSource, ServiceState)>> = instance_names
.iter()
.map(|name| (name.clone(), services.iter().map(|(s, v)| (*s, v.clone())).collect()))
.collect();
let instance_timings: HashMap<String, Timings> = instance_names
.iter()
.map(|name| (name.clone(), Timings::new()))
.collect();
Self {
app_name,
app_version,
services,
logs: HashMap::new(),
instance_logs: HashMap::new(),
node_logs: HashMap::new(),
active_pane: LogSource::System,
auto_scroll: true,
scroll_pos: 0,
search_query: String::new(),
search_input_active: false,
last_render_height: 24,
shutdown_phase: ShutdownPhase::Running,
timings: Timings::new(),
layout: TuiLayout::default(),
log_scroll_start: 0,
selection: None,
copy_flash: None,
instance_names,
instance_filter: None,
instance_services,
instance_timings,
view: TuiView::Dev,
infra: None,
app_list: Vec::new(),
focused_pane: FocusedPane::Logs,
daemon_env_files: HashMap::new(),
node_list: Vec::new(),
node_filter: None,
tracked_nodes: Vec::new(),
build_dialog: None,
}
}
pub fn seed_node_list(&mut self, names: &[String]) {
self.tracked_nodes = names.to_vec();
self.node_list = names
.iter()
.map(|n| AppStatus {
name: n.clone(),
status: ServiceStatus::Pending,
detail: String::new(),
path: None,
})
.collect();
if !self.node_list.is_empty() {
self.node_filter = Some(0);
}
}
pub fn focus_next_node(&mut self, delta: i32) {
let n = self.node_list.len();
if n == 0 {
return;
}
let cur = self.node_filter.unwrap_or(0);
let next = ((cur as i32 + delta).rem_euclid(n as i32)) as usize;
self.node_filter = Some(next);
self.scroll_pos = 0;
self.auto_scroll = true;
self.selection = None;
}
pub fn focused_node_name(&self) -> Option<&str> {
self.node_filter
.and_then(|i| self.node_list.get(i))
.map(|a| a.name.as_str())
}
pub fn set_daemon_env_file(&mut self, instance: &str, path: std::path::PathBuf) {
self.daemon_env_files.insert(instance.to_string(), path);
}
pub fn active_daemon_env_file(&self) -> Option<&std::path::Path> {
if let Some(name) = self.focused_node_name() {
if let Some(p) = self.daemon_env_files.get(name) {
return Some(p.as_path());
}
}
if let Some(name) = self.instance_filter_label() {
if let Some(p) = self.daemon_env_files.get(name) {
return Some(p.as_path());
}
}
self.daemon_env_files.values().next().map(|p| p.as_path())
}
pub fn cycle_focus(&mut self, delta: i32) {
let mut panes: Vec<FocusedPane> = vec![FocusedPane::Services];
if !self.app_list.is_empty() {
panes.push(FocusedPane::Apps);
}
if !self.node_list.is_empty() {
panes.push(FocusedPane::Nodes);
}
panes.push(FocusedPane::Logs);
let cur = panes
.iter()
.position(|p| *p == self.focused_pane)
.unwrap_or(0);
let next_idx = ((cur as i32 + delta).rem_euclid(panes.len() as i32)) as usize;
self.focused_pane = panes[next_idx];
self.auto_scroll = true;
self.scroll_pos = 0;
self.selection = None;
}
pub fn focus_next_service(&mut self, delta: i32) {
let hide_app = !self.app_list.is_empty();
let visible: Vec<LogSource> = self
.services
.iter()
.map(|(s, _)| *s)
.filter(|s| !(hide_app && *s == LogSource::App))
.collect();
if visible.is_empty() {
return;
}
let cur = visible
.iter()
.position(|s| *s == self.active_pane)
.unwrap_or(0);
let next_idx = ((cur as i32 + delta).rem_euclid(visible.len() as i32)) as usize;
self.active_pane = visible[next_idx];
self.auto_scroll = true;
self.scroll_pos = 0;
self.selection = None;
self.clear_search();
}
pub fn seed_app_list(&mut self, names: &[String]) {
self.app_list = names
.iter()
.map(|n| AppStatus {
name: n.clone(),
status: ServiceStatus::Pending,
detail: String::new(),
path: None,
})
.collect();
if !self.app_list.is_empty() {
self.focused_pane = FocusedPane::Apps;
}
}
pub fn set_app_status(&mut self, name: &str, status: ServiceStatus, detail: Option<String>) {
let detail = detail.unwrap_or_default();
if let Some(entry) = self.app_list.iter_mut().find(|a| a.name == name) {
entry.status = status;
entry.detail = detail;
} else {
self.app_list.push(AppStatus {
name: name.to_string(),
status,
detail,
path: None,
});
}
}
pub fn set_app_path(&mut self, name: &str, path: std::path::PathBuf) {
if let Some(entry) = self.app_list.iter_mut().find(|a| a.name == name) {
entry.path = Some(path);
} else {
self.app_list.push(AppStatus {
name: name.to_string(),
status: ServiceStatus::Pending,
detail: String::new(),
path: Some(path),
});
}
}
pub fn app_log_count(&self, name: &str) -> usize {
self.instance_logs
.get(name)
.and_then(|m| m.get(&LogSource::App))
.map(|v| v.len())
.unwrap_or(0)
}
pub fn focus_next_app(&mut self, delta: i32) {
let n = self.app_list.len();
if n == 0 {
return;
}
let current_idx = self.instance_filter_label()
.and_then(|name| self.app_list.iter().position(|a| a.name == name))
.unwrap_or(0);
let next_idx = ((current_idx as i32 + delta).rem_euclid(n as i32)) as usize;
let next_name = &self.app_list[next_idx].name;
if let Some(inst_idx) = self.instance_names.iter().position(|n| n == next_name) {
self.instance_filter = Some(inst_idx);
self.active_pane = LogSource::App;
self.auto_scroll = true;
self.scroll_pos = 0;
self.selection = None;
}
}
pub fn toggle_view(&mut self) {
if self.infra.is_none() {
return;
}
self.view = match self.view {
TuiView::Dev => TuiView::Infra,
TuiView::Infra => TuiView::Dev,
};
}
pub fn instance_filter_label(&self) -> Option<&str> {
self.instance_filter
.and_then(|i| self.instance_names.get(i))
.map(String::as_str)
}
pub fn cycle_instance_filter(&mut self) {
if self.instance_names.len() < 2 {
return;
}
self.instance_filter = match self.instance_filter {
None => Some(0),
Some(i) if i + 1 < self.instance_names.len() => Some(i + 1),
Some(_) => None,
};
self.scroll_pos = 0;
self.auto_scroll = true;
}
pub fn push_log(&mut self, entry: LogEntry) {
let line = strip_ansi(&entry.line);
if line.is_empty() {
return;
}
let (node_match, instance_match) = peel_prefixes(
&line,
&self.tracked_nodes,
&self.instance_names,
);
fn push_capped(buf: &mut VecDeque<String>, line: String) {
buf.push_back(line);
if buf.len() > MAX_LOG_LINES {
buf.pop_front();
}
}
push_capped(self.logs.entry(entry.source).or_default(), line.clone());
if let Some((node_name, node_prefix_len)) = &node_match {
let stripped = line[*node_prefix_len..].to_string();
push_capped(
self.node_logs
.entry(node_name.clone()).or_default()
.entry(entry.source).or_default(),
stripped,
);
} else if !self.tracked_nodes.is_empty() {
let names: Vec<String> = self.tracked_nodes.clone();
for name in names {
push_capped(
self.node_logs
.entry(name).or_default()
.entry(entry.source).or_default(),
line.clone(),
);
}
}
if let Some((inst_name, end_offset)) = &instance_match {
let stripped = line[*end_offset..].to_string();
push_capped(
self.instance_logs
.entry(inst_name.clone()).or_default()
.entry(entry.source).or_default(),
stripped,
);
} else if !self.instance_names.is_empty() && node_match.is_none() {
let names: Vec<String> = self.instance_names.clone();
for name in names {
push_capped(
self.instance_logs
.entry(name).or_default()
.entry(entry.source).or_default(),
line.clone(),
);
}
}
if self.auto_scroll && entry.source == self.active_pane && self.search_query.is_empty() {
let len = self.log_lines(entry.source).len();
self.scroll_pos = len;
}
}
pub fn update_service(
&mut self,
source: LogSource,
status: ServiceStatus,
detail: Option<String>,
) {
match (&source, &status) {
(LogSource::Build, ServiceStatus::Building) => {
self.timings.build_started_at = Some(Instant::now());
}
(LogSource::Build, ServiceStatus::Ready) => {
let is_builtins = detail.as_deref()
.map(|d| d.starts_with("builtins:"))
.unwrap_or(false);
if is_builtins {
if let Some(ms) = detail.as_deref()
.and_then(|d| d.strip_prefix("builtins:"))
.and_then(|r| r.strip_suffix("ms"))
.and_then(|n| n.parse::<u64>().ok())
{
self.timings.builtin_apps_build_ms = Some(ms);
}
} else if let Some(started) = self.timings.build_started_at.take() {
self.timings.last_build_ms = Some(started.elapsed().as_millis() as u64);
self.timings.build_count += 1;
}
}
(LogSource::Build, ServiceStatus::Failed(_)) => {
if let Some(started) = self.timings.build_started_at.take() {
self.timings.last_build_ms = Some(started.elapsed().as_millis() as u64);
self.timings.build_count += 1;
}
}
(LogSource::Daemon, ServiceStatus::Building) => {
self.timings.platform_build_started_at = Some(Instant::now());
}
(LogSource::Daemon, ServiceStatus::Starting) => {
if let Some(started) = self.timings.platform_build_started_at.take() {
self.timings.platform_build_ms = Some(started.elapsed().as_millis() as u64);
}
self.timings.daemon_started_at = Some(Instant::now());
}
(LogSource::App, ServiceStatus::Loaded { .. }) => {
if let Some(d) = &detail {
if let Some(rest) = d.strip_prefix("last reload: ") {
if let Some(ms_str) = rest.strip_suffix("ms") {
if let Ok(ms) = ms_str.parse::<u64>() {
self.timings.last_reload_ms = Some(ms);
}
}
}
}
}
_ => {}
}
let instance_name = detail.as_deref().and_then(|d| {
let inner = d.strip_prefix('[')?;
let (name, _) = inner.split_once(']')?;
if self.instance_names.iter().any(|n| n == name) {
Some(name.to_string())
} else {
None
}
});
if matches!(source, LogSource::Daemon | LogSource::UiServer) {
if let Some(d) = detail.as_deref() {
if let Some((node_name, rest)) =
d.strip_prefix('[').and_then(|inner| inner.split_once(']'))
{
if self.tracked_nodes.iter().any(|n| n == node_name) {
if let Some(entry) =
self.node_list.iter_mut().find(|a| a.name == node_name)
{
if source == LogSource::Daemon {
entry.status = status.clone();
}
entry.detail = rest.trim_start().to_string();
}
}
}
}
}
if let Some((_, svc)) = self.services.iter_mut().find(|(s, _)| *s == source) {
svc.status = status.clone();
if let Some(d) = detail.as_ref() {
svc.detail = d.clone();
}
}
match &instance_name {
Some(name) => {
if let Some(inst_svcs) = self.instance_services.get_mut(name) {
if let Some((_, svc)) = inst_svcs.iter_mut().find(|(s, _)| *s == source) {
svc.status = status;
if let Some(d) = detail {
let prefix = format!("[{name}] ");
svc.detail = d.strip_prefix(&prefix).unwrap_or(&d).to_string();
}
}
}
}
None => {
let names: Vec<String> = self.instance_names.clone();
for inst_name in &names {
if let Some(inst_svcs) = self.instance_services.get_mut(inst_name) {
if let Some((_, svc)) = inst_svcs.iter_mut().find(|(s, _)| *s == source) {
svc.status = status.clone();
if let Some(ref d) = detail {
svc.detail = d.clone();
}
}
}
}
}
}
}
pub fn active_services(&self) -> &[(LogSource, ServiceState)] {
if let Some(name) = self.instance_filter_label() {
if let Some(svcs) = self.instance_services.get(name) {
return svcs.as_slice();
}
}
&self.services
}
pub fn active_timings(&self) -> &Timings {
if let Some(name) = self.instance_filter_label() {
if let Some(t) = self.instance_timings.get(name) {
return t;
}
}
&self.timings
}
pub fn log_lines(&self, source: LogSource) -> &VecDeque<String> {
static EMPTY: std::sync::OnceLock<VecDeque<String>> = std::sync::OnceLock::new();
let empty = EMPTY.get_or_init(VecDeque::new);
if let Some(name) = self.focused_node_name() {
return self.node_logs
.get(name)
.and_then(|m| m.get(&source))
.unwrap_or(empty);
}
if let Some(name) = self.instance_filter_label() {
return self.instance_logs
.get(name)
.and_then(|m| m.get(&source))
.unwrap_or(empty);
}
self.logs.get(&source).unwrap_or(empty)
}
pub fn visible_start_for(&self, total: usize, height: usize) -> usize {
if self.auto_scroll {
total.saturating_sub(height)
} else {
self.scroll_pos.min(total.saturating_sub(height))
}
}
pub fn scroll_up(&mut self) {
if self.auto_scroll {
let total = self.log_lines(self.active_pane).len();
self.scroll_pos = total;
self.auto_scroll = false;
}
self.scroll_pos = self.scroll_pos.saturating_sub(1);
}
pub fn scroll_down(&mut self) {
if !self.auto_scroll {
let total = self.log_lines(self.active_pane).len();
self.scroll_pos = (self.scroll_pos + 1).min(total);
}
}
pub fn page_up(&mut self) {
let step = (self.last_render_height / 2).max(1);
if self.auto_scroll {
let total = self.log_lines(self.active_pane).len();
self.scroll_pos = total;
self.auto_scroll = false;
}
self.scroll_pos = self.scroll_pos.saturating_sub(step);
}
pub fn page_down(&mut self) {
if !self.auto_scroll {
let step = (self.last_render_height / 2).max(1);
let total = self.log_lines(self.active_pane).len();
self.scroll_pos = (self.scroll_pos + step).min(total);
}
}
pub fn scroll_top(&mut self) {
self.auto_scroll = false;
self.scroll_pos = 0;
}
pub fn scroll_bottom(&mut self) {
self.auto_scroll = true;
}
pub fn clear_active_pane(&mut self) {
if let Some(name) = self.instance_filter_label().map(str::to_owned) {
if let Some(m) = self.instance_logs.get_mut(&name) {
m.remove(&self.active_pane);
}
} else {
self.logs.remove(&self.active_pane);
}
self.scroll_pos = 0;
self.auto_scroll = true;
self.clear_search();
}
pub fn enter_search(&mut self) {
self.search_input_active = true;
}
pub fn exit_search_input(&mut self) {
self.search_input_active = false;
}
pub fn clear_search(&mut self) {
self.search_query.clear();
self.search_input_active = false;
self.instance_filter = None;
self.auto_scroll = true;
}
pub fn search_push(&mut self, c: char) {
self.search_query.push(c);
self.scroll_pos = 0;
self.auto_scroll = false;
}
pub fn search_backspace(&mut self) {
self.search_query.pop();
self.scroll_pos = 0;
if self.search_query.is_empty() {
self.auto_scroll = true;
}
}
pub fn matches(&self, line: &str) -> bool {
fuzzy_match(line, &self.search_query)
}
pub fn match_positions(&self, line: &str) -> Option<Vec<usize>> {
if self.search_query.is_empty() {
return None;
}
fuzzy_positions(line, &self.search_query)
}
pub fn get_selected_text(&self) -> String {
let sel = match self.selection {
Some(s) => s,
None => return String::new(),
};
let (start, end) = sel.range();
self.log_lines(self.active_pane)
.iter()
.enumerate()
.skip(start)
.take_while(|(i, _)| *i <= end)
.map(|(_, l)| l.as_str())
.collect::<Vec<_>>()
.join("\n")
}
}
pub fn nth_visible_service(app: &AppState, n: usize) -> Option<LogSource> {
let hide_app = !app.app_list.is_empty();
app.active_services()
.iter()
.map(|(s, _)| *s)
.filter(|s| !(hide_app && *s == LogSource::App))
.nth(n)
}
impl AppState {
pub fn service_number(&self, source: LogSource) -> Option<usize> {
let hide_app = !self.app_list.is_empty();
self.active_services()
.iter()
.map(|(s, _)| *s)
.filter(|s| !(hide_app && *s == LogSource::App))
.position(|s| s == source)
.map(|i| i + 1)
}
pub fn numbered_label(&self, source: LogSource) -> String {
let label = source.tab_label();
match self.service_number(source) {
Some(n) => format!("{n}:{label}"),
None => label.to_string(),
}
}
}
fn peel_one_bracket_prefix(line: &str) -> Option<(&str, usize)> {
let rest = line.strip_prefix('[')?;
let close_idx = rest.find(']')?;
let name = &rest[..close_idx];
let after_close = 1 + close_idx + 1; let mut chars = line[after_close..].chars();
if chars.next() != Some(' ') {
return None;
}
Some((name, after_close + 1))
}
pub type PrefixMatch = Option<(String, usize)>;
fn peel_prefixes(
line: &str,
tracked_nodes: &[String],
tracked_instances: &[String],
) -> (PrefixMatch, PrefixMatch) {
let mut node_match = None;
let mut instance_match = None;
if let Some((name, after_idx)) = peel_one_bracket_prefix(line) {
if tracked_nodes.iter().any(|n| n == name) {
node_match = Some((name.to_string(), after_idx));
if let Some((name2, after_idx2)) = peel_one_bracket_prefix(&line[after_idx..]) {
if tracked_instances.iter().any(|n| n == name2) {
instance_match = Some((name2.to_string(), after_idx + after_idx2));
}
}
} else if tracked_instances.iter().any(|n| n == name) {
instance_match = Some((name.to_string(), after_idx));
}
}
(node_match, instance_match)
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut it = s.chars().peekable();
while let Some(c) = it.next() {
match c {
'\x1b' => match it.peek() {
Some('[') => {
it.next();
for ch in it.by_ref() {
if ch.is_ascii() && (0x40..=0x7e).contains(&(ch as u8)) {
break;
}
}
}
Some(']') => {
it.next();
loop {
match it.next() {
None | Some('\x07') => break,
Some('\x1b') => {
if it.peek() == Some(&'\\') {
it.next();
}
break;
}
_ => {}
}
}
}
_ => {}
},
'\r' => out.clear(),
_ => out.push(c),
}
}
out
}
pub fn fuzzy_match(line: &str, query: &str) -> bool {
if query.is_empty() {
return true;
}
let mut line_chars = line.chars().flat_map(char::to_lowercase);
query
.chars()
.flat_map(char::to_lowercase)
.all(|q| line_chars.any(|c| c == q))
}
pub fn fuzzy_positions(line: &str, query: &str) -> Option<Vec<usize>> {
if query.is_empty() {
return Some(vec![]);
}
let line_chars: Vec<char> = line.chars().collect();
let query_lower: Vec<char> = query.chars().flat_map(char::to_lowercase).collect();
let mut positions = Vec::with_capacity(query_lower.len());
let mut li = 0usize;
for qc in &query_lower {
let found = line_chars[li..].iter().enumerate().find(|(_, lc)| {
lc.to_lowercase().next() == Some(*qc)
});
match found {
Some((offset, _)) => {
positions.push(li + offset);
li += offset + 1;
}
None => return None,
}
}
Some(positions)
}