use std::time::Duration;
use ratatui::{
Frame,
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
};
use crate::client::client::WhyBlocked;
use super::actions::ServiceDisplayInfo;
use super::app::{App, Focus, StatusLevel};
fn state_color(state: &str) -> Color {
match state {
"running" => Color::Green,
"starting" | "blocked" => Color::Yellow,
"stopping" => Color::Magenta,
"exited" => Color::Cyan,
"failed" => Color::Red,
"inactive" => Color::DarkGray,
_ => Color::DarkGray,
}
}
fn state_symbol(state: &str) -> &'static str {
match state {
"inactive" => "[-]",
"blocked" => "[?]",
"starting" => "[>]",
"running" => "[+]",
"stopping" | "exited" => "[.]",
"failed" => "[X]",
_ => "[?]",
}
}
pub fn draw(frame: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), Constraint::Min(10), Constraint::Length(3), ])
.split(frame.area());
draw_title(frame, app, chunks[0]);
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(30),
Constraint::Percentage(25),
Constraint::Percentage(45),
])
.split(chunks[1]);
draw_services(frame, app, main_chunks[0]);
draw_info_panel(frame, app, main_chunks[1]);
draw_logs(frame, app, main_chunks[2]);
draw_status_bar(frame, app, chunks[2]);
if app.show_help {
draw_help_popup(frame);
}
if app.show_detail {
if let Some(service) = app.selected_service() {
draw_detail_popup(frame, service);
}
}
if app.show_why {
if let Some(why) = &app.why_info {
draw_why_popup(frame, why);
}
}
}
fn draw_title(frame: &mut Frame, app: &App, area: Rect) {
let connection_indicator = if app.connected {
Span::styled(" [connected]", Style::default().fg(Color::Green))
} else {
Span::styled(
" [DISCONNECTED]",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)
};
let title = Paragraph::new(Line::from(vec![
Span::styled(
"zinit ",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::styled("TUI", Style::default().fg(Color::White)),
connection_indicator,
Span::raw(" | "),
Span::styled("?", Style::default().fg(Color::Yellow)),
Span::raw(" Help "),
Span::styled("w", Style::default().fg(Color::Yellow)),
Span::raw(" Why "),
Span::styled("Q", Style::default().fg(Color::Yellow)),
Span::raw(" Quit"),
]))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
);
frame.render_widget(title, area);
}
fn draw_services(frame: &mut Frame, app: &App, area: Rect) {
let items: Vec<ListItem> = app
.services
.iter()
.map(|service| {
let state_style = Style::default().fg(state_color(&service.state));
let content = Line::from(vec![
Span::styled(format!("{} ", state_symbol(&service.state)), state_style),
Span::styled(&service.name, Style::default().fg(Color::White)),
]);
ListItem::new(content)
})
.collect();
let border_color = if app.focus == Focus::Services {
Color::Cyan
} else {
Color::DarkGray
};
let services_list = List::new(items)
.block(
Block::default()
.title(format!(" Services ({}) ", app.services.len()))
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.highlight_style(
Style::default()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol("> ");
frame.render_stateful_widget(services_list, area, &mut app.selected.clone());
}
fn draw_info_panel(frame: &mut Frame, app: &App, area: Rect) {
let border_color = if app.focus == Focus::Deps {
Color::Cyan
} else {
Color::DarkGray
};
let info_lines: Vec<Line> = if let Some(service) = app.selected_service() {
let mut lines = vec![Line::from(vec![
Span::styled("State: ", Style::default().fg(Color::Yellow)),
Span::styled(
&service.state,
Style::default().fg(state_color(&service.state)),
),
])];
if service.pid > 0 {
lines.push(Line::from(vec![
Span::styled("PID: ", Style::default().fg(Color::Yellow)),
Span::styled(
format!("{}", service.pid),
Style::default().fg(Color::White),
),
]));
}
if let Some(code) = service.exit_code {
lines.push(Line::from(vec![
Span::styled("Exit code: ", Style::default().fg(Color::Yellow)),
Span::styled(format!("{}", code), Style::default().fg(Color::Cyan)),
]));
}
if let Some(ref err) = service.error {
lines.push(Line::from(vec![
Span::styled("Error: ", Style::default().fg(Color::Red)),
Span::styled(err, Style::default().fg(Color::White)),
]));
}
if !app.children.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![Span::styled(
format!("Children ({}):", app.children.len()),
Style::default().fg(Color::Magenta),
)]));
for child in &app.children {
let mem_mb = child.memory_bytes as f64 / (1024.0 * 1024.0);
lines.push(Line::from(vec![
Span::styled(
format!(" {} ", child.pid),
Style::default().fg(Color::White),
),
Span::styled(&child.name, Style::default().fg(Color::Gray)),
Span::styled(
format!(" ({:.1}MB)", mem_mb),
Style::default().fg(Color::DarkGray),
),
]));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Press 'w' to see blocking info",
Style::default().fg(Color::DarkGray),
)));
lines
} else {
vec![Line::from(Span::styled(
"No service selected",
Style::default().fg(Color::DarkGray),
))]
};
let info_widget = Paragraph::new(info_lines)
.block(
Block::default()
.title(" Service Info ")
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.wrap(Wrap { trim: false });
frame.render_widget(info_widget, area);
}
fn draw_logs(frame: &mut Frame, app: &App, area: Rect) {
let border_color = if app.focus == Focus::Logs {
Color::Cyan
} else {
Color::DarkGray
};
let service_name = app
.selected_service()
.map(|s| s.name.clone())
.unwrap_or_else(|| "none".to_string());
let visible_height = (area.height as usize).saturating_sub(2);
let total_logs = app.logs.len();
let scroll_offset = if app.following_logs {
total_logs.saturating_sub(visible_height)
} else {
app.log_scroll
.min(total_logs.saturating_sub(visible_height))
};
let visible_logs: Vec<Line> = app
.logs
.iter()
.skip(scroll_offset)
.take(visible_height)
.map(|line| {
let style = if line.contains("[ERR]")
|| line.contains("error")
|| line.contains("Error")
|| line.contains("ERROR")
{
Style::default().fg(Color::Red)
} else if line.contains("warn") || line.contains("Warn") || line.contains("WARN") {
Style::default().fg(Color::Yellow)
} else {
Style::default().fg(Color::Gray)
};
Line::from(Span::styled(line.clone(), style))
})
.collect();
let follow_indicator = if app.following_logs {
" [following]"
} else {
""
};
let scroll_info = format!(
" {}/{} ",
scroll_offset + visible_height.min(total_logs),
total_logs
);
let logs_widget = Paragraph::new(visible_logs)
.block(
Block::default()
.title(format!(" Logs: {}{} ", service_name, follow_indicator))
.title_bottom(Line::from(scroll_info).centered())
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.wrap(Wrap { trim: false });
frame.render_widget(logs_widget, area);
}
fn draw_status_bar(frame: &mut Frame, app: &App, area: Rect) {
let mut spans = Vec::new();
for op in &app.pending_operations {
let elapsed = op.started.elapsed().as_secs();
spans.push(Span::styled(
format!("{}({})...{}s ", op.action, op.service, elapsed),
Style::default().fg(Color::Yellow),
));
}
if let Some((msg, time, level)) = &app.status_message {
if time.elapsed() < Duration::from_secs(5) {
let color = match level {
StatusLevel::Info => Color::White,
StatusLevel::Success => Color::Green,
StatusLevel::Warning => Color::Yellow,
StatusLevel::Error => Color::Red,
};
spans.push(Span::styled(msg, Style::default().fg(color)));
}
}
if spans.is_empty() {
let service_info = app
.selected_service()
.map(|s| {
let pid_str = if s.pid > 0 {
format!(" PID:{}", s.pid)
} else {
String::new()
};
format!(
"{} {} {}{}",
state_symbol(&s.state),
s.name,
s.state,
pid_str
)
})
.unwrap_or_else(|| "No service selected".to_string());
let focus_indicator = match app.focus {
Focus::Services => "[Services]",
Focus::Deps => "[Deps]",
Focus::Logs => "[Logs]",
};
spans.push(Span::raw(format!(
"{} | {} | Tab: cycle focus",
service_info, focus_indicator
)));
}
let status = Paragraph::new(Line::from(spans))
.style(Style::default().fg(Color::White))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::DarkGray)),
);
frame.render_widget(status, area);
}
fn draw_help_popup(frame: &mut Frame) {
let area = frame.area();
let popup_area = Rect {
x: area.width / 6,
y: area.height / 6,
width: area.width * 2 / 3,
height: area.height * 2 / 3,
};
frame.render_widget(Clear, popup_area);
let help_text = vec![
Line::from(Span::styled(
"Keyboard Shortcuts",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![Span::styled(
"Navigation:",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::styled(" Up/k ", Style::default().fg(Color::Green)),
Span::raw("Move up in service list"),
]),
Line::from(vec![
Span::styled(" Down/j ", Style::default().fg(Color::Green)),
Span::raw("Move down in service list"),
]),
Line::from(vec![
Span::styled(" Tab ", Style::default().fg(Color::Green)),
Span::raw("Cycle focus: Services -> Deps -> Logs"),
]),
Line::from(vec![
Span::styled(" PgUp/PgDn ", Style::default().fg(Color::Green)),
Span::raw("Scroll logs"),
]),
Line::from(vec![
Span::styled(" g/G ", Style::default().fg(Color::Green)),
Span::raw("Go to top/bottom of logs"),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Actions:",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::styled(" s ", Style::default().fg(Color::Green)),
Span::raw("Start selected service"),
]),
Line::from(vec![
Span::styled(" S ", Style::default().fg(Color::Green)),
Span::raw("Stop selected service"),
]),
Line::from(vec![
Span::styled(" r ", Style::default().fg(Color::Green)),
Span::raw("Restart selected service"),
]),
Line::from(vec![
Span::styled(" K ", Style::default().fg(Color::Green)),
Span::raw("Kill with SIGKILL"),
]),
Line::from(vec![
Span::styled(" d ", Style::default().fg(Color::Green)),
Span::raw("Remove service"),
]),
Line::from(vec![
Span::styled(" D ", Style::default().fg(Color::Green)),
Span::raw("Halt & Remove (kill tree + remove)"),
]),
Line::from(vec![
Span::styled(" R ", Style::default().fg(Color::Green)),
Span::raw("Refresh service list"),
]),
Line::from(""),
Line::from(vec![Span::styled(
"Info:",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::styled(" w ", Style::default().fg(Color::Green)),
Span::raw("Show why service is blocked"),
]),
Line::from(vec![
Span::styled(" Enter ", Style::default().fg(Color::Green)),
Span::raw("Show service details"),
]),
Line::from(vec![
Span::styled(" ? ", Style::default().fg(Color::Green)),
Span::raw("Toggle this help"),
]),
Line::from(vec![
Span::styled(" Q/Esc ", Style::default().fg(Color::Green)),
Span::raw("Quit"),
]),
Line::from(""),
Line::from(Span::styled(
"Press any key to close",
Style::default().fg(Color::DarkGray),
)),
];
let help = Paragraph::new(help_text)
.block(
Block::default()
.title(" Help ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.style(Style::default().bg(Color::Black));
frame.render_widget(help, popup_area);
}
fn draw_detail_popup(frame: &mut Frame, service: &ServiceDisplayInfo) {
let area = frame.area();
let popup_area = Rect {
x: area.width / 6,
y: area.height / 6,
width: area.width * 2 / 3,
height: area.height * 2 / 3,
};
frame.render_widget(Clear, popup_area);
let state_col = state_color(&service.state);
let mut detail_text = vec![
Line::from(Span::styled(
format!(" {} ", service.name),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(vec![Span::styled(
"Status",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)]),
Line::from(vec![
Span::raw(" State: "),
Span::styled(
format!("{} {}", state_symbol(&service.state), &service.state),
Style::default().fg(state_col).add_modifier(Modifier::BOLD),
),
]),
];
if service.pid > 0 {
detail_text.push(Line::from(vec![
Span::raw(" PID: "),
Span::styled(service.pid.to_string(), Style::default().fg(Color::White)),
]));
}
if let Some(code) = service.exit_code {
detail_text.push(Line::from(vec![
Span::raw(" Exit: "),
Span::styled(format!("{}", code), Style::default().fg(Color::Cyan)),
]));
}
if let Some(ref err) = service.error {
detail_text.push(Line::from(""));
detail_text.push(Line::from(vec![Span::styled(
"Error",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)]));
detail_text.push(Line::from(vec![
Span::raw(" "),
Span::styled(err, Style::default().fg(Color::Red)),
]));
}
detail_text.push(Line::from(""));
detail_text.push(Line::from(Span::styled(
"Press Esc to close",
Style::default().fg(Color::DarkGray),
)));
let detail = Paragraph::new(detail_text)
.block(
Block::default()
.title(" Service Details ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Cyan)),
)
.style(Style::default().bg(Color::Black));
frame.render_widget(detail, popup_area);
}
fn draw_why_popup(frame: &mut Frame, why: &WhyBlocked) {
let area = frame.area();
let popup_area = Rect {
x: area.width / 8,
y: area.height / 8,
width: area.width * 3 / 4,
height: area.height * 3 / 4,
};
frame.render_widget(Clear, popup_area);
let mut lines = vec![Line::from(Span::styled(
"Why Blocked?",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))];
if !why.blocked {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Service is not blocked",
Style::default().fg(Color::Green),
)));
} else {
lines.push(Line::from(""));
for line in why.ascii.lines() {
lines.push(Line::from(Span::styled(
line.to_string(),
Style::default().fg(Color::White),
)));
}
if !why.waiting_on.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled("Waiting on: ", Style::default().fg(Color::Yellow)),
Span::styled(why.waiting_on.join(", "), Style::default().fg(Color::White)),
]));
}
if !why.conflicts_with.is_empty() {
lines.push(Line::from(vec![
Span::styled("Conflicts with: ", Style::default().fg(Color::Red)),
Span::styled(
why.conflicts_with.join(", "),
Style::default().fg(Color::White),
),
]));
}
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Press any key to close",
Style::default().fg(Color::DarkGray),
)));
let why_widget = Paragraph::new(lines)
.block(
Block::default()
.title(" Why Blocked? ")
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.style(Style::default().bg(Color::Black))
.wrap(Wrap { trim: false });
frame.render_widget(why_widget, popup_area);
}