use std::collections::{HashMap, VecDeque};
use std::time::Instant;
use ratatui::layout::Rect;
use super::LogEntry;
const MAX_LOG_LINES: usize = 2000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LogSource {
Daemon,
UiServer,
Build,
App,
System,
}
impl LogSource {
pub fn all() -> &'static [LogSource] {
&[
LogSource::System,
LogSource::Daemon,
LogSource::UiServer,
LogSource::Build,
LogSource::App,
]
}
pub fn tab_label(self) -> &'static str {
match self {
LogSource::System => "1:system",
LogSource::Daemon => "2:daemon",
LogSource::UiServer => "3:ui",
LogSource::Build => "4:build",
LogSource::App => "5: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,
}
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 ShutdownPhase {
Running,
ShuttingDown,
Done,
}
#[derive(Clone, Copy, Default)]
pub struct TuiLayout {
pub service_list: Rect,
pub log_scroll: 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>>>,
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>,
}
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(),
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,
}
}
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 instance_match: Option<(String, usize)> = self.instance_names.iter().find_map(|name| {
let prefix = format!("[{name}] ");
line.starts_with(&prefix).then(|| (name.clone(), prefix.len()))
});
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((inst_name, prefix_len)) = &instance_match {
let stripped = line[*prefix_len..].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() {
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 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.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 cycle_pane(&mut self, delta: i32) {
let sources = LogSource::all();
let pos = sources
.iter()
.position(|s| *s == self.active_pane)
.unwrap_or(0);
let next = ((pos as i32 + delta).rem_euclid(sources.len() as i32)) as usize;
self.active_pane = sources[next];
self.auto_scroll = true;
self.clear_search();
}
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")
}
}
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)
}