use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
symbols::Marker,
text::{Line, Span},
widgets::{
Axis, Block, BorderType, Borders, Cell, Chart, Clear, Dataset, GraphType, List, ListItem,
Padding, Paragraph, Row, Table, Wrap,
},
Frame,
};
use crate::aws::Environment;
use crate::theme::{IconStyle, Theme};
use crate::app::{
Action, ActionFlow, App, ConfirmKind, DetailTab, DisplayRow, LoadState, Mode, Overlay, Scope,
SortKey, ToastKind, ViewMode, ACTIONS,
};
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const ASCII_SPINNER: &[&str] = &["|", "/", "-", "\\"];
fn rounded_block(theme: &Theme, active: bool) -> Block<'static> {
let color = if active {
theme.border_active
} else {
theme.border_idle
};
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(color))
}
fn titled_block(theme: &Theme, raw_title: &str, active: bool, accent: Color) -> Block<'static> {
let trimmed = raw_title.trim();
let decorated = match theme.icons {
IconStyle::Ascii => format!("[ {trimmed} ]"),
IconStyle::Powerline => format!(" {trimmed} "),
IconStyle::Unicode => format!("[ ◆ {trimmed} ◆ ]"),
};
rounded_block(theme, active).title(Span::styled(
decorated,
Style::default().fg(accent).add_modifier(Modifier::BOLD),
))
}
fn pill(text: &str, fg: Color, bg: Color) -> Span<'static> {
Span::styled(
format!(" {text} "),
Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD),
)
}
fn pill_chain(items: &[(String, Color, Color)], theme: &Theme) -> Vec<Span<'static>> {
if items.is_empty() {
return Vec::new();
}
let powerline = theme.icons == IconStyle::Powerline;
let mut spans: Vec<Span<'static>> = Vec::with_capacity(items.len() * 2 + 1);
if powerline {
let (_, _, first_bg) = items[0];
spans.push(Span::styled("\u{e0b2}", Style::default().fg(first_bg)));
for (i, (text, fg, bg)) in items.iter().enumerate() {
spans.push(Span::styled(
format!(" {text} "),
Style::default()
.fg(*fg)
.bg(*bg)
.add_modifier(Modifier::BOLD),
));
let bridge_style = if let Some(next) = items.get(i + 1) {
Style::default().fg(*bg).bg(next.2)
} else {
Style::default().fg(*bg)
};
spans.push(Span::styled("\u{e0b0}", bridge_style));
}
} else {
for (i, (text, fg, bg)) in items.iter().enumerate() {
if i > 0 {
spans.push(sep(theme));
}
spans.push(Span::styled(
format!(" {text} "),
Style::default()
.fg(*fg)
.bg(*bg)
.add_modifier(Modifier::BOLD),
));
}
}
spans
}
fn build_chain_pills(app: &App) -> Vec<(String, Color, Color)> {
let theme = &app.theme;
let fg = |bg: Color| theme.contrast_text(bg);
let mut chain: Vec<(String, Color, Color)> = Vec::new();
if app.alerts > 0 {
chain.push((
format!(
"! {} alert{}",
app.alerts,
if app.alerts == 1 { "" } else { "s" }
),
fg(theme.health_red),
theme.health_red,
));
}
if let Some(pd) = app.pending_dispatch.as_ref() {
let now = std::time::Instant::now();
let remaining = pd.deadline.saturating_duration_since(now).as_secs() + 1;
chain.push((
format!("{} {}s — U undo", pd.action_label, remaining),
fg(theme.health_red),
theme.health_red,
));
}
let in_flight: Vec<&str> = app
.pending_actions
.iter()
.filter(|e| e.completed.is_none())
.map(|e| e.label.as_str())
.collect();
if !in_flight.is_empty() {
chain.push((
format!(
"{}{}",
pending_glyph(theme),
summarize_in_flight(&in_flight)
),
fg(theme.health_yellow),
theme.health_yellow,
));
}
let n_selected = app.multi_selected.len();
if n_selected > 0 {
chain.push((
format!("{}{n_selected} selected", multi_select_glyph(theme)),
fg(theme.title),
theme.title,
));
}
if app.read_only {
chain.push((
"READ-ONLY".into(),
fg(theme.health_green),
theme.health_green,
));
}
if let Some(release) = app.update_available.as_ref() {
chain.push((
format!("UPDATE {} (:update)", release.version),
fg(theme.title_alt),
theme.title_alt,
));
}
if let Some(exp) = app.sso_expiry {
let remaining = exp.signed_duration_since(chrono::Utc::now());
if remaining > chrono::Duration::seconds(0) {
let mins = remaining.num_minutes();
let label = if mins >= 60 {
format!("SSO {}h", remaining.num_hours())
} else {
format!("SSO {mins}m")
};
let bg = if mins < 15 {
theme.health_red
} else if mins < 60 {
theme.health_yellow
} else {
theme.health_grey
};
chain.push((label, fg(bg), bg));
}
}
if app.frozen {
let stale = app
.last_refresh
.map(|t| chrono::Utc::now().signed_duration_since(t) >= chrono::Duration::minutes(5))
.unwrap_or(false);
let bg = if stale {
theme.health_yellow
} else {
theme.health_grey
};
let label = if stale {
"FROZEN (stale)".to_string()
} else {
"FROZEN".to_string()
};
chain.push((label, fg(bg), bg));
}
if app.redact {
chain.push((
"REDACT".into(),
fg(theme.health_yellow),
theme.health_yellow,
));
}
if app.grouped {
chain.push(("GROUPED".into(), fg(theme.title_alt), theme.title_alt));
}
match app.view_mode {
ViewMode::Compact => {
chain.push(("COMPACT".into(), fg(theme.accent), theme.accent));
}
ViewMode::Spacious => {
chain.push(("SPACIOUS".into(), fg(theme.accent), theme.accent));
}
ViewMode::Default => {}
}
chain
}
fn pending_glyph(theme: &Theme) -> &'static str {
match theme.icons {
IconStyle::Ascii => "* ",
_ => "⏳ ",
}
}
fn multi_select_glyph(theme: &Theme) -> &'static str {
match theme.icons {
IconStyle::Ascii => "+ ",
_ => "▶ ",
}
}
fn header_layout(app: &App, area_width: u16) -> (u16, bool) {
let col0 = (area_width as u32 * 60 / 100) as u16;
let inner = col0.saturating_sub(2) as usize;
let mut pills = build_chain_pills(app);
prune_pills_to_width(&mut pills, &app.theme, inner);
let chain_spans = pill_chain(&pills, &app.theme);
let chain_w: usize = chain_spans.iter().map(|s| s.width()).sum();
let info_w = estimated_info_row_width(app);
header_dimensions(info_w, chain_w, inner, !app.named_filters.is_empty())
}
fn prune_pills_to_width(pills: &mut Vec<(String, Color, Color)>, theme: &Theme, max_w: usize) {
if pills.is_empty() {
return;
}
let measure = |slice: &[(String, Color, Color)]| -> usize {
pill_chain(slice, theme).iter().map(|s| s.width()).sum()
};
let original_len = pills.len();
while pills.len() > 1 && measure(pills) > max_w {
pills.pop();
}
if pills.len() < original_len {
let hidden = original_len - pills.len();
if let Some(last) = pills.last_mut() {
last.0 = format!("{} +{hidden}", last.0);
}
}
}
fn header_dimensions(
info_row_w: usize,
chain_w: usize,
inner_w: usize,
has_filters: bool,
) -> (u16, bool) {
let gap = 2usize;
let pills_present = chain_w > 0;
let merge_pills = pills_present && info_row_w + gap + chain_w <= inner_w;
let pill_row = pills_present && !merge_pills;
let rows = 2 + 3 + (if pill_row { 1 } else { 0 }) + (if has_filters { 1 } else { 0 });
(rows as u16, merge_pills)
}
fn estimated_info_row_width(app: &App) -> usize {
const STATUS_SLOT: usize = 10;
let sep_w = 5; let sort_dir = if app.sort_desc { "↓" } else { "↑" };
let sort_label = format!("{}{}", app.sort_key.label(), sort_dir);
let env_count = app.environments.len().to_string();
let caller = redact(
&app.context
.caller_arn
.as_deref()
.map(short_caller)
.unwrap_or_else(|| "—".into()),
app.redact,
);
let last = format_refresh_label(app.last_refresh, chrono::Utc::now(), app.refresh_interval);
let mut w = "Sort: ".chars().count() + sort_label.chars().count();
w += sep_w + "Status: ".chars().count() + STATUS_SLOT;
w += sep_w + "Envs: ".chars().count() + env_count.chars().count();
for (bucket, delta) in app.health_delta.iter().chain(app.status_delta.iter()) {
if *delta == 0 {
continue;
}
w += 1 + 1 + delta.abs().to_string().chars().count() + 1 + bucket.chars().count();
}
w += sep_w + "Last: ".chars().count() + last.chars().count();
w += sep_w + "Caller: ".chars().count() + caller.chars().count();
if !app.filter.is_empty() {
w += sep_w + "Filter: ".chars().count() + app.filter.chars().count();
}
w
}
fn health_dot(health: &str, theme: &Theme) -> Span<'static> {
let c = health_color(health, theme);
let glyph = match theme.icons {
IconStyle::Ascii => "*",
IconStyle::Powerline => "\u{f111}",
IconStyle::Unicode => "●",
};
Span::styled(glyph, Style::default().fg(c).add_modifier(Modifier::BOLD))
}
fn spinner(elapsed_ms: u128, icons: IconStyle) -> &'static str {
match icons {
IconStyle::Unicode | IconStyle::Powerline => {
SPINNER_FRAMES[(elapsed_ms / 100) as usize % SPINNER_FRAMES.len()]
}
IconStyle::Ascii => ASCII_SPINNER[(elapsed_ms / 100) as usize % ASCII_SPINNER.len()],
}
}
fn tab_icon(t: DetailTab, icons: IconStyle) -> &'static str {
match (icons, t) {
(IconStyle::Unicode, DetailTab::Health) => "♥",
(IconStyle::Unicode, DetailTab::Events) => "⚡",
(IconStyle::Unicode, DetailTab::Instances) => "▣",
(IconStyle::Unicode, DetailTab::Metrics) => "▆",
(IconStyle::Unicode, DetailTab::Queue) => "✉",
(IconStyle::Unicode, DetailTab::Logs) => "≣",
(IconStyle::Unicode, DetailTab::Config) => "⚙",
(IconStyle::Powerline, DetailTab::Health) => "\u{f02d1}", (IconStyle::Powerline, DetailTab::Events) => "\u{f0e7}", (IconStyle::Powerline, DetailTab::Instances) => "\u{f048b}", (IconStyle::Powerline, DetailTab::Metrics) => "\u{f0680}", (IconStyle::Powerline, DetailTab::Queue) => "\u{f01ee}", (IconStyle::Powerline, DetailTab::Logs) => "\u{f021a}", (IconStyle::Powerline, DetailTab::Config) => "\u{f0493}", (IconStyle::Ascii, DetailTab::Health) => "H",
(IconStyle::Ascii, DetailTab::Events) => "E",
(IconStyle::Ascii, DetailTab::Instances) => "I",
(IconStyle::Ascii, DetailTab::Metrics) => "M",
(IconStyle::Ascii, DetailTab::Queue) => "Q",
(IconStyle::Ascii, DetailTab::Logs) => "L",
(IconStyle::Ascii, DetailTab::Config) => "C",
}
}
fn micro_bar(value: i64, max: i64, width: usize) -> String {
if max <= 0 || width == 0 || value < 0 {
return String::new();
}
let frac = (value as f64 / max as f64).clamp(0.0, 1.0);
let total_eighths = (frac * (width as f64) * 8.0).round() as usize;
let full = total_eighths / 8;
let rem = total_eighths % 8;
let mut out = String::new();
for _ in 0..full.min(width) {
out.push('█');
}
if full < width && rem > 0 {
out.push(match rem {
1 => '▏',
2 => '▎',
3 => '▍',
4 => '▌',
5 => '▋',
6 => '▊',
7 => '▉',
_ => ' ',
});
}
out
}
const SPARKLINE_WIDTH: usize = 10;
const DIVIDER_FILL_WIDTH: usize = 200;
pub fn draw(f: &mut Frame, app: &mut App) {
if app.mode == Mode::Shell && app.current_shell.is_some() {
draw_shell(f, f.area(), app);
return;
}
if app.dlq.is_some() {
draw_dlq(f, f.area(), app);
} else if app.detail.is_some() {
draw_detail(f, f.area(), app);
} else {
let events_height: u16 = if app.events_visible {
app.events_panel_height
} else {
0
};
let (header_height, merge_pills) = header_layout(app, f.area().width);
let mut constraints: Vec<Constraint> =
vec![Constraint::Length(header_height), Constraint::Min(3)];
if events_height > 0 {
constraints.push(Constraint::Length(events_height));
}
constraints.push(Constraint::Length(2));
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(f.area());
draw_header(f, chunks[0], app, merge_pills);
match app.scope {
Scope::Envs => draw_table(f, chunks[1], app),
Scope::Apps => draw_apps_table(f, chunks[1], app),
}
if app.events_visible {
app.events_area = Some(chunks[2]);
draw_events(f, chunks[2], app);
draw_footer(f, chunks[3], app);
} else {
app.events_area = None;
draw_footer(f, chunks[2], app);
}
}
if app.mode == Mode::Help {
draw_help(f, f.area(), app);
} else {
app.help_max_scroll = 0;
}
if app.mode == Mode::Picker {
draw_picker(f, f.area(), app);
}
if app.mode == Mode::Action {
draw_action(f, f.area(), app);
}
if let Some(overlay) = app.current_overlay.clone() {
match overlay {
Overlay::Describe(text) => draw_describe(f, f.area(), app, &text),
Overlay::Whatsnew(text) => draw_whatsnew(f, f.area(), app, &text),
Overlay::History(text) => draw_history_overlay(f, f.area(), app, &text),
Overlay::Alarms { body, .. } => draw_alarms_overlay(f, f.area(), app, &body),
Overlay::Diff(text) => draw_diff_overlay(f, f.area(), app, &text),
Overlay::SavedConfigs(text) => draw_saved_configs_overlay(f, f.area(), app, &text),
Overlay::SavedConfigsInteractive {
items,
cursor,
confirm_delete,
} => draw_saved_configs_interactive(f, f.area(), app, &items, cursor, confirm_delete),
Overlay::TextDump { title, body } => {
draw_text_dump_overlay(f, f.area(), app, &title, &body)
}
Overlay::LogTail { .. } => draw_log_tail_overlay(f, f.area(), app),
Overlay::WhyRed { .. } => draw_why_red_overlay(f, f.area(), app),
Overlay::AppsActionMenu {
app_name,
env_names,
cursor,
} => draw_apps_action_menu(f, f.area(), app, &app_name, &env_names, cursor),
}
}
if app.mode == Mode::Palette {
draw_palette(f, f.area(), app);
}
if app.mode == Mode::Form {
draw_form(f, f.area(), app);
}
if !app.toasts.is_empty() {
draw_toasts(f, f.area(), app);
}
}
fn draw_form(f: &mut Frame, area: Rect, app: &App) {
use crate::form::{FieldKind, FormState};
let Some(form) = app.form.as_ref() else {
return;
};
let popup = centered_rect(60, 70, area);
f.render_widget(Clear, popup);
let theme = &app.theme;
let outer = titled_block(theme, &form.title, true, theme.title_alt);
let inner = outer.inner(popup);
f.render_widget(outer, popup);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let banner = if form.env_name.is_empty() {
format!(" file: {}", crate::config::config_path().display())
} else {
format!(" target: {}", form.env_name)
};
f.render_widget(
Paragraph::new(Span::styled(banner, Style::default().fg(theme.muted))),
chunks[0],
);
f.render_widget(
Paragraph::new(Span::styled(
"─".repeat(inner.width as usize),
Style::default().fg(theme.muted),
)),
chunks[1],
);
if form.state == FormState::Loading {
f.render_widget(
Paragraph::new(Span::styled(
" loading current values from AWS…",
Style::default().fg(theme.muted),
)),
chunks[2],
);
} else {
let max_label = form
.fields
.iter()
.map(|fld| fld.label.chars().count())
.max()
.unwrap_or(0)
.clamp(8, 32);
let mut lines: Vec<Line> = Vec::new();
for (i, fld) in form.fields.iter().enumerate() {
let is_cursor = i == form.cursor;
let pointer = if is_cursor { "▶ " } else { " " };
let label_style = if is_cursor {
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
let value_style = if is_cursor {
Style::default().fg(theme.text).bg(theme.row_selected_bg)
} else {
Style::default().fg(theme.text)
};
let value_text: String = match &fld.kind {
FieldKind::Text | FieldKind::Integer { .. } => {
if is_cursor {
format!("{}_", fld.value)
} else {
fld.value.clone()
}
}
FieldKind::Boolean => {
if fld.value == "true" {
"[x] true".to_string()
} else {
"[ ] false".to_string()
}
}
FieldKind::Select { options } => {
let _ = options; if is_cursor {
format!("◀ {} ▶", fld.value)
} else {
fld.value.clone()
}
}
FieldKind::MultiSelect { options } => {
let n_selected = crate::form::parse_multi_value(&fld.value).len();
format!("({n_selected} / {} selected)", options.len())
}
};
let mut row_spans = vec![
Span::styled(pointer.to_string(), Style::default().fg(theme.accent)),
Span::styled(
format!("{:<width$} ", fld.label, width = max_label),
label_style,
),
Span::styled(value_text, value_style),
];
if fld.error.is_some() {
row_spans.push(Span::styled(
" ✗",
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD),
));
}
lines.push(Line::from(row_spans));
if let FieldKind::MultiSelect { options } = &fld.kind {
let annotations = fld.option_annotations.as_deref();
for (idx, opt) in options.iter().enumerate() {
let selected = crate::form::is_multi_selected(&fld.value, opt);
let marker = if selected { "[x]" } else { "[ ]" };
let row_is_cursor = is_cursor && idx == fld.option_cursor;
let row_style = if row_is_cursor {
Style::default().fg(theme.text).bg(theme.row_selected_bg)
} else if selected {
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
let annot = annotations
.and_then(|a| a.get(idx))
.map(|s| s.as_str())
.unwrap_or("");
let row_spans = if annot.is_empty() {
vec![Span::styled(format!(" {marker} {opt}"), row_style)]
} else {
vec![
Span::styled(format!(" {marker} {opt} "), row_style),
Span::styled(annot.to_string(), Style::default().fg(theme.muted)),
]
};
lines.push(Line::from(row_spans));
}
}
if let Some(help) = &fld.help {
lines.push(Line::from(Span::styled(
format!(" {help}"),
Style::default().fg(theme.muted),
)));
}
if let Some(err) = &fld.error {
lines.push(Line::from(Span::styled(
format!(" {}{err}", warn_glyph(theme.icons)),
Style::default().fg(theme.health_red),
)));
}
}
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), chunks[2]);
}
let footer = match form.state {
FormState::Loading => " esc to cancel",
FormState::Submitting => " submitting…",
FormState::Ready => " tab field · ↓↑ field-or-option · type to edit · space toggle · ←→ cycle select · ^S submit · esc cancel",
};
f.render_widget(
Paragraph::new(Span::styled(footer, Style::default().fg(theme.muted))),
chunks[3],
);
}
fn draw_palette(f: &mut Frame, area: Rect, app: &App) {
let popup = centered_rect(60, 70, area);
f.render_widget(Clear, popup);
let theme = &app.theme;
let outer = titled_block(theme, "palette", true, theme.title_alt);
let inner = outer.inner(popup);
f.render_widget(outer, popup);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), Constraint::Length(1), ])
.split(inner);
let input = Paragraph::new(Line::from(vec![
Span::styled(
" ❯ ",
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD),
),
Span::styled(app.palette_input.clone(), Style::default().fg(theme.text)),
Span::styled(
caret_glyph(theme),
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::SLOW_BLINK),
),
]));
f.render_widget(input, layout[0]);
f.render_widget(
Paragraph::new(Span::styled(
"─".repeat(inner.width as usize),
Style::default().fg(theme.muted),
)),
layout[1],
);
let items: Vec<ListItem> = app
.palette_filtered
.iter()
.filter_map(|i| app.palette_items.get(*i))
.map(|it| {
let tag_color = match it.kind_tag {
"cmd" => theme.title,
"env" => theme.text,
"view" => theme.title_alt,
"plugin" => theme.accent,
_ => theme.muted,
};
ListItem::new(Line::from(vec![
Span::styled(
format!(" {:<6}", it.kind_tag),
Style::default().fg(tag_color).add_modifier(Modifier::DIM),
),
Span::styled(
format!("{:<32}", it.label),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
Span::styled(it.detail.clone(), Style::default().fg(theme.muted)),
]))
})
.collect();
let list = List::new(items)
.highlight_style(
Style::default()
.bg(theme.row_selected_bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(cursor_marker(theme));
let mut state = app.palette_state.clone();
f.render_stateful_widget(list, layout[2], &mut state);
let hint_count = app.palette_filtered.len();
let total = app.palette_items.len();
let hint = Paragraph::new(Span::styled(
format!(
" {}/{} matches ↑/↓ move ⏎ run esc cancel",
hint_count, total,
),
Style::default().fg(theme.muted),
));
f.render_widget(hint, layout[3]);
}
fn draw_toasts(f: &mut Frame, area: Rect, app: &App) {
let theme = &app.theme;
let width: u16 = 50;
let toast_h: u16 = 3;
let n = app.toasts.len() as u16;
let total_h = n * toast_h;
if area.height < total_h + 2 || area.width < width + 2 {
return;
}
let x = area.x + area.width.saturating_sub(width + 2);
let mut y = area.y + area.height.saturating_sub(total_h + 2);
for t in &app.toasts {
let rect = Rect {
x,
y,
width,
height: toast_h,
};
let (border_color, label, glyph) = match (t.kind, theme.icons) {
(ToastKind::Info, IconStyle::Powerline) => (theme.title, "info", "\u{f05a}"),
(ToastKind::Success, IconStyle::Powerline) => (theme.health_green, "ok", "\u{f058}"),
(ToastKind::Error, IconStyle::Powerline) => (theme.health_red, "error", "\u{f057}"),
(ToastKind::Info, IconStyle::Unicode) => (theme.title, "info", "ⓘ"),
(ToastKind::Success, IconStyle::Unicode) => (theme.health_green, "ok", "✓"),
(ToastKind::Error, IconStyle::Unicode) => (theme.health_red, "error", "✗"),
(ToastKind::Info, IconStyle::Ascii) => (theme.title, "info", "i"),
(ToastKind::Success, IconStyle::Ascii) => (theme.health_green, "ok", "+"),
(ToastKind::Error, IconStyle::Ascii) => (theme.health_red, "error", "!"),
};
let block = rounded_block(theme, true)
.border_style(Style::default().fg(border_color))
.title(Span::styled(
format!(" {glyph} {label} "),
Style::default()
.fg(border_color)
.add_modifier(Modifier::BOLD),
));
let mut text = t.text.clone();
let max = (width as usize).saturating_sub(7);
if text.chars().count() > max {
text = text.chars().take(max.saturating_sub(1)).collect::<String>();
text.push('…');
}
let para = Paragraph::new(Line::from(vec![
Span::styled(
stripe_glyph(theme.icons),
Style::default()
.fg(border_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {glyph} "),
Style::default()
.fg(border_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(text, Style::default().fg(theme.text)),
]))
.block(block);
f.render_widget(Clear, rect);
f.render_widget(para, rect);
y += toast_h;
}
}
fn draw_saved_configs_interactive(
f: &mut Frame,
area: Rect,
app: &App,
items: &[(String, String)],
cursor: usize,
confirm_delete: bool,
) {
let popup = centered_rect(60, 70, area);
f.render_widget(Clear, popup);
let theme = &app.theme;
let target = app
.selected_env()
.map(|e| e.name.clone())
.unwrap_or_else(|| "—".into());
let target_app = app.selected_env().map(|e| e.application.clone());
let row_budget = popup.height.saturating_sub(8) as usize;
let (visible_start, visible_end) = visible_window(cursor, items.len(), row_budget);
let mut lines: Vec<Line> = Vec::with_capacity(row_budget + 6);
let banner = if confirm_delete {
let cur_label = items
.get(cursor)
.map(|(a, t)| format!("{a}/{t}"))
.unwrap_or_else(|| "?".into());
Line::from(Span::styled(
format!(" delete {cur_label}? (Y/N)"),
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD),
))
} else {
Line::from(Span::styled(
format!(" apply target: {target}"),
Style::default().fg(theme.muted),
))
};
lines.push(banner);
if visible_start > 0 {
lines.push(Line::from(Span::styled(
format!(" ↑ {visible_start} more above"),
Style::default().fg(theme.muted),
)));
} else {
lines.push(Line::from(""));
}
let mut prev_app: Option<&str> = None;
if visible_start > 0 {
prev_app = items.get(visible_start - 1).map(|(a, _)| a.as_str());
}
for (i, (app_name, tmpl)) in items
.iter()
.enumerate()
.skip(visible_start)
.take(visible_end - visible_start)
{
if Some(app_name.as_str()) != prev_app {
lines.push(Line::from(Span::styled(
app_name.clone(),
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD),
)));
prev_app = Some(app_name.as_str());
}
let cross_app = target_app
.as_ref()
.map(|ta| ta != app_name)
.unwrap_or(false);
let marker = if i == cursor { " ▶ " } else { " " };
let style = if i == cursor {
let bg = if confirm_delete {
theme.row_red_bg
} else {
theme.row_selected_bg
};
Style::default()
.fg(theme.text)
.bg(bg)
.add_modifier(Modifier::BOLD)
} else if cross_app {
Style::default().fg(theme.muted)
} else {
Style::default().fg(theme.text)
};
let suffix = if cross_app {
" (different app — apply will fail)"
} else {
""
};
let line = Line::from(vec![
Span::styled(marker.to_string(), Style::default().fg(theme.accent)),
Span::styled(tmpl.clone(), style),
Span::styled(suffix.to_string(), Style::default().fg(theme.health_yellow)),
]);
lines.push(line);
}
if visible_end < items.len() {
let more = items.len() - visible_end;
lines.push(Line::from(Span::styled(
format!(" ↓ {more} more below"),
Style::default().fg(theme.muted),
)));
}
lines.push(Line::from(""));
let footer = if confirm_delete {
" Y confirm • N / esc cancel "
} else {
" j/k move • enter/a apply • i inspect • c create • x delete • ? help • esc close "
};
lines.push(Line::from(Span::styled(
footer,
Style::default().fg(theme.muted),
)));
let p = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
titled_block(&app.theme, "saved configurations", true, app.theme.title)
.padding(Padding::uniform(1)),
);
f.render_widget(p, popup);
}
pub fn visible_window(cursor: usize, total: usize, budget: usize) -> (usize, usize) {
if total == 0 {
return (0, 0);
}
let budget = budget.max(1).min(total);
if total <= budget {
return (0, total);
}
let half = budget / 2;
let start = cursor.saturating_sub(half);
let start = start.min(total - budget);
(start, start + budget)
}
fn draw_log_tail_overlay(f: &mut Frame, area: Rect, app: &App) {
let Some(crate::app::Overlay::LogTail {
log_group,
env_name,
events,
scroll,
following,
filter_input,
filter_active,
filter_pattern,
last_err,
..
}) = app.current_overlay.as_ref()
else {
return;
};
let popup = centered_rect(85, 80, area);
f.render_widget(Clear, popup);
let theme = &app.theme;
let mut lines: Vec<Line> = Vec::with_capacity(events.len());
for ev in events.iter() {
if let Some(pat) = filter_pattern.as_ref() {
if !pat.is_match(&ev.message) {
continue;
}
}
let dt = chrono::DateTime::<chrono::Utc>::from_timestamp_millis(ev.timestamp_ms)
.unwrap_or_else(chrono::Utc::now);
let stream_tail: String = ev
.stream
.chars()
.rev()
.take(8)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let ts_style = Style::default().fg(theme.muted);
let stream_style = Style::default().fg(theme.title_alt);
let msg_style = Style::default().fg(theme.text);
lines.push(Line::from(vec![
Span::styled(format!("{} ", dt.format("%H:%M:%S")), ts_style),
Span::styled(format!("{stream_tail} "), stream_style),
Span::styled(ev.message.clone(), msg_style),
]));
}
let body_rows = popup.height.saturating_sub(6) as usize;
let total = lines.len();
let start = if *following {
total.saturating_sub(body_rows)
} else {
let max_start = total.saturating_sub(body_rows);
max_start.saturating_sub(*scroll as usize)
};
let visible_lines: Vec<Line> = lines.into_iter().skip(start).take(body_rows).collect();
let title_text = format!(
"logs-tail — {env_name} · {} · {} lines{}",
log_group.rsplit('/').next().unwrap_or(log_group.as_str()),
events.len(),
if *following {
" · following"
} else {
" · paused (G to follow)"
}
);
let footer_text = if *filter_active {
format!(" filter: {filter_input}_ (esc cancel)")
} else if let Some(err) = last_err {
format!(" {}{err}", warn_glyph(theme.icons))
} else {
" j/k scroll · g/G top/follow · / filter · n clear-filter · Tab change group · esc / q close".to_string()
};
let mut paragraph_lines = visible_lines;
paragraph_lines.push(Line::raw(""));
paragraph_lines.push(Line::from(Span::styled(
footer_text,
Style::default().fg(theme.muted),
)));
let p = Paragraph::new(paragraph_lines)
.wrap(Wrap { trim: false })
.block(
titled_block(&app.theme, &title_text, true, app.theme.title)
.padding(Padding::uniform(1)),
);
f.render_widget(p, popup);
}
fn draw_text_dump_overlay(f: &mut Frame, area: Rect, app: &App, title: &str, text: &str) {
let popup = centered_rect(70, 70, area);
f.render_widget(Clear, popup);
let theme = &app.theme;
let lines: Vec<Line> = text
.lines()
.map(|l| Line::from(Span::styled(l.to_string(), Style::default().fg(theme.text))))
.collect();
let outer = titled_block(&app.theme, title, true, app.theme.title);
let inner = outer.inner(popup);
f.render_widget(outer, popup);
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(1)])
.split(inner);
f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), chunks[0]);
f.render_widget(
Paragraph::new(Span::styled(
" esc / q to close",
Style::default().fg(theme.muted),
)),
chunks[1],
);
}
fn draw_saved_configs_overlay(f: &mut Frame, area: Rect, app: &App, text: &str) {
let popup = centered_rect(60, 70, area);
f.render_widget(Clear, popup);
let theme = &app.theme;
let mut lines: Vec<Line> = text
.lines()
.map(|l| {
let style = if l.starts_with("Application:") {
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD)
} else if l.trim_start().starts_with('▸') {
Style::default().fg(theme.text)
} else if l.starts_with("─") {
Style::default().fg(theme.muted)
} else {
Style::default().fg(theme.text)
};
Line::from(Span::styled(l.to_string(), style))
})
.collect();
push_close_hint(&mut lines, &app.theme);
let p = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
titled_block(&app.theme, "saved configurations", true, app.theme.title)
.padding(Padding::uniform(1)),
);
f.render_widget(p, popup);
}
fn push_close_hint(lines: &mut Vec<Line<'static>>, theme: &Theme) {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" esc / q to close",
Style::default().fg(theme.muted),
)));
}
fn draw_diff_overlay(f: &mut Frame, area: Rect, app: &App, text: &str) {
let popup = centered_rect(80, 70, area);
f.render_widget(Clear, popup);
let theme = &app.theme;
let mut lines: Vec<Line> = text
.lines()
.map(|l| {
let style = if l.starts_with('≠') {
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::BOLD)
} else if l.starts_with("─") {
Style::default().fg(theme.muted)
} else {
Style::default().fg(theme.text)
};
Line::from(Span::styled(l.to_string(), style))
})
.collect();
push_close_hint(&mut lines, &app.theme);
let p = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
titled_block(&app.theme, "diff", true, app.theme.title).padding(Padding::uniform(1)),
);
f.render_widget(p, popup);
}
fn draw_alarms_overlay(f: &mut Frame, area: Rect, app: &App, text: &str) {
let popup = centered_rect(70, 70, area);
f.render_widget(Clear, popup);
let theme = &app.theme;
let mut lines: Vec<Line> = text
.lines()
.map(|l| {
let style = if l.starts_with("ALARM") {
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD)
} else if l.starts_with("OK") {
Style::default().fg(theme.health_green)
} else if l.starts_with("INSUFFICIENT") || l.trim_start().starts_with("↳") {
Style::default().fg(theme.muted)
} else {
Style::default().fg(theme.text)
};
Line::from(Span::styled(l.to_string(), style))
})
.collect();
push_close_hint(&mut lines, &app.theme);
let p = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
titled_block(&app.theme, "alarms", true, app.theme.title).padding(Padding::uniform(1)),
);
f.render_widget(p, popup);
}
fn draw_why_red_overlay(f: &mut Frame, area: Rect, app: &App) {
let Some(crate::app::Overlay::WhyRed {
env_name,
tier,
events,
alarms,
instances,
deploys,
queues,
dlq_messages,
..
}) = app.current_overlay.as_ref()
else {
return;
};
let is_worker = tier.eq_ignore_ascii_case("Worker");
let popup = centered_rect(78, 80, area);
f.render_widget(Clear, popup);
let theme = &app.theme;
let now = chrono::Utc::now();
let mut lines: Vec<Line> = Vec::new();
let section = |title: &str| -> Line<'static> {
Line::from(Span::styled(
format!("─── {title} "),
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD),
))
};
let blank = || Line::raw("");
let muted = |s: String| -> Line<'static> {
Line::from(Span::styled(s, Style::default().fg(theme.muted)))
};
lines.push(section("recent events (last 30 min)"));
match events {
None => lines.push(muted(" fetching events…".into())),
Some(Err(e)) => lines.push(Line::from(Span::styled(
format!(" error: {e}"),
Style::default().fg(theme.health_red),
))),
Some(Ok(evs)) => {
let cutoff = now - chrono::Duration::minutes(30);
let recent: Vec<&crate::aws::Event> = evs
.iter()
.filter(|e| e.at.map(|t| t >= cutoff).unwrap_or(true))
.take(15)
.collect();
if recent.is_empty() {
lines.push(muted(" (no events in the last 30 min)".into()));
} else {
for e in recent {
let when =
e.at.map(|t| t.with_timezone(&chrono::Local).format("%H:%M").to_string())
.unwrap_or_else(|| "??:??".into());
let sev_style = match e.severity.to_uppercase().as_str() {
"ERROR" => Style::default().fg(theme.health_red),
"WARN" => Style::default().fg(theme.health_yellow),
_ => Style::default().fg(theme.muted),
};
lines.push(Line::from(vec![
Span::styled(format!(" {when} "), Style::default().fg(theme.muted)),
Span::styled(format!("{:<5}", e.severity), sev_style),
Span::raw(" "),
Span::styled(e.message.clone(), Style::default().fg(theme.text)),
]));
}
}
}
}
lines.push(blank());
lines.push(section("alarms"));
match alarms {
None => lines.push(muted(" fetching alarms…".into())),
Some(Err(e)) => lines.push(Line::from(Span::styled(
format!(" error: {e}"),
Style::default().fg(theme.health_red),
))),
Some(Ok(als)) => {
if als.is_empty() {
lines.push(muted(" (no CloudWatch alarms attached to this env)".into()));
} else {
let mut sorted: Vec<&crate::aws::CwAlarm> = als.iter().collect();
sorted.sort_by_key(|a| match a.state.as_str() {
"ALARM" => 0,
"INSUFFICIENT_DATA" => 1,
_ => 2,
});
for a in sorted.iter().take(10) {
let (tag, style) = match a.state.as_str() {
"ALARM" => (
"ALARM",
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD),
),
"OK" => ("OK ", Style::default().fg(theme.health_green)),
_ => ("INS ", Style::default().fg(theme.muted)),
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(tag.to_string(), style),
Span::raw(" "),
Span::styled(
a.name.clone(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" ({}/{})", a.namespace, a.metric_name),
Style::default().fg(theme.muted),
),
]));
if !a.state_reason.is_empty() && a.state == "ALARM" {
lines.push(Line::from(Span::styled(
format!(" ↳ {}", a.state_reason),
Style::default().fg(theme.muted),
)));
}
}
}
}
}
lines.push(blank());
if is_worker {
lines.push(section("worker queues"));
match queues {
None => lines.push(muted(" fetching queue depths…".into())),
Some(Err(e)) => lines.push(Line::from(Span::styled(
format!(" error: {e}"),
Style::default().fg(theme.health_red),
))),
Some(Ok(q)) => {
let main_line = match q.main_stats.as_ref() {
Some(s) => format!(
" main: visible={} in-flight={} delayed={}",
s.visible, s.in_flight, s.delayed
),
None => " main: (queue URL not resolved)".to_string(),
};
let main_style = match q.main_stats.as_ref().map(|s| s.visible).unwrap_or(0) {
n if n > 100 => Style::default().fg(theme.health_yellow),
_ => Style::default().fg(theme.text),
};
lines.push(Line::from(Span::styled(main_line, main_style)));
let dlq_visible = q.dlq_stats.as_ref().map(|s| s.visible).unwrap_or(0);
let dlq_line = match q.dlq_stats.as_ref() {
Some(s) => format!(
" dlq: visible={} in-flight={} delayed={}",
s.visible, s.in_flight, s.delayed
),
None => " dlq: (queue URL not resolved)".to_string(),
};
let dlq_style = if dlq_visible > 0 {
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
lines.push(Line::from(Span::styled(dlq_line, dlq_style)));
if dlq_visible > 0 {
match dlq_messages {
None => lines.push(muted(" peeking dlq messages…".into())),
Some(Err(e)) => lines.push(Line::from(Span::styled(
format!(" dlq peek error: {e}"),
Style::default().fg(theme.health_red),
))),
Some(Ok(msgs)) if msgs.is_empty() => {
lines.push(muted(
" dlq peek returned no bodies (try again in a few seconds)".into(),
));
}
Some(Ok(msgs)) => {
lines.push(Line::from(Span::styled(
format!(" dlq message peek ({} of {dlq_visible}):", msgs.len()),
Style::default().fg(theme.muted),
)));
for (i, m) in msgs.iter().enumerate() {
let when = m
.sent_at
.map(|t| humanize_age(now.signed_duration_since(t)))
.unwrap_or_else(|| "—".into());
lines.push(Line::from(vec![
Span::styled(
format!(" {}.", i + 1),
Style::default().fg(theme.muted),
),
Span::styled(
format!(" sent {when} ago"),
Style::default().fg(theme.muted),
),
Span::styled(
format!(" · received {}×", m.receive_count),
Style::default().fg(theme.muted),
),
]));
lines.push(Line::from(Span::styled(
format!(" {}", truncate_for_display(&m.body, 100)),
Style::default().fg(theme.text),
)));
}
}
}
}
}
}
lines.push(blank());
}
lines.push(section("instance health"));
match instances {
None => lines.push(muted(" fetching instance health…".into())),
Some(Err(e)) => lines.push(Line::from(Span::styled(
format!(" error: {e}"),
Style::default().fg(theme.health_red),
))),
Some(Ok(insts)) => {
if insts.is_empty() {
lines.push(muted(" (no instances reported)".into()));
} else {
for i in insts {
let style = match i.color.as_str() {
"Red" => Style::default().fg(theme.health_red),
"Yellow" => Style::default().fg(theme.health_yellow),
"Green" => Style::default().fg(theme.health_green),
_ => Style::default().fg(theme.muted),
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(i.id.clone(), Style::default().fg(theme.text)),
Span::raw(" "),
Span::styled(format!("{:<8}", i.health), style),
Span::styled(
format!(" {} {}", i.instance_type, i.availability_zone),
Style::default().fg(theme.muted),
),
]));
for cause in i.causes.iter().take(3) {
lines.push(Line::from(Span::styled(
format!(" ↳ {cause}"),
Style::default().fg(theme.muted),
)));
}
}
}
}
}
lines.push(blank());
lines.push(section("recent deploys"));
match deploys {
None => lines.push(muted(" fetching deploys…".into())),
Some(Err(e)) => lines.push(Line::from(Span::styled(
format!(" error: {e}"),
Style::default().fg(theme.health_red),
))),
Some(Ok(vers)) => {
if vers.is_empty() {
lines.push(muted(" (no versions registered yet)".into()));
} else {
for v in vers.iter().take(5) {
let when = v
.created
.map(|t| humanize_age(now.signed_duration_since(t)))
.unwrap_or_else(|| "—".into());
let when_style = Style::default().fg(age_color(v.created, now, theme));
let mut spans = vec![
Span::raw(" "),
Span::styled(
v.label.clone(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {when} ago"), when_style),
];
if !v.description.is_empty() {
spans.push(Span::styled(
format!(" — {}", truncate_for_display(&v.description, 60)),
Style::default().fg(theme.muted),
));
}
lines.push(Line::from(spans));
}
}
}
}
push_close_hint(&mut lines, theme);
let title = format!("why is {env_name} red?");
let p = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(titled_block(theme, &title, true, theme.title).padding(Padding::uniform(1)));
f.render_widget(p, popup);
}
fn truncate_for_display(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let prefix: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{prefix}…")
}
fn draw_apps_action_menu(
f: &mut Frame,
area: Rect,
app: &App,
app_name: &str,
env_names: &[String],
cursor: usize,
) {
let theme = &app.theme;
let popup = centered_rect(40, 30, area);
f.render_widget(Clear, popup);
let n_envs = env_names.len();
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(Span::styled(
format!("application: {app_name} · {n_envs} env(s)"),
Style::default().fg(theme.muted),
)));
lines.push(Line::from(""));
for (i, item) in crate::app::APPS_ACTION_ITEMS.iter().enumerate() {
let active = i == cursor;
let cursor_glyph = if active { cursor_marker(theme) } else { " " };
let label = match item {
crate::app::AppsActionItem::BatchRebuild => {
format!("Rebuild all {n_envs} env(s)")
}
crate::app::AppsActionItem::BatchRestart => {
format!("Restart all {n_envs} env(s)")
}
crate::app::AppsActionItem::BatchDeploy => {
format!("Deploy version label to all {n_envs} env(s)")
}
_ => item.label().to_string(),
};
let style = if active {
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
lines.push(Line::from(vec![
Span::styled(cursor_glyph.to_string(), style),
Span::styled(label, style),
]));
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" j/k move · enter dispatch · esc / q cancel",
Style::default().fg(theme.muted),
)));
let title = format!("apps action — {app_name}");
let p = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(titled_block(theme, &title, true, theme.title).padding(Padding::uniform(1)));
f.render_widget(p, popup);
}
fn draw_history_overlay(f: &mut Frame, area: Rect, app: &App, text: &str) {
let popup = centered_rect(60, 70, area);
f.render_widget(Clear, popup);
let mut lines: Vec<Line> = text
.lines()
.map(|l| {
Line::from(Span::styled(
l.to_string(),
Style::default().fg(app.theme.text),
))
})
.collect();
push_close_hint(&mut lines, &app.theme);
let p = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
titled_block(&app.theme, "history", true, app.theme.title).padding(Padding::uniform(1)),
);
f.render_widget(p, popup);
}
fn draw_whatsnew(f: &mut Frame, area: Rect, app: &App, text: &str) {
let popup = centered_rect(60, 70, area);
f.render_widget(Clear, popup);
let lines: Vec<Line> = text
.lines()
.map(|l| {
Line::from(Span::styled(
l.to_string(),
Style::default().fg(app.theme.text),
))
})
.collect();
let p = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
titled_block(
&app.theme,
"what's new — esc / w / q to close",
true,
app.theme.title_alt,
)
.padding(Padding::uniform(1)),
);
f.render_widget(p, popup);
}
fn draw_describe(f: &mut Frame, area: Rect, app: &App, text: &str) {
let popup = centered_rect(60, 70, area);
f.render_widget(Clear, popup);
let lines: Vec<Line> = text
.lines()
.map(|l| {
Line::from(Span::styled(
l.to_string(),
Style::default().fg(app.theme.text),
))
})
.collect();
let p = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
titled_block(
&app.theme,
"describe — esc / D / q to close",
true,
app.theme.title_alt,
)
.padding(Padding::uniform(1)),
);
f.render_widget(p, popup);
}
fn draw_header(f: &mut Frame, area: Rect, app: &App, merge_pills: bool) {
let theme = &app.theme;
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(60), Constraint::Percentage(40)])
.split(area);
let profile = app
.context
.profile
.clone()
.unwrap_or_else(|| "default".into());
let last = format_refresh_label(app.last_refresh, chrono::Utc::now(), app.refresh_interval);
let now = std::time::Instant::now();
let live_load_visible = app
.loading_since
.map(|t| t.elapsed() >= crate::app::LOADING_INDICATOR_THRESHOLD)
.unwrap_or(false);
let linger_active = app.loading_visible_until.map(|t| now < t).unwrap_or(false);
let show_loading = live_load_visible || linger_active;
let elapsed_ms = if let Some(t) = app.loading_since {
t.elapsed().as_millis()
} else if let Some(until) = app.loading_visible_until {
let linger_started = until - crate::app::LOADING_INDICATOR_LINGER;
now.saturating_duration_since(linger_started).as_millis()
} else {
0
};
const STATUS_SLOT: usize = 10;
let status: Span<'static> = if matches!(app.load_state, LoadState::Error) {
let label = format!("{:<width$}", "error", width = STATUS_SLOT);
Span::styled(label, Style::default().fg(theme.health_red))
} else if show_loading {
let label = format!(
"{:<width$}",
format!("{} loading…", spinner(elapsed_ms, theme.icons)),
width = STATUS_SLOT
);
Span::styled(label, Style::default().fg(theme.health_yellow))
} else {
let label = format!("{:<width$}", "idle", width = STATUS_SLOT);
Span::styled(label, Style::default().fg(theme.health_green))
};
let env_count = app.environments.len().to_string();
let account = redact(
&app.context.account_id.clone().unwrap_or_else(|| "—".into()),
app.redact,
);
let caller = redact(
&app.context
.caller_arn
.as_deref()
.map(short_caller)
.unwrap_or_else(|| "—".into()),
app.redact,
);
let mut line1 = kv("Account", &account, theme);
line1.push(sep(theme));
line1.extend(kv("Region", &app.context.region, theme));
line1.push(sep(theme));
line1.extend(kv("Profile", &profile, theme));
let sort_dir = if app.sort_desc { "↓" } else { "↑" };
let sort_label = format!("{}{}", app.sort_key.label(), sort_dir);
let mut line2 = kv("Sort", &sort_label, theme);
line2.push(sep(theme));
line2.push(Span::raw("Status: "));
line2.push(status);
line2.push(sep(theme));
line2.extend(kv("Envs", &env_count, theme));
for (bucket, delta) in app.health_delta.iter().chain(app.status_delta.iter()) {
if *delta == 0 {
continue;
}
let arrow = if *delta > 0 { "▲" } else { "▼" };
let color = match bucket.to_lowercase().as_str() {
"red" | "severe" => theme.health_red,
"yellow" | "warning" => theme.health_yellow,
"green" | "ok" | "ready" => theme.health_green,
"updating" | "launching" => theme.health_yellow,
"terminating" | "terminated" => theme.health_red,
_ => theme.muted,
};
line2.push(Span::raw(" "));
line2.push(Span::styled(
format!("{arrow}{} {}", delta.abs(), bucket),
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
}
line2.push(sep(theme));
line2.extend(kv("Last", &last, theme));
line2.push(sep(theme));
line2.extend(kv("Caller", &caller, theme));
if !app.filter.is_empty() {
line2.push(sep(theme));
let filter_text = app.filter.clone();
line2.push(Span::styled("Filter: ", Style::default().fg(theme.muted)));
line2.push(Span::styled(
filter_text,
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::BOLD),
));
}
let inner_w = (area.width as u32 * 60 / 100) as usize;
let inner_w = inner_w.saturating_sub(2);
let mut chain_pills = build_chain_pills(app);
prune_pills_to_width(&mut chain_pills, theme, inner_w);
if merge_pills && !chain_pills.is_empty() {
line2.push(Span::raw(" "));
line2.extend(pill_chain(&chain_pills, theme));
}
let pill_line: Option<Line<'static>> = if merge_pills || chain_pills.is_empty() {
None
} else {
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::raw(" "));
spans.extend(pill_chain(&chain_pills, theme));
Some(Line::from(spans))
};
let crumb = breadcrumb_line(app);
let mut paragraph_lines: Vec<Line> = vec![crumb, Line::from(line1), Line::from(line2)];
if let Some(pl) = pill_line {
paragraph_lines.push(pl);
}
if !app.named_filters.is_empty() {
let mut chips: Vec<Span> =
vec![Span::styled("Filters: ", Style::default().fg(theme.muted))];
if theme.icons == IconStyle::Powerline {
let pills: Vec<(String, Color, Color)> = app
.named_filters
.iter()
.map(|(name, value)| {
let active = !app.filter.is_empty() && value == &app.filter;
let (fg, bg) = if active {
(Color::Black, theme.title_alt)
} else {
(theme.muted, theme.row_alt_bg)
};
(name.to_string(), fg, bg)
})
.collect();
chips.extend(pill_chain(&pills, theme));
} else {
for (name, value) in app.named_filters.iter() {
let active = !app.filter.is_empty() && value == &app.filter;
chips.push(Span::styled(
format!(" {name} "),
if active {
Style::default()
.fg(Color::Black)
.bg(theme.title_alt)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.muted)
},
));
chips.push(Span::raw(" "));
}
}
paragraph_lines.push(Line::from(chips));
}
let info =
Paragraph::new(paragraph_lines).block(titled_block(theme, "ebman", false, theme.title));
f.render_widget(info, cols[0]);
let scope_label = match app.scope {
Scope::Envs => "Envs",
Scope::Apps => "Apps",
};
let context_panel = Paragraph::new(vec![
Line::from(vec![
Span::styled(
"Elastic Beanstalk ",
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD),
),
pill(scope_label, Color::Black, theme.title),
]),
Line::from(Span::styled(
"<tab> scope <?> help <:> command </> filter <q> quit",
Style::default().fg(theme.muted),
)),
])
.alignment(Alignment::Right)
.block(rounded_block(theme, false));
f.render_widget(context_panel, cols[1]);
}
fn draw_apps_table(f: &mut Frame, area: Rect, app: &mut App) {
let theme = app.theme.clone();
let header = Row::new(
[
"NAME",
"ENVS",
"RED",
"UPDATING",
"VERSIONS",
"UPDATED",
"LATEST",
"DESCRIPTION",
]
.map(|h| {
Cell::from(h).style(
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD),
)
}),
)
.height(1);
let now = chrono::Utc::now();
let rows: Vec<Row> = app
.applications
.iter()
.enumerate()
.map(|(i, a)| {
let age = |d: Option<chrono::DateTime<chrono::Utc>>| -> String {
d.map(|t| humanize_age(now.signed_duration_since(t)))
.unwrap_or_else(|| "—".into())
};
let latest_cell = match (a.latest_version_label.as_deref(), a.latest_version_created) {
(Some(label), Some(created)) => Cell::from(Line::from(vec![
Span::styled(
label.to_string(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {}", humanize_age(now.signed_duration_since(created))),
Style::default().fg(age_color(Some(created), now, &theme)),
),
])),
(Some(label), None) => Cell::from(Span::styled(
label.to_string(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
)),
_ => Cell::from(Span::styled("—", Style::default().fg(theme.muted))),
};
let rollup = crate::app::app_rollup(&app.environments, &a.name, &app.worker_dlq_depths);
let red_style = if rollup.red_count > 0 {
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.muted)
};
let updating_style = if rollup.updating_count > 0 {
Style::default()
.fg(theme.status_updating)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.muted)
};
let total_alerting = rollup.red_count + rollup.worker_dlq_alerts;
let r = Row::new(vec![
Cell::from(a.name.clone())
.style(Style::default().fg(theme.text).add_modifier(Modifier::BOLD)),
Cell::from(rollup.env_count.to_string())
.style(Style::default().fg(theme.text).add_modifier(Modifier::BOLD)),
Cell::from(total_alerting.to_string()).style(red_style),
Cell::from(rollup.updating_count.to_string()).style(updating_style),
Cell::from(a.version_count.to_string())
.style(Style::default().fg(theme.app_palette[0])),
Cell::from(age(a.date_updated)).style(Style::default().fg(age_color(
a.date_updated,
now,
&theme,
))),
latest_cell,
Cell::from(a.description.clone()).style(Style::default().fg(theme.text)),
]);
if i % 2 == 0 {
r.style(Style::default().bg(theme.row_alt_bg))
} else {
r
}
})
.collect();
let title = format!("Applications {}", app.applications.len());
let widths = [
Constraint::Percentage(20),
Constraint::Length(5), Constraint::Length(4), Constraint::Length(9), Constraint::Length(8), Constraint::Length(8), Constraint::Percentage(22), Constraint::Percentage(28), ];
let popup_open = matches!(
app.mode,
Mode::Help | Mode::Picker | Mode::Command | Mode::Action | Mode::Filter
);
let table = Table::new(rows, widths)
.header(header)
.row_highlight_style(
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
)
.highlight_symbol(cursor_marker(&theme))
.block(
titled_block(&theme, &title, !popup_open, theme.title).padding(Padding::horizontal(1)),
);
f.render_stateful_widget(table, area, &mut app.app_table_state);
}
fn draw_table(f: &mut Frame, area: Rect, app: &mut App) {
app.table_area = area;
let theme = app.theme.clone();
let compact = app.view_mode == ViewMode::Compact;
let spacious = app.view_mode == ViewMode::Spacious;
let row_height: u16 = if spacious { 2 } else { 1 };
let block_padding: u16 = if spacious { 2 } else { 1 };
let indexes = app.filtered_indexes();
let mut full = vec![
("NAME", SortKey::Name),
("APPLICATION", SortKey::App),
("TIER", SortKey::App),
("STATUS", SortKey::Status),
("HEALTH", SortKey::Health),
("TREND", SortKey::Health),
("PLATFORM", SortKey::Version),
("VERSION", SortKey::Version),
("CNAME", SortKey::Name),
("AGE", SortKey::Age),
];
if !app.multi_regions.is_empty() {
full.insert(1, ("REGION", SortKey::App));
}
if compact {
full.retain(|(label, _)| !matches!(*label, "TREND" | "PLATFORM"));
}
let columns: Vec<(&'static str, SortKey)> = full
.into_iter()
.filter(|(label, _)| {
if *label == "NAME" {
return true;
}
!app.hidden_cols.contains(*label)
})
.collect();
let sort_marker = if app.sort_desc { " ▼" } else { " ▲" };
let trend_window =
crate::app::humanize_short_age(app.refresh_interval * crate::app::HISTORY_CAP as u32);
let header_cells: Vec<Cell> = columns
.iter()
.map(|(label, key)| {
let display: std::borrow::Cow<'_, str> = if *label == "HEALTH" {
"●".into()
} else if *label == "TREND" {
format!("TREND ({trend_window})").into()
} else {
(*label).into()
};
let mut text = display.into_owned();
let primary_match = matches!(
(key, app.sort_key),
(SortKey::Name, SortKey::Name)
| (SortKey::App, SortKey::App)
| (SortKey::Status, SortKey::Status)
| (SortKey::Health, SortKey::Health)
| (SortKey::Age, SortKey::Age)
| (SortKey::Version, SortKey::Version)
);
let show_marker = primary_match && !matches!(*label, "TREND" | "CNAME" | "TIER");
if show_marker {
text.push_str(sort_marker);
}
Cell::from(text).style(
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD),
)
})
.collect();
let header = Row::new(header_cells).height(1);
let app_colors = &app.cached_app_colors;
let hover = if app.mode == Mode::Normal {
app.hover_row
} else {
None
};
let display = app.display_rows();
let now = chrono::Utc::now();
let mut env_idx: usize = 0;
let rows: Vec<Row> = display
.iter()
.enumerate()
.map(|(row_idx, row)| match row {
DisplayRow::Env(i) => {
let env_position = env_idx;
env_idx += 1;
let e = &app.environments[*i];
let color = app_colors
.get(&e.application)
.copied()
.unwrap_or(theme.text);
let age = e
.updated
.map(|u| humanize_age(now.signed_duration_since(u)))
.unwrap_or_else(|| "—".into());
let display_name = app
.aliases
.get(&e.name)
.cloned()
.unwrap_or_else(|| e.name.clone());
let star = if app.pinned.contains(&e.name) {
"★ "
} else {
""
};
let checked = if app.multi_selected.contains(&e.name) {
"✓ "
} else {
""
};
let added_marker = if app.newly_added.contains(&e.name) {
"+ "
} else {
""
};
let alert = if app.newly_red.contains(&e.name) {
"▲ "
} else {
""
};
let (drift_glyph, drift_color) = match e.updated {
Some(u) => {
let dur = now.signed_duration_since(u);
if dur < chrono::Duration::hours(24) && dur > chrono::Duration::zero() {
("◆ ", theme.title_alt)
} else if dur > chrono::Duration::days(30) {
("◇ ", theme.muted)
} else {
("", theme.text)
}
}
None => ("", theme.text),
};
let name_cell = Cell::from(Line::from(vec![
Span::styled(
checked.to_string(),
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD),
),
Span::styled(
star.to_string(),
Style::default()
.fg(theme.accent)
.add_modifier(Modifier::BOLD),
),
Span::styled(
alert.to_string(),
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD),
),
Span::styled(
added_marker.to_string(),
Style::default()
.fg(theme.health_green)
.add_modifier(Modifier::BOLD),
),
Span::styled(
drift_glyph.to_string(),
Style::default()
.fg(drift_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
display_name,
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
]));
let cells: Vec<Cell> = columns
.iter()
.map(|(label, _)| match *label {
"NAME" => name_cell.clone(),
"APPLICATION" => Cell::from(Span::raw(e.application.as_str()))
.style(Style::default().fg(color).add_modifier(Modifier::BOLD)),
"TIER" => tier_cell(&e.tier, &theme),
"STATUS" => {
let dlq = if e.tier.eq_ignore_ascii_case("Worker") {
app.worker_dlq_depths.get(&e.name).copied().unwrap_or(0)
} else {
0
};
let alerting = dlq > 0
|| e.health.eq_ignore_ascii_case("Red")
|| e.health.eq_ignore_ascii_case("Severe");
if dlq > 0 {
Cell::from(Line::from(vec![
status_pill_for(&e.status, &theme, alerting),
Span::styled(
format!(" {}{dlq}", warn_glyph(theme.icons).trim_end()),
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD),
),
]))
} else {
Cell::from(status_pill_for(&e.status, &theme, alerting))
}
}
"HEALTH" => Cell::from(health_dot(&e.health, &theme)),
"TREND" => Cell::from(sparkline_for(
app.history.get(&e.name),
&theme,
app.newly_red.contains(&e.name),
)),
"PLATFORM" => {
let style = platform_style(&e.platform);
let colour = style
.as_ref()
.and_then(|s| theme.app_palette.get(s.palette_idx).copied())
.unwrap_or(theme.muted);
let icon = if theme.icons == IconStyle::Powerline {
style.as_ref().map(|s| s.icon)
} else {
None
};
match icon {
Some(g) => Cell::from(Line::from(vec![
Span::styled(format!("{g} "), Style::default().fg(colour)),
Span::styled(e.platform.as_str(), Style::default().fg(colour)),
])),
None => Cell::from(Span::raw(e.platform.as_str()))
.style(Style::default().fg(colour)),
}
}
"VERSION" => Cell::from(Span::raw(e.version_label.as_str()))
.style(Style::default().fg(theme.app_palette[0])),
"CNAME" => Cell::from(redact(&e.cname, app.redact))
.style(Style::default().fg(theme.muted)),
"AGE" => Cell::from(age.clone())
.style(Style::default().fg(age_color(e.updated, now, &theme))),
"REGION" => Cell::from(Span::raw(e.region.as_deref().unwrap_or_default()))
.style(Style::default().fg(theme.accent)),
_ => Cell::from(""),
})
.collect();
let is_hover = hover == Some(row_idx);
let dlq_red = e.tier.eq_ignore_ascii_case("Worker")
&& app.worker_dlq_depths.get(&e.name).copied().unwrap_or(0) > 0;
let bg = if dlq_red
|| e.health.eq_ignore_ascii_case("Red")
|| e.health.eq_ignore_ascii_case("Severe")
{
Some(theme.row_red_bg)
} else if e.health.eq_ignore_ascii_case("Yellow") {
Some(theme.row_yellow_bg)
} else if is_hover {
Some(theme.row_hover_bg)
} else if env_position.is_multiple_of(2) {
Some(theme.row_alt_bg)
} else {
None
};
let style = match bg {
Some(c) => Style::default().bg(c),
None => Style::default(),
};
Row::new(cells).style(style).height(row_height)
}
DisplayRow::Separator => {
let (next_app_name, next_color) = display
.iter()
.skip(row_idx + 1)
.find_map(|r| match r {
DisplayRow::Env(i) => {
let env = &app.environments[*i];
Some((
env.application.clone(),
app_colors
.get(&env.application)
.copied()
.unwrap_or(theme.muted),
))
}
_ => None,
})
.unwrap_or_else(|| (String::new(), theme.muted));
let group_envs: Vec<&Environment> = display
.iter()
.skip(row_idx + 1)
.map_while(|r| match r {
DisplayRow::Env(i) => Some(&app.environments[*i]),
DisplayRow::Separator => None,
})
.collect();
let summary = summarize_group(&group_envs);
let dashes = "─".repeat(DIVIDER_FILL_WIDTH);
let count = columns.len();
if theme.icons == IconStyle::Powerline && !next_app_name.is_empty() {
let summary_text = summary.clone();
let cells: Vec<Cell> = columns
.iter()
.enumerate()
.map(|(i, (label, _))| {
if i == 0 && *label == "NAME" {
Cell::from(Line::from(vec![
Span::styled("\u{e0b2}", Style::default().fg(next_color)),
Span::styled(
format!(" {next_app_name} "),
Style::default()
.fg(Color::Black)
.bg(next_color)
.add_modifier(Modifier::BOLD),
),
Span::styled("\u{e0b0}", Style::default().fg(next_color)),
]))
} else if i == 1 {
Cell::from(Span::styled(
format!(" {summary_text} "),
Style::default().fg(theme.muted),
))
} else {
Cell::from(Span::styled(
dashes.clone(),
Style::default().fg(next_color),
))
}
})
.collect();
Row::new(cells)
} else if !next_app_name.is_empty() {
let glyph = separator_glyph(theme.icons);
let summary_text = summary.clone();
let cells: Vec<Cell> = columns
.iter()
.enumerate()
.map(|(i, (label, _))| {
if i == 0 && *label == "NAME" {
Cell::from(Line::from(vec![
Span::styled(
"── ".to_string(),
Style::default().fg(theme.muted),
),
Span::styled(
format!("{glyph} "),
Style::default()
.fg(next_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
next_app_name.clone(),
Style::default()
.fg(next_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" ──".to_string(),
Style::default().fg(theme.muted),
),
]))
} else if i == 1 {
Cell::from(Span::styled(
format!(" {summary_text} "),
Style::default().fg(theme.muted),
))
} else {
Cell::from(Span::styled(
dashes.clone(),
Style::default().fg(next_color),
))
}
})
.collect();
Row::new(cells)
} else {
let cells = (0..count).map(|_| {
Cell::from(Span::styled(
dashes.clone(),
Style::default().fg(next_color),
))
});
Row::new(cells)
}
}
})
.collect();
let title = format!("Environments {}/{}", indexes.len(), app.environments.len());
let widths: Vec<Constraint> = columns
.iter()
.map(|(label, _)| match *label {
"NAME" => Constraint::Percentage(14),
"APPLICATION" => Constraint::Percentage(12),
"TIER" => Constraint::Length(11),
"STATUS" => Constraint::Length(10),
"HEALTH" => Constraint::Length(3),
"TREND" => Constraint::Length(12),
"PLATFORM" => Constraint::Percentage(15),
"VERSION" => Constraint::Percentage(10),
"CNAME" => Constraint::Percentage(14),
"AGE" => Constraint::Length(6),
_ => Constraint::Length(6),
})
.collect();
let popup_open = matches!(
app.mode,
Mode::Help | Mode::Picker | Mode::Command | Mode::Action | Mode::Filter
);
let block = titled_block(&theme, &title, !popup_open, theme.title)
.padding(Padding::horizontal(block_padding));
let table = Table::new(rows, widths)
.header(header)
.row_highlight_style(
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
)
.highlight_symbol(cursor_marker(&theme))
.block(block);
let hover_preview: Option<(Rect, String)> = hover.and_then(|idx| match display.get(idx)? {
DisplayRow::Env(i) => {
let e = &app.environments[*i];
let alias_part = match app.aliases.get(&e.name) {
Some(a) => format!(" alias \"{a}\""),
None => String::new(),
};
let preview = format!(
" ⓘ {}{} · {} · {} / {} · {} · {}",
e.name,
alias_part,
e.application,
e.status,
e.health,
e.platform,
redact(&e.cname, app.redact),
);
let row = Rect {
x: area.x,
y: area.y + area.height.saturating_sub(1),
width: area.width,
height: 1,
};
Some((row, preview))
}
_ => None,
});
let env_count_total = app.environments.len();
let env_count_visible = indexes.len();
f.render_stateful_widget(table, area, &mut app.table_state);
if env_count_visible == 0 {
let heading: String;
let hint: String;
if env_count_total == 0 {
heading = "no envs in this account / region".to_string();
hint = "try a different region (r) or profile (p), or check the AWS console (b)"
.to_string();
} else if app.filter.is_empty() {
heading = "no envs match the active view".to_string();
hint = "type `:views` to switch back to default, or `:filters` to drop a saved one"
.to_string();
} else {
heading = format!("no envs match `{}`", app.filter);
hint = "press / to edit, or Esc in filter mode to clear".to_string();
}
let block_height: u16 = 4;
let inner = Rect {
x: area.x + 2,
y: area
.y
.saturating_add(area.height.saturating_sub(block_height) / 2),
width: area.width.saturating_sub(4),
height: block_height.min(area.height),
};
let lines = vec![
Line::from(Span::styled(
heading,
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD),
))
.alignment(Alignment::Center),
Line::from(Span::raw("")),
Line::from(Span::styled(hint, Style::default().fg(theme.muted)))
.alignment(Alignment::Center),
];
f.render_widget(Paragraph::new(lines), inner);
}
if let Some((row, preview)) = hover_preview {
let para = Paragraph::new(Span::styled(
preview,
Style::default()
.bg(theme.row_hover_bg)
.fg(theme.text)
.add_modifier(Modifier::DIM),
));
f.render_widget(Clear, row);
f.render_widget(para, row);
}
if app.show_minimap {
draw_minimap(f, area, app);
}
}
fn draw_minimap(f: &mut Frame, area: Rect, app: &App) {
let theme = &app.theme;
let envs = &app.environments;
if envs.is_empty() {
return;
}
let max_w: u16 = area.width.saturating_sub(2).min(30);
let max_h: u16 = 4.min(area.height.saturating_sub(2));
if max_w == 0 || max_h == 0 {
return;
}
let capacity = (max_w as usize) * (max_h as usize);
let to_show: Vec<&crate::aws::Environment> = envs.iter().take(capacity).collect();
let needed_w: u16 = (to_show.len() as u16).min(max_w);
let rows_needed: u16 = (to_show.len() as u16).div_ceil(max_w);
let rows: u16 = rows_needed.min(max_h);
let map_rect = Rect {
x: area.x + area.width.saturating_sub(needed_w + 2),
y: area.y + 1,
width: needed_w + 2,
height: rows + 2,
};
f.render_widget(Clear, map_rect);
f.render_widget(
rounded_block(theme, false)
.border_style(Style::default().fg(theme.muted))
.title(Span::styled(
" minimap ",
Style::default().fg(theme.title_alt),
)),
map_rect,
);
for (i, e) in to_show.iter().enumerate() {
let row_idx = (i as u16) / max_w;
let col_idx = (i as u16) % max_w;
if row_idx >= rows {
break;
}
let cell = Rect {
x: map_rect.x + 1 + col_idx,
y: map_rect.y + 1 + row_idx,
width: 1,
height: 1,
};
let color = health_color(&e.health, theme);
f.render_widget(
Paragraph::new(Span::styled("█", Style::default().fg(color))),
cell,
);
}
}
fn tier_cell(tier: &str, theme: &Theme) -> Cell<'static> {
let label_width = "Worker".chars().count();
let (web_icon, worker_icon) = tier_icons(theme.icons);
match tier {
"Worker" => Cell::from(Line::from(vec![
pill(
&format!("{worker_icon} {:<label_width$}", "Worker"),
Color::Black,
theme.accent,
),
Span::raw(" "),
])),
"Web" => Cell::from(Line::from(vec![
pill(
&format!("{web_icon} {:<label_width$}", "Web"),
Color::Black,
theme.title,
),
Span::raw(" "),
])),
other => Cell::from(Span::styled(
other.to_string(),
Style::default().fg(theme.muted),
)),
}
}
struct PlatformStyle {
icon: &'static str,
palette_idx: usize,
}
fn platform_style(family: &str) -> Option<PlatformStyle> {
let lc = family.to_ascii_lowercase();
let (icon, palette_idx) = if lc.contains("node") {
("\u{e718}", 2) } else if lc.contains("java") || lc.contains("tomcat") || lc.contains("corretto") {
("\u{e738}", 3) } else if lc.contains("python") {
("\u{e73c}", 0) } else if lc.contains("ruby") {
("\u{e791}", 5) } else if lc.contains("php") {
("\u{e73d}", 6) } else if lc.contains(".net") || lc.contains("iis") {
("\u{e77f}", 1) } else if lc.contains("docker") {
("\u{e7b0}", 7) } else if lc.contains("go ") || lc.ends_with(" go") || lc == "go" {
("\u{e626}", 9) } else {
return None;
};
Some(PlatformStyle { icon, palette_idx })
}
fn tier_icons(icons: IconStyle) -> (&'static str, &'static str) {
match icons {
IconStyle::Ascii => ("W", "K"),
_ => ("⊕", "⚒"),
}
}
fn status_pill(status: &str, theme: &Theme) -> Span<'static> {
status_pill_for(status, theme, false)
}
fn status_pill_for(status: &str, theme: &Theme, muted: bool) -> Span<'static> {
if status.eq_ignore_ascii_case("ready") {
if muted {
Span::styled(" Ready ", Style::default().fg(theme.muted))
} else {
pill("Ready", Color::Black, theme.status_ready)
}
} else if ieq_any(status, &["updating", "launching"]) {
Span::styled(
format!(" {status} "),
Style::default()
.fg(Color::Black)
.bg(theme.status_updating)
.add_modifier(Modifier::BOLD | Modifier::SLOW_BLINK),
)
} else if ieq_any(status, &["terminating", "terminated"]) {
pill(status, Color::White, theme.status_terminating)
} else {
Span::styled(status.to_string(), Style::default().fg(theme.text))
}
}
fn draw_events(f: &mut Frame, area: Rect, app: &App) {
let scope_suffix = match app.events_for_env.as_deref() {
Some(env) => format!(" · {env}"),
None => " · all envs".to_string(),
};
let title = format!("Events {}{}", app.events.len(), scope_suffix);
let block =
titled_block(&app.theme, &title, true, app.theme.title).padding(Padding::horizontal(1));
if app.events.is_empty() {
let lines = vec![
Line::from(""),
Line::from(Span::styled(
" ◌ no events yet",
Style::default()
.fg(app.theme.muted)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
" they appear on the next refresh — ^R now, or wait for the tick",
Style::default().fg(app.theme.muted),
)),
];
let p = Paragraph::new(lines).block(block);
f.render_widget(p, area);
return;
}
let now = chrono::Utc::now();
let lines: Vec<Line> = app
.events
.iter()
.enumerate()
.map(|(i, e)| {
let when = match e.at {
Some(t) => humanize_age(now.signed_duration_since(t)),
None => "—".into(),
};
let sev_style = severity_style(&e.severity, &app.theme);
let is_cursor = app.events_cursor == Some(i);
let marker = if is_cursor { "▶ " } else { " " };
let marker_style = if is_cursor {
Style::default()
.fg(app.theme.title_alt)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(app.theme.muted)
};
Line::from(vec![
Span::styled(marker, marker_style),
Span::styled(
format!("{:>4} ", when),
Style::default().fg(app.theme.muted),
),
Span::styled(format!("{:<5} ", e.severity), sev_style),
Span::styled(
format!("{} ", env_label(e)),
Style::default()
.fg(app.theme.text)
.add_modifier(Modifier::BOLD),
),
Span::raw(e.message.clone()),
])
})
.collect();
let para = Paragraph::new(lines)
.block(block)
.scroll((app.events_scroll, 0));
f.render_widget(para, area);
}
fn env_label(e: &crate::aws::Event) -> String {
if e.env.is_empty() {
if e.application.is_empty() {
"—".into()
} else {
format!("[{}]", e.application)
}
} else if e.application.is_empty() {
format!("[{}]", e.env)
} else {
format!("[{}/{}]", e.application, e.env)
}
}
fn severity_style(s: &str, theme: &Theme) -> Style {
match s.to_uppercase().as_str() {
"ERROR" | "FATAL" => Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD),
"WARN" => Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::BOLD),
"INFO" => Style::default().fg(theme.text),
"DEBUG" | "TRACE" => Style::default().fg(theme.muted),
_ => Style::default().fg(theme.text),
}
}
fn draw_footer(f: &mut Frame, area: Rect, app: &App) {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Length(1)])
.split(area);
let mut top: Vec<Span> = Vec::new();
let theme = &app.theme;
match app.mode {
Mode::Filter => {
top.push(Span::styled(
" /",
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::BOLD),
));
top.push(Span::raw(" "));
top.push(Span::styled(
app.filter.clone(),
Style::default().fg(theme.text),
));
top.push(Span::styled(
caret_glyph(theme),
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::SLOW_BLINK),
));
top.push(Span::styled(
" [enter] apply [esc] cancel",
Style::default().fg(theme.muted),
));
}
Mode::Command => {
top.push(Span::styled(
" :",
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD),
));
top.push(Span::styled(
app.command_input.clone(),
Style::default().fg(theme.text),
));
top.push(Span::styled(
caret_glyph(theme),
Style::default()
.fg(theme.title)
.add_modifier(Modifier::SLOW_BLINK),
));
top.push(Span::styled(
" [enter] run [esc] cancel",
Style::default().fg(theme.muted),
));
}
Mode::QuickJump => {
top.push(Span::styled(
" '",
Style::default()
.fg(app.theme.accent)
.add_modifier(Modifier::BOLD),
));
top.push(Span::raw(" "));
top.push(Span::styled(
app.quickjump_input.clone(),
Style::default().fg(app.theme.text),
));
top.push(Span::styled(
caret_glyph(theme),
Style::default()
.fg(app.theme.accent)
.add_modifier(Modifier::SLOW_BLINK),
));
top.push(Span::styled(
" jump to env by name prefix [enter] keep [esc] cancel",
Style::default().fg(app.theme.muted),
));
}
_ => {
if let Some(msg) = &app.error_message {
top.push(Span::styled(
format!(" {msg}"),
Style::default().fg(theme.health_red),
));
} else if let Some(msg) = &app.status_message {
top.push(Span::styled(
format!(" {msg}"),
Style::default().fg(theme.health_yellow),
));
} else if !app.filter.is_empty() {
top.push(Span::styled(
format!(" filter: {}", app.filter),
Style::default().fg(theme.health_yellow),
));
} else if let Some(hint) = context_hint(app) {
top.push(Span::styled(
format!(" {}{hint}", hint_glyph(theme.icons)),
Style::default().fg(theme.muted),
));
}
}
}
f.render_widget(Paragraph::new(Line::from(top)), rows[0]);
let keys: String = match app.mode {
Mode::Filter => " type to filter [enter] apply [esc] cancel".into(),
Mode::Help => " j/k scroll ? / esc / q close help".into(),
Mode::Picker => " j/k move type to filter [enter] select [esc] cancel".into(),
Mode::Command => " type a command [enter] run [esc] cancel (try :help)".into(),
Mode::QuickJump => " type env name prefix [enter] keep selection [esc] cancel".into(),
Mode::Palette => " type to fuzzy-find ↑/↓ move [enter] run [esc] cancel".into(),
Mode::Normal => {
match app.focus {
crate::app::Focus::Events if app.events_visible => {
" EVENTS j/k cursor y yank line ^] back to table ^E hide esc / q".into()
}
_ => " j/k move enter drill a actions / filter : command ^K palette r region p profile ^R refresh ? help q quit".into(),
}
}
Mode::Detail => match app.detail.as_ref().map(|d| d.tab()) {
Some(crate::app::DetailTab::Instances) => {
" INSTANCES j/k move enter console s ssm shell y yank id x terminate a actions ^R refresh ? help esc / q back".into()
}
_ => " tab/shift-tab switch j/k scroll a actions ^R refresh R auto-refresh ? help esc / q back".into(),
},
Mode::Action => " j/k move enter confirm ? help esc / q cancel".into(),
Mode::Dlq => match app.dlq.as_ref().map(|d| d.viewing) {
Some(crate::app::QueueView::Main) => {
" MAIN j/k move enter view body x delete m → DLQ ^R refresh ? help esc / q back".into()
}
_ => " DLQ j/k move enter view body r resend x delete p purge m → MAIN ^R refresh ? help esc / q back".into(),
},
Mode::Shell => {
" SHELL keys → subprocess · F12 detach back to ebman · ^D / exit closes".into()
}
Mode::Form => " FORM tab/↓↑ field type to edit ^S submit esc cancel".into(),
};
f.render_widget(
Paragraph::new(Span::styled(keys, Style::default().fg(theme.muted))),
rows[1],
);
}
fn draw_help(f: &mut Frame, area: Rect, app: &mut App) {
let theme = &app.theme;
let popup = centered_rect(70, 70, area);
f.render_widget(Clear, popup);
match app.help_topic {
crate::app::HelpTopic::Detail => return draw_help_detail(f, popup, app),
crate::app::HelpTopic::Dlq => return draw_help_dlq(f, popup, app),
crate::app::HelpTopic::Action => return draw_help_action(f, popup, app),
crate::app::HelpTopic::Shell => return draw_help_shell(f, popup, app),
crate::app::HelpTopic::SavedConfigs => {
return draw_help_saved_configs(f, popup, app);
}
crate::app::HelpTopic::Global => {}
}
let interval_secs = app.refresh_interval.as_secs();
let mut lines: Vec<Line> = vec![
Line::from(Span::styled(
"ebman — keybindings",
Style::default()
.fg(app.theme.title)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
help_line("j / ↓ / wheel", "move selection down", theme),
help_line("k / ↑ / wheel", "move selection up", theme),
help_line("g / G", "jump to top / bottom", theme),
help_line("enter", "open drill-down view for the selected env", theme),
help_line(
"a",
"open actions menu (rebuild / restart / swap / terminate)",
theme,
),
help_line("b", "open selected env in the AWS console", theme),
help_line("D", "describe overlay (raw env dump as JSON)", theme),
help_line(
"!",
"diagnose selected env (events + alarms + instances + recent deploys)",
theme,
),
help_line(
"U",
"undo a pending action dispatch during its 5s cancel window",
theme,
),
help_line("f", "freeze / unfreeze auto-refresh", theme),
help_line(
"1 - 9",
"jump to env at position 1-9 in the current view",
theme,
),
help_line("'", "name-jump: type a prefix to move selection", theme),
help_line(
"Ctrl-W",
"yank equivalent `aws elasticbeanstalk describe-environments` command",
theme,
),
help_line("tab / shift-tab", "cycle scope (envs ↔ apps); Apps scope shows per-app rollup + has its own `a` / `b` / Enter", theme),
help_line("click", "select row", theme),
help_line("/", "filter rows (name, app, status, health)", theme),
help_line("s / S", "cycle sort key / toggle ascending", theme),
help_line("Ctrl-G", "toggle group-by-application", theme),
help_line("Ctrl-E", "toggle events panel", theme),
help_line("y / Y", "yank CNAME / name to clipboard", theme),
help_line("Ctrl-Y", "export filtered table as TSV to clipboard", theme),
help_line("r", "switch AWS region", theme),
help_line("p", "switch AWS profile", theme),
help_line(
"Ctrl-K",
"command palette: fuzzy search across commands / envs / views / plugins",
theme,
),
help_line("Ctrl-R / F5", "refresh now", theme),
help_line(
"Ctrl-X",
"toggle redact mode (account id, ARN, CNAMEs)",
theme,
),
help_line("?", "toggle this help", theme),
help_line("q / Ctrl-C", "quit", theme),
];
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Apps-scope keys (tab to enter)",
Style::default()
.fg(app.theme.title)
.add_modifier(Modifier::BOLD),
)));
lines.push(help_line(
"enter",
"drill into envs (filters the envs table to this application)",
theme,
));
lines.push(help_line(
"a",
"open per-app action menu (Rebuild / Restart / Deploy / Open in console)",
theme,
));
lines.push(help_line(
"b",
"open application's AWS console page in the browser",
theme,
));
lines.push(help_line("j / k / g / G", "navigate the apps table", theme));
for category in crate::commands::Category::ORDER {
let entries: Vec<&crate::commands::CommandSpec> = crate::commands::COMMANDS
.iter()
.filter(|c| c.category == *category)
.collect();
if entries.is_empty() {
continue;
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
category.label(),
Style::default()
.fg(app.theme.title)
.add_modifier(Modifier::BOLD),
)));
for c in entries {
let mut label = format!(":{}", c.name);
for alias in c.aliases {
label.push_str(&format!(" / :{alias}"));
}
lines.push(help_line(&label, c.help, theme));
}
}
if !app.plugins.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"User plugin commands (commands.toml)",
Style::default()
.fg(app.theme.title)
.add_modifier(Modifier::BOLD),
)));
for (name, plugin) in &app.plugins {
let desc = plugin
.description
.clone()
.unwrap_or_else(|| "plugin command".to_string());
lines.push(help_line(&format!(":{name}"), &desc, theme));
}
}
let mut detail_lines: Vec<Line> = vec![
Line::from(""),
Line::from(Span::styled(
"Detail-view per-tab keys",
Style::default()
.fg(app.theme.title)
.add_modifier(Modifier::BOLD),
)),
help_line(
"[ / ] (Metrics tab)",
"decrease / increase metric range (15m → 24h)",
theme,
),
help_line(
"(Logs tab) ^R",
"request tail logs (takes ~10–20s while EB samples instances)",
theme,
),
help_line(
"(Logs tab) s",
"open CW Logs streaming overlay (live tail; needs `:logs-stream on`)",
theme,
),
help_line("(Logs tab) /", "regex-filter the visible log lines", theme),
help_line(
"(Logs overlay) Tab",
"switch tailed log group via picker (over the env's discovered groups)",
theme,
),
Line::from(""),
];
lines.append(&mut detail_lines);
lines.push(Line::from(Span::styled(
format!(
"Refresh runs automatically every {interval_secs}s. Theme: {}. Configurable in ~/.config/ebman/config.toml.",
app.theme.name
),
Style::default().fg(app.theme.muted),
)));
lines.push(Line::from(Span::styled(
"Region/profile come from the standard AWS env (AWS_REGION, AWS_PROFILE).",
Style::default().fg(app.theme.muted),
)));
let total_lines = lines.len() as u16;
let inner_height = popup.height.saturating_sub(4); let body_height = inner_height.saturating_sub(1);
let max_scroll = total_lines.saturating_sub(body_height);
app.help_max_scroll = max_scroll;
let effective_scroll = app.help_scroll.min(max_scroll);
let footer_row = Rect {
x: popup.x + 2,
y: popup.y + popup.height.saturating_sub(2),
width: popup.width.saturating_sub(4),
height: 1,
};
let above_row = Rect {
x: popup.x + 2,
y: popup.y + 2, width: popup.width.saturating_sub(4),
height: 1,
};
let below_row = Rect {
x: popup.x + 2,
y: popup.y + popup.height.saturating_sub(3),
width: popup.width.saturating_sub(4),
height: 1,
};
let help = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.scroll((effective_scroll, 0))
.block(
titled_block(&app.theme, "help", true, app.theme.title_alt)
.padding(Padding::uniform(1)),
);
f.render_widget(help, popup);
let muted_hint = Style::default()
.fg(app.theme.accent)
.add_modifier(Modifier::BOLD);
if effective_scroll > 0 {
let n = effective_scroll;
f.render_widget(Clear, above_row);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!("↑ {n} more above"),
muted_hint,
))),
above_row,
);
}
if effective_scroll < max_scroll {
let n = max_scroll - effective_scroll;
f.render_widget(Clear, below_row);
f.render_widget(
Paragraph::new(Line::from(Span::styled(
format!("↓ {n} more below"),
muted_hint,
))),
below_row,
);
}
f.render_widget(Clear, footer_row);
let credit = Paragraph::new(Line::from(Span::styled(
format!(
"ebman {} · built by Tom Baldwin / Polymorphism Ltd · :about",
env!("CARGO_PKG_VERSION")
),
Style::default()
.fg(app.theme.muted)
.add_modifier(Modifier::ITALIC),
)));
f.render_widget(credit, footer_row);
}
fn draw_dlq(f: &mut Frame, area: Rect, app: &mut App) {
let theme = app.theme.clone();
let Some(dlq) = app.dlq.as_mut() else { return };
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(2),
])
.split(area);
let (window_title, view_label, accent) = match dlq.viewing {
crate::app::QueueView::Main => ("Main Worker Queue", "MAIN", theme.health_yellow),
crate::app::QueueView::Dlq => ("Dead-Letter Queue", "DLQ", theme.health_red),
};
let header = Paragraph::new(Line::from(vec![
Span::styled(format!("{view_label}: "), Style::default().fg(theme.muted)),
Span::styled(
dlq.env_name.clone(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
format!("{} messages", dlq.messages.len()),
Style::default().fg(theme.health_yellow),
),
if dlq.confirm_delete_idx.is_some() {
Span::styled(
format!(" {}delete this message? y / n", warn_glyph(theme.icons)),
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw("")
},
]))
.block(titled_block(&theme, window_title, true, accent));
f.render_widget(header, chunks[0]);
let block = rounded_block(&theme, true);
if dlq.messages.is_empty() {
let p = Paragraph::new(Span::styled(
if dlq.loading {
"loading messages…"
} else {
"no messages in DLQ"
},
Style::default().fg(theme.muted),
))
.block(block);
f.render_widget(p, chunks[1]);
} else {
let now = chrono::Utc::now();
let items: Vec<ListItem> = dlq
.messages
.iter()
.map(|m| {
let age = m
.sent_at
.map(|t| humanize_age(now.signed_duration_since(t)))
.unwrap_or_else(|| "—".into());
let preview = m.body.lines().next().unwrap_or("").to_string();
let preview = if preview.len() > 80 {
format!("{}…", &preview[..80])
} else {
preview
};
ListItem::new(Line::from(vec![
Span::styled(
format!(" {:<20} ", m.id),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
Span::styled(
format!("recv:{:<3} ", m.receive_count),
Style::default().fg(theme.health_yellow),
),
Span::styled(format!("{:>5} ", age), Style::default().fg(theme.muted)),
Span::raw(preview),
]))
})
.collect();
let list = List::new(items)
.block(block)
.highlight_style(
Style::default()
.bg(theme.row_selected_bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(cursor_marker(&theme));
f.render_stateful_widget(list, chunks[1], &mut dlq.list_state);
}
if dlq.confirm_purge {
let line = Paragraph::new(Line::from(vec![
Span::styled(
" PURGE DLQ — type ",
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD),
),
Span::styled(
dlq.env_name.clone(),
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
" to confirm: ",
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD),
),
Span::styled(
dlq.purge_typed.clone(),
Style::default()
.fg(if dlq.purge_typed == dlq.env_name {
theme.health_green
} else {
theme.text
})
.add_modifier(Modifier::BOLD),
),
Span::styled(
caret_glyph(&theme),
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::SLOW_BLINK),
),
]));
f.render_widget(line, chunks[2]);
} else {
let keys = match dlq.viewing {
crate::app::QueueView::Main => {
" MAIN j/k move enter view body x delete m → DLQ ^R refresh esc / q back"
}
crate::app::QueueView::Dlq => {
" DLQ j/k move enter view body r resend x delete p purge m → MAIN ^R refresh esc / q back"
}
};
let footer = Paragraph::new(vec![
Line::from(match &dlq.error {
Some(err) => Span::styled(format!(" {err}"), Style::default().fg(theme.health_red)),
None => Span::raw(""),
}),
Line::from(Span::styled(keys, Style::default().fg(theme.muted))),
]);
f.render_widget(footer, chunks[2]);
}
}
fn draw_action(f: &mut Frame, area: Rect, app: &mut App) {
let theme = app.theme.clone();
let Some(flow) = app.action_flow.as_mut() else {
return;
};
match flow {
ActionFlow::Menu { list_state } => {
let popup = centered_rect(50, 40, area);
f.render_widget(Clear, popup);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(1)])
.split(popup);
let items: Vec<ListItem> = ACTIONS
.iter()
.map(|a| {
let style = if a.destructive() {
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
let glyph_style = if a.destructive() {
Style::default().fg(theme.health_red)
} else {
Style::default().fg(theme.title_alt)
};
ListItem::new(Line::from(vec![
Span::styled(format!(" {} ", a.glyph(theme.icons)), glyph_style),
Span::styled(format!("{} ", a.label()), style),
]))
})
.collect();
let list = List::new(items)
.block(titled_block(&theme, "action", true, theme.title_alt))
.highlight_style(
Style::default()
.bg(theme.row_selected_bg)
.add_modifier(Modifier::BOLD),
)
.highlight_symbol(cursor_marker(&theme));
f.render_stateful_widget(list, layout[0], list_state);
f.render_widget(
Paragraph::new(Span::styled(
" j/k move [enter] select [esc] cancel",
Style::default().fg(theme.muted),
)),
layout[1],
);
}
ActionFlow::SwapTarget { source, picker } => {
let popup = centered_rect(50, 60, area);
f.render_widget(Clear, popup);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(1),
])
.split(popup);
let title = format!("swap CNAMEs: {source} ↔ ?");
let block = titled_block(&theme, &title, true, theme.title_alt);
let prompt = Paragraph::new(Line::from(vec![
Span::styled(
" /",
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(picker.filter.clone(), Style::default().fg(theme.text)),
Span::styled(
caret_glyph(&theme),
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::SLOW_BLINK),
),
]))
.block(block);
f.render_widget(prompt, layout[0]);
let filtered = picker.filtered();
let items: Vec<ListItem> = filtered
.iter()
.map(|i| {
let name = picker.items[*i].clone();
ListItem::new(Line::from(Span::styled(
format!(" {name}"),
Style::default().fg(theme.text),
)))
})
.collect();
let list = List::new(items)
.block(rounded_block(&theme, true))
.highlight_style(
Style::default()
.bg(theme.row_selected_bg)
.add_modifier(Modifier::BOLD),
);
let mut vs = ratatui::widgets::ListState::default();
if let Some(real) = picker.list_state.selected() {
vs.select(filtered.iter().position(|i| *i == real));
}
f.render_stateful_widget(list, layout[1], &mut vs);
f.render_widget(
Paragraph::new(Span::styled(
" j/k move type to filter [enter] confirm [esc] cancel",
Style::default().fg(theme.muted),
)),
layout[2],
);
}
ActionFlow::Confirm(modal) => {
let popup = centered_rect(60, 35, area);
f.render_widget(Clear, popup);
let accent = if modal.action.destructive() {
theme.health_red
} else {
theme.title_alt
};
let block = rounded_block(&theme, true)
.border_style(Style::default().fg(accent))
.title(Span::styled(
" confirm ",
Style::default().fg(accent).add_modifier(Modifier::BOLD),
));
let mut lines: Vec<Line> = Vec::new();
let summary = match modal.action {
Action::Rebuild => format!(
"Rebuild env '{}'? (terminates and recreates all instances)",
modal.target_env
),
Action::RestartAppServer => {
format!("Restart app server on env '{}'?", modal.target_env)
}
Action::SwapCnames => format!(
"Swap CNAMEs between '{}' and '{}'?",
modal.target_env,
modal.swap_with.as_deref().unwrap_or("?")
),
Action::Terminate => format!(
"TERMINATE env '{}'. This cannot be undone.",
modal.target_env
),
Action::Deploy => format!(
"Deploy version '{}' to env '{}'? (rolling, reversible)",
modal.deploy_version.as_deref().unwrap_or("?"),
modal.target_env
),
Action::UpgradePlatform => format!(
"Upgrade '{}' to platform: {} (rolling, reversible)",
modal.target_env,
modal.upgrade_platform_label.as_deref().unwrap_or("?")
),
Action::Clone => format!(
"Clone '{}' into a new env named '{}'? (creates a new env)",
modal.target_env,
modal.clone_target.as_deref().unwrap_or("?")
),
Action::Scale => format!(
"Scale '{}' to min={} / max={}? (rolling)",
modal.target_env,
modal.scale_min.unwrap_or(0),
modal.scale_max.unwrap_or(0)
),
Action::AbortUpdate => format!("Abort current update on '{}'?", modal.target_env),
Action::Capacity
| Action::ConfigSave
| Action::ConfigDelete
| Action::ConfigApply
| Action::TerminateInstance => {
format!("{} on '{}'", modal.action.label(), modal.target_env)
}
};
lines.push(Line::from(""));
let body_style = Style::default()
.fg(if modal.action.destructive() {
theme.health_red
} else {
theme.text
})
.add_modifier(Modifier::BOLD);
let name_style = if modal.action.destructive() {
Style::default()
.fg(theme.health_red)
.bg(theme.row_red_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD)
};
lines.push(highlight_env_in_summary(
&summary,
&modal.target_env,
body_style,
name_style,
));
if let Some(w) = &modal.traffic_warning {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
format!(" {}{w}", warn_glyph(theme.icons)),
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD),
)));
}
if modal.loading_dryrun {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" fetching impact…",
Style::default().fg(theme.muted),
)));
} else if let Some(dr) = &modal.dryrun {
lines.push(Line::from(""));
let inst_word = if dr.instance_count == 1 {
"instance"
} else {
"instances"
};
let az_word = if dr.az_count == 1 { "AZ" } else { "AZs" };
lines.push(Line::from(Span::styled(
format!(
" impact: {} {inst_word} across {} {az_word}",
dr.instance_count, dr.az_count
),
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::BOLD),
)));
}
if let Some(events) = &modal.recent_events {
if !events.is_empty() {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" recent events:",
Style::default().fg(theme.muted),
)));
let now = chrono::Utc::now();
for e in events.iter().take(3) {
let when = match e.at {
Some(t) => humanize_age(now.signed_duration_since(t)),
None => "—".into(),
};
lines.push(Line::from(vec![
Span::styled(
format!(" {when:>4} "),
Style::default().fg(theme.muted),
),
Span::styled(
format!("{:<5} ", e.severity),
severity_style(&e.severity, &theme),
),
Span::styled(e.message.clone(), Style::default().fg(theme.text)),
]));
}
}
} else if modal.loading_events {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" fetching recent events…",
Style::default().fg(theme.muted),
)));
}
lines.push(Line::from(""));
match modal.kind {
ConfirmKind::YesNo => {
lines.push(Line::from(Span::styled(
" [y] yes / [enter] [n] no / [esc]",
Style::default().fg(theme.muted),
)));
}
ConfirmKind::TypeName => {
lines.push(Line::from(vec![
Span::styled(" type ", Style::default().fg(theme.muted)),
Span::styled(
modal.target_env.clone(),
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(" to confirm:", Style::default().fg(theme.muted)),
]));
lines.push(Line::from(""));
let matches = modal.typed == modal.target_env;
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
modal.typed.clone(),
Style::default()
.fg(if matches {
theme.health_green
} else {
theme.text
})
.add_modifier(Modifier::BOLD),
),
Span::styled(
caret_glyph(&theme),
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::SLOW_BLINK),
),
]));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
if matches {
" [enter] terminate [esc] cancel"
} else {
" [esc] cancel"
},
Style::default().fg(theme.muted),
)));
}
}
f.render_widget(
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(block),
popup,
);
}
}
}
fn draw_detail(f: &mut Frame, area: Rect, app: &mut App) {
let Some(detail) = app.detail.as_ref() else {
return;
};
let env = detail.env_snapshot.clone();
let env = &env;
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(5), Constraint::Length(3), Constraint::Min(3), Constraint::Length(2), ])
.split(area);
let theme = &app.theme;
let mut h1 = kv("Name", &env.name, theme);
h1.push(sep(theme));
h1.extend(kv("Application", &env.application, theme));
h1.push(sep(theme));
h1.push(Span::styled("Status: ", Style::default().fg(theme.muted)));
h1.push(status_pill(&env.status, theme));
h1.push(sep(theme));
h1.push(Span::styled("Health: ", Style::default().fg(theme.muted)));
h1.push(health_dot(&env.health, theme));
h1.push(Span::raw(" "));
h1.push(Span::styled(
env.health.clone(),
health_style(&env.health, &app.theme),
));
if let Some(reco) = health_recommendation(env, app) {
h1.push(Span::raw(" "));
h1.push(Span::styled(
reco,
Style::default()
.fg(app.theme.health_yellow)
.add_modifier(Modifier::BOLD),
));
}
let cname_text = redact(&env.cname, app.redact);
let mut h2 = kv("Platform", &env.platform, theme);
h2.push(sep(theme));
h2.extend(kv("Version", &env.version_label, theme));
h2.push(sep(theme));
h2.extend(kv("CNAME", &cname_text, theme));
let header_title = format!("env: {}", env.name);
let header = Paragraph::new(vec![Line::from(h1), Line::from(h2), Line::raw("")]).block(
titled_block(&app.theme, &header_title, true, app.theme.title),
);
f.render_widget(header, chunks[0]);
let tabs_block = rounded_block(&app.theme, false);
let tab_line = render_tabs(&detail.tabs, detail.tab_idx, &app.theme);
f.render_widget(Paragraph::new(tab_line).block(tabs_block), chunks[1]);
let body_area = chunks[2];
let active_tab = detail.tab();
match active_tab {
DetailTab::Health => draw_detail_health(f, body_area, detail, app),
DetailTab::Events => draw_detail_events(f, body_area, detail, &app.theme),
DetailTab::Instances => draw_detail_instances(f, body_area, detail, &app.theme),
DetailTab::Metrics => draw_detail_metrics(f, body_area, detail, &app.theme),
DetailTab::Queue => draw_detail_queue(f, body_area, detail, app.redact, &app.theme),
DetailTab::Logs => draw_detail_logs(f, body_area, detail, &app.theme),
DetailTab::Config => draw_detail_config(
f,
body_area,
env,
detail,
app.redact,
&app.required_tags,
&app.theme,
),
}
let footer_state = DetailFooterState {
auto_refresh: detail.auto_refresh,
error: detail.error.clone(),
loading_events: detail.loading_events,
loading_instances: detail.loading_instances,
loading_queues: detail.loading_queues,
loading_metrics: detail.loading_metrics,
log_stage: detail.log_tail.stage,
};
if let Some(d) = app.detail.as_mut() {
d.metrics_body_rect = if active_tab == DetailTab::Metrics {
Some(body_area)
} else {
d.metrics_hover_col = None;
None
};
}
let auto_badge: Span<'static> = if footer_state.auto_refresh {
pill("AUTO", Color::Black, app.theme.health_green)
} else {
Span::raw("")
};
let footer = Paragraph::new(vec![
Line::from(vec![
if let Some(err) = &footer_state.error {
Span::styled(format!(" {err}"), Style::default().fg(app.theme.health_red))
} else if footer_state.loading_events
|| footer_state.loading_instances
|| footer_state.loading_queues
|| footer_state.loading_metrics
|| matches!(
footer_state.log_stage,
crate::app::LogTailStage::Requesting
| crate::app::LogTailStage::Polling
| crate::app::LogTailStage::Fetching
)
{
Span::styled(" loading…", Style::default().fg(app.theme.health_yellow))
} else {
Span::raw("")
},
Span::raw(" "),
auto_badge,
]),
Line::from(Span::styled(
detail_tab_keystrip(active_tab),
Style::default().fg(app.theme.muted),
)),
]);
f.render_widget(footer, chunks[3]);
}
fn detail_tab_keystrip(tab: DetailTab) -> &'static str {
match tab {
DetailTab::Health => {
" HEALTH j/k move enter drill tab→ Events a actions ^R refresh ? help esc back"
}
DetailTab::Instances => {
" INSTANCES j/k cursor s ssm shell i info y yank id x terminate tab→ Metrics a actions ^R refresh ? help esc back"
}
DetailTab::Events => {
" EVENTS j/k scroll / filter n/N next/prev tab→ Instances a actions ^R refresh ? help esc back"
}
DetailTab::Metrics => {
" METRICS [ / ] range hover values tab→ Queue a actions ^R refresh R auto-refresh ? help esc back"
}
DetailTab::Queue => {
" QUEUE j/k pick Main/DLQ enter view d DLQ ^R refresh ? help esc back"
}
DetailTab::Logs => {
" LOGS ^R snapshot s live-stream / filter ? help esc back"
}
DetailTab::Config => {
" CONFIG tab/shift-tab cycle a actions ^R refresh ? help esc back"
}
}
}
struct DetailFooterState {
auto_refresh: bool,
error: Option<String>,
loading_events: bool,
loading_instances: bool,
loading_queues: bool,
loading_metrics: bool,
log_stage: crate::app::LogTailStage,
}
fn render_tabs(tabs: &[DetailTab], active: usize, theme: &Theme) -> Line<'static> {
if theme.icons == IconStyle::Powerline {
let active_bg = theme.border_active;
let inactive_bg = theme.row_alt_bg;
let mut spans: Vec<Span> = Vec::with_capacity(tabs.len() * 2 + 2);
let first_bg = if active == 0 { active_bg } else { inactive_bg };
spans.push(Span::styled("\u{e0b2}", Style::default().fg(first_bg)));
for (i, t) in tabs.iter().enumerate() {
let is_active = i == active;
let bg = if is_active { active_bg } else { inactive_bg };
let fg = if is_active { Color::Black } else { theme.muted };
let label = format!(" {} {} ", tab_icon(*t, theme.icons), t.title());
let mut style = Style::default().fg(fg).bg(bg);
if is_active {
style = style.add_modifier(Modifier::BOLD);
}
spans.push(Span::styled(label, style));
let bridge_style =
if let Some(next_is_active) = tabs.get(i + 1).map(|_| i + 1 == active) {
let next_bg = if next_is_active {
active_bg
} else {
inactive_bg
};
Style::default().fg(bg).bg(next_bg)
} else {
Style::default().fg(bg)
};
spans.push(Span::styled("\u{e0b0}", bridge_style));
}
return Line::from(spans);
}
let mut spans: Vec<Span> = vec![Span::raw(" ")];
for (i, t) in tabs.iter().enumerate() {
if i > 0 {
spans.push(Span::styled(" ", Style::default()));
}
let label = format!(" {} {} ", tab_icon(*t, theme.icons), t.title());
let style = if i == active {
Style::default()
.fg(Color::Black)
.bg(theme.border_active)
.add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
} else {
Style::default().fg(theme.muted)
};
spans.push(Span::styled(label, style));
}
Line::from(spans)
}
fn draw_detail_health(f: &mut Frame, area: Rect, detail: &crate::app::DetailState, app: &App) {
let theme = &app.theme;
let env = &detail.env_snapshot;
let now = chrono::Utc::now();
let mut lines: Vec<Line> = Vec::new();
let section = |title: &str| -> Line<'static> {
Line::from(Span::styled(
format!("─── {title} "),
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD),
))
};
let muted = |s: String| -> Line<'static> {
Line::from(Span::styled(s, Style::default().fg(theme.muted)))
};
let items = crate::app::health_items(detail, now);
let active_item: Option<crate::app::HealthItem> = items.get(detail.health_cursor).copied();
let cursor_glyph = cursor_marker(theme);
let item_prefix = |is_active: bool| -> Span<'static> {
if is_active {
Span::styled(
cursor_glyph.to_string(),
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD),
)
} else {
Span::raw(" ")
}
};
let mut status_line: Vec<Span<'static>> = vec![
Span::styled(" status: ", Style::default().fg(theme.muted)),
status_pill(&env.status, theme),
Span::raw(" "),
Span::styled("health: ", Style::default().fg(theme.muted)),
health_dot(&env.health, theme),
Span::raw(" "),
Span::styled(
env.health.clone(),
Style::default()
.fg(health_color(&env.health, theme))
.add_modifier(Modifier::BOLD),
),
];
let is_worker = env.tier.eq_ignore_ascii_case("Worker");
let dlq_depth = if is_worker {
app.worker_dlq_depths.get(&env.name).copied().unwrap_or(0)
} else {
0
};
if dlq_depth > 0 {
status_line.push(Span::raw(" "));
status_line.push(Span::styled(
format!("{}DLQ:{dlq_depth}", warn_glyph(theme.icons)),
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD),
));
}
if env.status.eq_ignore_ascii_case("Updating") {
use crate::app::UpdateKind;
let kind_label: Option<String> = match crate::app::classify_update_kind(&detail.events) {
UpdateKind::Deploy {
version_label: Some(label),
} => Some(format!("deploying {label}")),
UpdateKind::Deploy {
version_label: None,
} => Some("deploying a new version".into()),
UpdateKind::Config => Some("config change".into()),
UpdateKind::Scale => Some("scaling instances".into()),
UpdateKind::Platform => Some("platform update".into()),
UpdateKind::Generic => None,
};
if let Some(label) = kind_label {
status_line.push(Span::raw(" "));
status_line.push(Span::styled(
format!("→ {label}"),
Style::default()
.fg(theme.status_updating)
.add_modifier(Modifier::BOLD),
));
}
}
lines.push(Line::from(status_line));
lines.push(Line::raw(""));
lines.push(section("recent events (last 30 min · errors + warnings)"));
if detail.loading_events && detail.events.is_empty() {
lines.push(muted(" fetching events…".into()));
} else {
let cutoff = now - chrono::Duration::minutes(30);
let recent: Vec<(usize, &crate::aws::Event)> = detail
.events
.iter()
.enumerate()
.filter(|(_, e)| {
let sev = e.severity.to_uppercase();
sev == "ERROR" || sev == "WARN"
})
.filter(|(_, e)| e.at.map(|t| t >= cutoff).unwrap_or(true))
.take(10)
.collect();
if recent.is_empty() {
lines.push(muted(
" (no error / warning events in the last 30 min)".into(),
));
} else {
for (idx, e) in recent {
let when =
e.at.map(|t| t.with_timezone(&chrono::Local).format("%H:%M").to_string())
.unwrap_or_else(|| "??:??".into());
let sev_style = match e.severity.to_uppercase().as_str() {
"ERROR" => Style::default().fg(theme.health_red),
"WARN" => Style::default().fg(theme.health_yellow),
_ => Style::default().fg(theme.muted),
};
let is_active =
active_item == Some(crate::app::HealthItem::Event { event_idx: idx });
lines.push(Line::from(vec![
item_prefix(is_active),
Span::styled(format!("{when} "), Style::default().fg(theme.muted)),
Span::styled(format!("{:<5}", e.severity), sev_style),
Span::raw(" "),
Span::styled(e.message.clone(), Style::default().fg(theme.text)),
]));
}
}
}
lines.push(Line::raw(""));
lines.push(section("instances"));
if detail.loading_instances && detail.instances.is_empty() {
lines.push(muted(" fetching instances…".into()));
} else if detail.instances.is_empty() {
lines.push(muted(" (no instances reported)".into()));
} else {
let mut buckets: std::collections::BTreeMap<String, u32> =
std::collections::BTreeMap::new();
for i in &detail.instances {
*buckets.entry(i.color.clone()).or_default() += 1;
}
let total = detail.instances.len();
let mut summary_spans = vec![Span::styled(
format!(" {total} instance(s) · "),
Style::default().fg(theme.muted),
)];
for (color, count) in &buckets {
let style = match color.as_str() {
"Red" => Style::default().fg(theme.health_red),
"Yellow" => Style::default().fg(theme.health_yellow),
"Green" => Style::default().fg(theme.health_green),
_ => Style::default().fg(theme.muted),
};
summary_spans.push(Span::styled(format!("{count} {color} "), style));
}
lines.push(Line::from(summary_spans));
let mut shown = 0;
for (idx, i) in detail.instances.iter().enumerate() {
if shown >= 3 {
break;
}
let red =
i.color.eq_ignore_ascii_case("Red") || i.health.eq_ignore_ascii_case("Severe");
if !red {
continue;
}
shown += 1;
let is_active =
active_item == Some(crate::app::HealthItem::Instance { instance_idx: idx });
lines.push(Line::from(vec![
item_prefix(is_active),
Span::styled(i.id.clone(), Style::default().fg(theme.text)),
Span::raw(" "),
Span::styled(
i.health.clone(),
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD),
),
]));
for cause in i.causes.iter().take(2) {
lines.push(Line::from(Span::styled(
format!(" ↳ {cause}"),
Style::default().fg(theme.muted),
)));
}
}
}
lines.push(Line::raw(""));
let alarms_present = matches!(&detail.cw_alarms, Some(Ok(a)) if !a.is_empty());
let alarms_loading = detail.loading_cw_alarms && detail.cw_alarms.is_none();
if alarms_present || alarms_loading {
lines.push(section("alarms"));
if alarms_loading {
lines.push(muted(" fetching alarms…".into()));
} else if let Some(Ok(als)) = &detail.cw_alarms {
let mut sorted: Vec<&crate::aws::CwAlarm> = als.iter().collect();
sorted.sort_by_key(|a| match a.state.as_str() {
"ALARM" => 0,
"INSUFFICIENT_DATA" => 1,
_ => 2,
});
for a in sorted.iter().take(8) {
let (tag, style) = match a.state.as_str() {
"ALARM" => (
"ALARM",
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD),
),
"OK" => ("OK ", Style::default().fg(theme.health_green)),
_ => ("INS ", Style::default().fg(theme.muted)),
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(tag.to_string(), style),
Span::raw(" "),
Span::styled(
a.name.clone(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" ({}/{})", a.namespace, a.metric_name),
Style::default().fg(theme.muted),
),
]));
}
}
lines.push(Line::raw(""));
} else if let Some(Err(e)) = &detail.cw_alarms {
lines.push(section("alarms"));
lines.push(Line::from(Span::styled(
format!(" error: {e}"),
Style::default().fg(theme.health_red),
)));
lines.push(Line::raw(""));
}
let versions_present = matches!(&detail.recent_versions, Some(Ok(v)) if !v.is_empty());
let versions_loading = detail.loading_recent_versions && detail.recent_versions.is_none();
if versions_present || versions_loading {
lines.push(section("recent deploys"));
if versions_loading {
lines.push(muted(" fetching deploys…".into()));
} else if let Some(Ok(vers)) = &detail.recent_versions {
for v in vers.iter().take(3) {
let when = v
.created
.map(|t| humanize_age(now.signed_duration_since(t)))
.unwrap_or_else(|| "—".into());
let when_style = Style::default().fg(age_color(v.created, now, theme));
let mut spans = vec![
Span::raw(" "),
Span::styled(
v.label.clone(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
Span::styled(format!(" {when} ago"), when_style),
];
if !v.description.is_empty() {
spans.push(Span::styled(
format!(" — {}", truncate_for_display(&v.description, 60)),
Style::default().fg(theme.muted),
));
}
lines.push(Line::from(spans));
}
}
lines.push(Line::raw(""));
}
if is_worker {
lines.push(section("worker queues"));
if detail.loading_queues {
lines.push(muted(" fetching queue depths…".into()));
} else {
let q = &detail.queues;
let main_text = match q.main_stats.as_ref() {
Some(s) => format!(
"main: visible={} in-flight={} delayed={}",
s.visible, s.in_flight, s.delayed
),
None => "main: (queue URL not resolved)".to_string(),
};
let main_active = active_item == Some(crate::app::HealthItem::MainQueue);
lines.push(Line::from(vec![
item_prefix(main_active),
Span::styled(main_text, Style::default().fg(theme.text)),
]));
let dlq_visible = q.dlq_stats.as_ref().map(|s| s.visible).unwrap_or(0);
let dlq_text = match q.dlq_stats.as_ref() {
Some(s) => format!(
"dlq: visible={} in-flight={} delayed={}",
s.visible, s.in_flight, s.delayed
),
None => "dlq: (queue URL not resolved)".to_string(),
};
let dlq_style = if dlq_visible > 0 {
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
let dlq_active = active_item == Some(crate::app::HealthItem::Dlq);
lines.push(Line::from(vec![
item_prefix(dlq_active),
Span::styled(dlq_text, dlq_style),
]));
}
lines.push(Line::raw(""));
}
lines.push(muted(
" ── tab → drill into Events / Instances / Metrics / Queue / Logs / Config ──".into(),
));
let block = rounded_block(theme, false).padding(Padding::horizontal(1));
let p = Paragraph::new(lines)
.wrap(Wrap { trim: false })
.block(block);
f.render_widget(p, area);
}
fn draw_detail_events(f: &mut Frame, area: Rect, detail: &crate::app::DetailState, theme: &Theme) {
let matches = if let Some(re) = detail.search_pattern.as_ref() {
detail
.events
.iter()
.filter(|e| re.is_match(&e.message))
.count()
} else {
0
};
let title = if detail.search_pattern.is_some() {
format!(" Events [{}] · matches: {matches} ", detail.events.len())
} else {
format!(" Events [{}] ", detail.events.len())
};
let outer = rounded_block(theme, true)
.title(Span::styled(
title,
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD),
))
.padding(Padding::horizontal(1));
let inner = outer.inner(area);
f.render_widget(outer, area);
let show_search_bar =
detail.search_active || detail.search_pattern.is_some() || detail.search_error.is_some();
let (search_area, body_area) = if show_search_bar {
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(inner);
(Some(rows[0]), rows[1])
} else {
(None, inner)
};
if let Some(sa) = search_area {
let mut spans = vec![
Span::styled(
"/",
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(detail.search_input.clone(), Style::default().fg(theme.text)),
];
if detail.search_active {
spans.push(Span::styled(
caret_glyph(theme),
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::SLOW_BLINK),
));
spans.push(Span::styled(
" [enter] apply [esc] cancel",
Style::default().fg(theme.muted),
));
} else if let Some(err) = &detail.search_error {
spans.push(Span::styled(
format!(" {err}"),
Style::default().fg(theme.health_red),
));
} else if detail.search_pattern.is_some() {
spans.push(Span::styled(
" n / N next/prev / re-edit esc clear",
Style::default().fg(theme.muted),
));
}
f.render_widget(Paragraph::new(Line::from(spans)), sa);
}
if detail.events.is_empty() && !detail.loading_events {
let lines = vec![
Line::from(""),
Line::from(Span::styled(
" ◌ no events for this env",
Style::default()
.fg(theme.muted)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
" ^R to re-fetch, R to toggle auto-refresh",
Style::default().fg(theme.muted),
)),
];
let p = Paragraph::new(lines);
f.render_widget(p, body_area);
return;
}
let now = chrono::Utc::now();
let re = detail.search_pattern.as_ref();
let lines: Vec<Line> = detail
.events
.iter()
.map(|e| {
let when = match e.at {
Some(t) => humanize_age(now.signed_duration_since(t)),
None => "—".into(),
};
let matches = re.is_some_and(|r| r.is_match(&e.message));
let msg_style = if matches {
Style::default()
.fg(Color::Black)
.bg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
Line::from(vec![
Span::styled(format!("{:>4} ", when), Style::default().fg(theme.muted)),
Span::styled(
format!("{:<5} ", e.severity),
severity_style(&e.severity, theme),
),
Span::styled(e.message.clone(), msg_style),
])
})
.collect();
let p = Paragraph::new(lines).scroll((detail.events_scroll, 0));
f.render_widget(p, body_area);
}
fn draw_detail_instances(
f: &mut Frame,
area: Rect,
detail: &crate::app::DetailState,
theme: &Theme,
) {
let block = rounded_block(theme, true)
.title(Span::styled(
format!(" Instances [{}] ", detail.instances.len()),
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD),
))
.padding(Padding::horizontal(1));
if detail.instances.is_empty() && !detail.loading_instances {
let lines = vec![
Line::from(""),
Line::from(Span::styled(
" ◌ no instance data",
Style::default()
.fg(theme.muted)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
" env may be terminating, or DescribeInstancesHealth not yet warm",
Style::default().fg(theme.muted),
)),
];
let p = Paragraph::new(lines).block(block);
f.render_widget(p, area);
return;
}
let now = chrono::Utc::now();
let cursor_idx = detail.instances_cursor;
let confirming = detail.instance_terminate_confirm.is_some();
let mut lines: Vec<Line> = Vec::new();
if confirming {
if let Some(idx) = detail.instance_terminate_confirm {
if let Some(inst) = detail.instances.get(idx) {
lines.push(Line::from(Span::styled(
format!(
" {}TERMINATE instance {}? ASG will replace it. y / n",
warn_glyph(theme.icons),
inst.id
),
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD),
)));
lines.push(Line::from(""));
}
}
}
for (idx, i) in detail.instances.iter().enumerate() {
let age = i
.launched_at
.map(|t| humanize_age(now.signed_duration_since(t)))
.unwrap_or_else(|| "—".into());
let is_cursor = idx == cursor_idx;
let row_bg = if is_cursor {
Some(theme.row_selected_bg)
} else {
None
};
let with_bg = |s: Style| match row_bg {
Some(bg) => s.bg(bg),
None => s,
};
let marker = if is_cursor { "▶ " } else { " " };
let marker_style = with_bg(if is_cursor {
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.muted)
});
let id_style = with_bg(if is_cursor {
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text).add_modifier(Modifier::BOLD)
});
let head = vec![
Span::styled(marker.to_string(), marker_style),
Span::styled(format!("{:<19} ", i.id), id_style),
Span::styled(
format!("{:<8} ", i.health),
with_bg(health_style(&i.color, theme)),
),
Span::styled(
format!("{:<12} ", i.instance_type),
with_bg(Style::default().fg(theme.muted)),
),
Span::styled(
format!("{:<14} ", i.availability_zone),
with_bg(Style::default().fg(theme.muted)),
),
Span::styled(
format!("up {age}"),
with_bg(Style::default().fg(theme.muted)),
),
];
lines.push(Line::from(head));
for cause in &i.causes {
lines.push(Line::from(Span::styled(
format!(" ↳ {cause}"),
with_bg(Style::default().fg(theme.health_yellow)),
)));
}
}
let p = Paragraph::new(lines)
.block(block)
.scroll((detail.instances_scroll, 0));
f.render_widget(p, area);
}
fn draw_detail_metrics(f: &mut Frame, area: Rect, detail: &crate::app::DetailState, theme: &Theme) {
let title_text = if detail.metrics_hover_col.is_some() {
format!(
"Metrics · last {} · CloudWatch · cursor pinned (mouse to roam)",
humanize_range(detail.metrics_range_secs)
)
} else {
format!(
"Metrics · last {} · CloudWatch",
humanize_range(detail.metrics_range_secs)
)
};
let outer = titled_block(theme, &title_text, true, theme.title).padding(Padding::horizontal(1));
let inner = outer.inner(area);
f.render_widget(outer, area);
if detail.metrics.is_empty() {
let msg = if detail.loading_metrics {
"loading metrics…"
} else {
"no metrics returned — env may be too new, or CloudWatch perms missing"
};
f.render_widget(
Paragraph::new(Span::styled(msg, Style::default().fg(theme.muted))),
inner,
);
return;
}
let n = detail.metrics.len() as u16;
if n == 0 || inner.height < n {
return;
}
let per = (inner.height / n).max(3);
let constraints: Vec<Constraint> = (0..n).map(|_| Constraint::Length(per)).collect();
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints(constraints)
.split(inner);
for (i, series) in detail.metrics.iter().enumerate() {
let series_color = match series.id.as_str() {
"health" => theme.health_green,
"req4xx" => theme.health_yellow,
"req5xx" => theme.health_red,
"p90" => theme.title,
_ => theme.text,
};
let values: Vec<f64> = series.points.iter().map(|(_, v)| *v).collect();
let max = values.iter().copied().fold(0.0_f64, f64::max);
let min = values.iter().copied().fold(f64::INFINITY, f64::min);
let min = if min.is_infinite() { 0.0 } else { min };
let last = values.last().copied().unwrap_or(0.0);
let first = values.first().copied().unwrap_or(last);
let delta = last - first;
let anomaly = series_anomaly_label(&series.id, &values);
let hover_value = detail
.metrics_hover_col
.and_then(|col| hover_index(col, inner, values.len()))
.and_then(|idx| values.get(idx).copied());
let mut title_spans: Vec<Span<'static>> = vec![Span::styled(
format!("{:<26} ", series.label),
Style::default()
.fg(series_color)
.add_modifier(Modifier::BOLD),
)];
if values.is_empty() {
title_spans.push(Span::styled(
"(no data in window)",
Style::default().fg(theme.muted),
));
} else {
title_spans.push(Span::styled(
format!("now {} ", format_metric(&series.id, last)),
Style::default().fg(theme.text),
));
title_spans.push(Span::styled(
format!("max {} ", format_metric(&series.id, max)),
Style::default().fg(theme.muted),
));
title_spans.push(Span::styled(
format!("min {} ", format_metric(&series.id, min)),
Style::default().fg(theme.muted),
));
title_spans.push(delta_span(delta, &series.id, theme));
}
if let Some(label) = anomaly {
title_spans.push(Span::raw(" "));
title_spans.push(Span::styled(
label,
Style::default()
.fg(theme.health_red)
.add_modifier(Modifier::BOLD),
));
}
if let Some(hv) = hover_value {
title_spans.push(Span::raw(" "));
title_spans.push(Span::styled(
format!("@cursor {}", format_metric(&series.id, hv)),
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD),
));
}
let title = Line::from(title_spans);
let row_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(rows[i]);
f.render_widget(Paragraph::new(title), row_layout[0]);
let pts: Vec<(f64, f64)> = values
.iter()
.enumerate()
.map(|(idx, v)| (idx as f64, *v))
.collect();
if pts.is_empty() {
continue;
}
let max_x = (pts.len() as f64 - 1.0).max(1.0);
let max_y = (max * 1.1).max(1.0);
let dataset = Dataset::default()
.marker(Marker::Braille)
.graph_type(GraphType::Line)
.style(Style::default().fg(series_color))
.data(&pts);
let chart = Chart::new(vec![dataset])
.style(Style::default())
.x_axis(Axis::default().bounds([0.0, max_x]))
.y_axis(Axis::default().bounds([0.0, max_y]));
f.render_widget(chart, row_layout[1]);
}
}
pub fn hover_index(col: u16, area: Rect, n: usize) -> Option<usize> {
if n == 0 || area.width < 2 {
return None;
}
if col < area.x || col >= area.x.saturating_add(area.width) {
return None;
}
let rel = (col - area.x) as f64;
let width = (area.width - 1) as f64;
let scaled = (rel / width) * (n as f64 - 1.0);
Some(scaled.round() as usize)
}
pub fn series_anomaly_label(id: &str, values: &[f64]) -> Option<String> {
if values.len() < 4 {
return None;
}
let last = *values.last()?;
let prior = &values[..values.len() - 1];
let sum: f64 = prior.iter().copied().filter(|v| v.is_finite()).sum();
let count = prior.iter().filter(|v| v.is_finite()).count() as f64;
if count == 0.0 {
return None;
}
let mean = sum / count;
if mean <= 0.0 || !last.is_finite() {
return None;
}
let (multiplier, glyph) = match id {
"req5xx" => (2.0_f64, "▲ anomaly: 5xx > 2× baseline"),
"req4xx" => (2.0_f64, "▲ anomaly: 4xx > 2× baseline"),
"p90" => (1.5_f64, "▲ anomaly: latency > 1.5× baseline"),
_ => return None,
};
if last > mean * multiplier {
Some(glyph.to_string())
} else {
None
}
}
fn delta_span(delta: f64, id: &str, theme: &Theme) -> Span<'static> {
if delta.abs() < f64::EPSILON {
return Span::styled("Δ flat", Style::default().fg(theme.muted));
}
let arrow = if delta >= 0.0 { "▲" } else { "▼" };
let color = match (id, delta >= 0.0) {
("health", true) => theme.health_red,
("health", false) => theme.health_green,
("req4xx" | "req5xx" | "p90", true) => theme.health_red,
("req4xx" | "req5xx" | "p90", false) => theme.health_green,
_ => theme.text,
};
Span::styled(
format!("Δ {arrow} {}", format_metric(id, delta.abs())),
Style::default().fg(color).add_modifier(Modifier::BOLD),
)
}
fn format_metric(id: &str, v: f64) -> String {
match id {
"health" => format!("{:.0}", v),
"p90" => {
if v >= 1.0 {
format!("{:.2}s", v)
} else {
format!("{:.0}ms", v * 1000.0)
}
}
_ => format!("{:.0}", v),
}
}
fn humanize_range(secs: i64) -> String {
if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86_400)
}
}
fn draw_detail_queue(
f: &mut Frame,
area: Rect,
detail: &crate::app::DetailState,
redact_on: bool,
theme: &Theme,
) {
let block = rounded_block(theme, true)
.title(Span::styled(
" Queue ",
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD),
))
.padding(Padding::horizontal(2));
let q = &detail.queues;
let scale = q
.main_stats
.as_ref()
.into_iter()
.chain(q.dlq_stats.as_ref())
.flat_map(|s| [s.visible, s.in_flight, s.delayed])
.max()
.unwrap_or(1)
.max(1);
let row = |label: &'static str, value: String, hi: Option<Color>| -> Line {
let v_style = match hi {
Some(c) => Style::default().fg(c).add_modifier(Modifier::BOLD),
None => Style::default().fg(theme.text),
};
Line::from(vec![
Span::styled(format!("{label:<22}"), Style::default().fg(theme.muted)),
Span::styled(value, v_style),
])
};
let stats_row = |label: &'static str, s: Option<&crate::aws::QueueStats>| -> Vec<Line> {
match s {
Some(s) => {
let bar = |n: i64, color: Color| -> Span<'static> {
Span::styled(micro_bar(n, scale, 12), Style::default().fg(color))
};
vec![
Line::from(vec![
Span::styled(format!("{label:<22}"), Style::default().fg(theme.muted)),
Span::styled(
format!("visible: {:>5} ", s.visible),
Style::default()
.fg(if s.visible > 0 {
theme.health_yellow
} else {
theme.text
})
.add_modifier(Modifier::BOLD),
),
bar(s.visible, theme.health_yellow),
]),
Line::from(vec![
Span::styled(format!("{:<22}", ""), Style::default().fg(theme.muted)),
Span::styled(
format!("in-flight:{:>5} ", s.in_flight),
Style::default().fg(theme.text),
),
bar(s.in_flight, theme.app_palette[0]),
]),
Line::from(vec![
Span::styled(format!("{:<22}", ""), Style::default().fg(theme.muted)),
Span::styled(
format!("delayed: {:>5} ", s.delayed),
Style::default().fg(theme.muted),
),
bar(s.delayed, theme.app_palette[1]),
]),
]
}
None => vec![row(label, "—".into(), None)],
}
};
let main_selected = detail.queue_cursor == 0;
let dlq_selected = detail.queue_cursor == 1;
let queue_row = |selected: bool, label: &str, value: String| -> Line<'static> {
let (marker, marker_style) = if selected {
(
"▶ ",
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD),
)
} else {
(" ", Style::default().fg(theme.muted))
};
let label_style = if selected {
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.muted)
};
let value_style = if selected {
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text)
};
Line::from(vec![
Span::styled(marker.to_string(), marker_style),
Span::styled(format!("{label:<20}"), label_style),
Span::styled(value, value_style),
])
};
let mut lines = Vec::new();
lines.push(queue_row(
main_selected,
"Main queue URL",
redact(q.main_url.as_deref().unwrap_or("—"), redact_on),
));
lines.extend(stats_row(" stats", q.main_stats.as_ref()));
lines.push(Line::from(""));
lines.push(queue_row(
dlq_selected,
"DLQ URL",
redact(q.dlq_url.as_deref().unwrap_or("—"), redact_on),
));
lines.extend(stats_row(" stats", q.dlq_stats.as_ref()));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" j/k pick queue · enter view messages · d quick-open DLQ",
Style::default().fg(theme.muted),
)));
if detail.loading_queues {
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
" loading queue stats…",
Style::default().fg(theme.health_yellow),
)));
}
f.render_widget(Paragraph::new(lines).block(block), area);
}
fn draw_detail_logs(f: &mut Frame, area: Rect, detail: &crate::app::DetailState, theme: &Theme) {
use crate::app::LogTailStage;
let tail = &detail.log_tail;
let lines_total: usize = tail
.by_instance
.iter()
.map(|(_, t)| t.lines().count())
.sum();
let matches = if let Some(re) = tail.search_pattern.as_ref() {
tail.by_instance
.iter()
.map(|(_, t)| t.lines().filter(|l| re.is_match(l)).count())
.sum::<usize>()
} else {
0
};
let title = if tail.search_pattern.is_some() {
format!(
" Logs · {} instance(s) · {lines_total} lines · matches: {matches} ",
tail.by_instance.len()
)
} else {
format!(
" Logs · {} instance(s) · {lines_total} lines ",
tail.by_instance.len()
)
};
let outer = rounded_block(theme, true)
.title(Span::styled(
title,
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD),
))
.padding(Padding::horizontal(1));
let inner = outer.inner(area);
f.render_widget(outer, area);
let stage_line: Line<'static> = match tail.stage {
LogTailStage::Idle => {
let hint = match detail.cw_log_groups.as_ref() {
Some(groups) if !groups.is_empty() => {
" press ^R for one-shot snapshot · s to live-stream CW Logs"
}
Some(_) => {
" press ^R for one-shot snapshot · CW Logs not configured (`:logs-stream on` to enable)"
}
None => " press ^R for one-shot snapshot · s to live-stream CW Logs (checking…)",
};
Line::from(Span::styled(hint, Style::default().fg(theme.muted)))
}
LogTailStage::Requesting => Line::from(Span::styled(
" requesting tail from EB…",
Style::default().fg(theme.health_yellow),
)),
LogTailStage::Polling => Line::from(Span::styled(
format!(
" waiting for instance samples (attempt {}/12)…",
tail.poll_attempt.max(1)
),
Style::default().fg(theme.health_yellow),
)),
LogTailStage::Fetching => Line::from(Span::styled(
" fetching log content…",
Style::default().fg(theme.health_yellow),
)),
LogTailStage::Ready => {
if let Some(err) = &tail.error {
Line::from(Span::styled(
format!(" {err}"),
Style::default().fg(theme.health_red),
))
} else if tail.search_active || tail.search_pattern.is_some() {
let mut spans = vec![
Span::styled(
"/",
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(tail.search_input.clone(), Style::default().fg(theme.text)),
];
if tail.search_active {
spans.push(Span::styled(
caret_glyph(theme),
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::SLOW_BLINK),
));
spans.push(Span::styled(
" [enter] apply [esc] cancel",
Style::default().fg(theme.muted),
));
} else if let Some(err) = &tail.search_error {
spans.push(Span::styled(
format!(" {err}"),
Style::default().fg(theme.health_red),
));
}
Line::from(spans)
} else {
Line::from(Span::styled(
" ^R refresh / search esc clear",
Style::default().fg(theme.muted),
))
}
}
};
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.split(inner);
f.render_widget(Paragraph::new(stage_line), rows[0]);
let mut body: Vec<Line<'static>> = Vec::new();
if tail.by_instance.is_empty() && tail.stage != LogTailStage::Ready {
body.push(Line::from(Span::styled(
" (no content yet)",
Style::default().fg(theme.muted),
)));
}
for (instance_id, text) in &tail.by_instance {
body.push(Line::from(Span::styled(
format!("── {instance_id} "),
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD),
)));
for raw in text.lines() {
if let Some(re) = tail.search_pattern.as_ref() {
if !re.is_match(raw) {
continue;
}
}
body.push(Line::from(Span::styled(
raw.to_string(),
Style::default().fg(theme.text),
)));
}
body.push(Line::from(""));
}
let scroll = (tail.scroll, 0);
f.render_widget(
Paragraph::new(body)
.wrap(Wrap { trim: false })
.scroll(scroll),
rows[1],
);
}
fn draw_detail_config(
f: &mut Frame,
area: Rect,
env: &crate::aws::Environment,
detail: &crate::app::DetailState,
redact_on: bool,
required_tags: &[String],
theme: &Theme,
) {
let block = titled_block(theme, "Config", true, theme.title).padding(Padding::horizontal(2));
let updated = env
.updated
.map(|u| {
u.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S %Z")
.to_string()
})
.unwrap_or_else(|| "—".into());
let row = |label: &'static str, value: String| -> Line<'static> {
Line::from(vec![
Span::styled(format!("{label:<14}"), Style::default().fg(theme.muted)),
Span::styled(value, Style::default().fg(theme.text)),
])
};
let mut lines: Vec<Line<'static>> = vec![
row("Environment", env.name.clone()),
row("Application", env.application.clone()),
row("Tier", env.tier.clone()),
row("Status", env.status.clone()),
row("Health", env.health.clone()),
row("Platform", env.platform.clone()),
row("Version", env.version_label.clone()),
row("CNAME", redact(&env.cname, redact_on)),
row("Updated", updated),
];
lines.push(Line::raw(""));
if detail.loading_instances && detail.instances.is_empty() {
lines.push(Line::from(Span::styled(
"Est. cost loading…",
Style::default().fg(theme.muted),
)));
} else if detail.instances.is_empty() {
lines.push(Line::from(Span::styled(
"Est. cost no running instances",
Style::default().fg(theme.muted),
)));
} else {
let (hourly, missing) = crate::app::estimate_cost(&detail.instances);
let monthly = hourly * 730.0; let mut summary = format!(
"{} instance{} ~ ${:.2}/hr ~ ${:.0}/mo",
detail.instances.len(),
if detail.instances.len() == 1 { "" } else { "s" },
hourly,
monthly,
);
if missing > 0 {
summary.push_str(&format!(
" ({missing} unknown type{})",
if missing == 1 { "" } else { "s" }
));
}
lines.push(Line::from(vec![
Span::styled(
format!("{:<14}", "Est. cost"),
Style::default().fg(theme.muted),
),
Span::styled(summary, Style::default().fg(theme.text)),
]));
lines.push(Line::from(Span::styled(
" (approximate, us-east-1 on-demand Linux rates)",
Style::default().fg(theme.muted),
)));
}
lines.push(Line::raw(""));
if detail.loading_tags && detail.tags.is_empty() {
lines.push(Line::from(Span::styled(
"Tags loading…",
Style::default().fg(theme.muted),
)));
} else if detail.tags.is_empty() {
lines.push(Line::from(Span::styled(
"Tags (none)",
Style::default().fg(theme.muted),
)));
} else {
lines.push(Line::from(Span::styled(
format!("Tags ({} total)", detail.tags.len()),
Style::default().fg(theme.muted),
)));
let mut sorted: Vec<(&String, &String)> = detail.tags.iter().map(|(k, v)| (k, v)).collect();
sorted.sort_by_key(|(k, _)| k.to_lowercase());
let max_key_width: usize = sorted
.iter()
.map(|(k, _)| k.chars().count())
.max()
.unwrap_or(0)
.clamp(12, 40);
for (k, v) in &sorted {
let key_len = k.chars().count();
let key_text = if key_len <= max_key_width {
format!(" {k:<width$}", width = max_key_width)
} else {
format!(" {k}\n {pad:<width$}", pad = "", width = max_key_width)
};
lines.push(Line::from(vec![
Span::styled(key_text, Style::default().fg(theme.app_palette[0])),
Span::raw(" "),
Span::styled(v.to_string(), Style::default().fg(theme.text)),
]));
}
}
if !required_tags.is_empty() {
let present: std::collections::HashSet<&str> =
detail.tags.iter().map(|(k, _)| k.as_str()).collect();
let missing: Vec<&str> = required_tags
.iter()
.filter(|r| !present.contains(r.as_str()))
.map(|r| r.as_str())
.collect();
if !missing.is_empty() {
lines.push(Line::raw(""));
lines.push(Line::from(vec![
Span::styled("Tag policy ", Style::default().fg(theme.muted)),
Span::styled(
format!(
"{}missing required tag(s): {}",
warn_glyph(theme.icons),
missing.join(", ")
),
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::BOLD),
),
]));
}
}
lines.push(Line::raw(""));
if detail.loading_env_vars && detail.env_vars.is_empty() {
lines.push(Line::from(Span::styled(
"Env vars loading…",
Style::default().fg(theme.muted),
)));
} else if detail.env_vars.is_empty() {
lines.push(Line::from(Span::styled(
"Env vars (none — set with `:env set KEY VAL`)",
Style::default().fg(theme.muted),
)));
} else {
lines.push(Line::from(Span::styled(
format!("Env vars ({} total)", detail.env_vars.len()),
Style::default().fg(theme.muted),
)));
let max_key_width: usize = detail
.env_vars
.iter()
.map(|(k, _)| k.chars().count())
.max()
.unwrap_or(0)
.clamp(12, 40);
for (k, v) in &detail.env_vars {
let key_len = k.chars().count();
let key_text = if key_len <= max_key_width {
format!(" {k:<width$}", width = max_key_width)
} else {
format!(" {k}\n {pad:<width$}", pad = "", width = max_key_width)
};
let value = if v.is_empty() { "\"\"" } else { v.as_str() };
lines.push(Line::from(vec![
Span::styled(key_text, Style::default().fg(theme.app_palette[1])),
Span::raw(" "),
Span::styled(value.to_string(), Style::default().fg(theme.text)),
]));
}
}
f.render_widget(Paragraph::new(lines).block(block), area);
}
fn draw_picker(f: &mut Frame, area: Rect, app: &mut App) {
let theme = app.theme.clone();
let Some(picker) = app.picker.as_mut() else {
return;
};
let popup = centered_rect(50, 60, area);
f.render_widget(Clear, popup);
let layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(1),
])
.split(popup);
let filter_block = titled_block(&theme, picker.title().trim(), true, theme.title_alt);
let filter_inner = Paragraph::new(Line::from(vec![
Span::styled(
" /",
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(picker.filter.clone(), Style::default().fg(theme.text)),
Span::styled(
caret_glyph(&theme),
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::SLOW_BLINK),
),
]))
.block(filter_block);
f.render_widget(filter_inner, layout[0]);
let filtered = picker.filtered();
let items: Vec<ListItem> = filtered
.iter()
.map(|i| {
let name = picker.items[*i].clone();
ListItem::new(Line::from(Span::styled(
format!(" {name}"),
Style::default().fg(theme.text),
)))
})
.collect();
let list_block = rounded_block(&theme, true);
let list = List::new(items).block(list_block).highlight_style(
Style::default()
.bg(theme.row_selected_bg)
.add_modifier(Modifier::BOLD),
);
let mut visible_state = ratatui::widgets::ListState::default();
if let Some(real) = picker.list_state.selected() {
visible_state.select(filtered.iter().position(|i| *i == real));
}
f.render_stateful_widget(list, layout[1], &mut visible_state);
let hint = Paragraph::new(Span::styled(
" j/k move type to filter enter select esc cancel",
Style::default().fg(theme.muted),
));
f.render_widget(hint, layout[2]);
}
fn draw_help_detail(f: &mut Frame, popup: Rect, app: &App) {
let theme = &app.theme;
let lines = vec![
Line::from(Span::styled(
"Detail view — keybindings",
Style::default()
.fg(app.theme.title)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
help_line(
"tab / l",
"next tab (Events → Instances → Metrics → Queue → Logs → Config)",
theme,
),
help_line("shift-tab / h", "previous tab", theme),
help_line(
"j / k",
"scroll within active tab (cursor on Instances / Queue tabs)",
theme,
),
help_line("^R", "re-fetch active tab's data", theme),
help_line("R", "toggle per-tab auto-refresh", theme),
help_line("a", "actions menu (rebuild / restart / deploy / …)", theme),
help_line("b", "open env in AWS console", theme),
help_line("D", "describe overlay (raw env dump as JSON)", theme),
help_line("d", "open DLQ for this env (Worker tier only)", theme),
help_line("*", "pin / unpin", theme),
Line::from(""),
Line::from(Span::styled(
"Events tab",
Style::default().fg(app.theme.title),
)),
help_line("/", "regex filter event messages", theme),
help_line("n / N", "jump next / previous match", theme),
Line::from(""),
Line::from(Span::styled(
"Metrics tab",
Style::default().fg(app.theme.title),
)),
help_line(
"[ / ]",
"decrease / increase metric range (15m → 24h)",
theme,
),
help_line(
"mouse hover",
"show metric value at cursor x-position",
theme,
),
Line::from(""),
Line::from(Span::styled(
"Instances tab",
Style::default().fg(app.theme.title),
)),
help_line(
"enter / i",
"open instance info overlay (id, type, AZ, health, causes)",
theme,
),
help_line("b", "open instance in EC2 console (browser)", theme),
help_line("s", "embedded SSM shell into selected instance", theme),
help_line("y", "yank instance ID", theme),
help_line(
"x",
"terminate selected instance (Y/N; ASG replaces)",
theme,
),
Line::from(""),
Line::from(Span::styled(
"Queue tab",
Style::default().fg(app.theme.title),
)),
help_line("j / k", "pick Main / DLQ", theme),
help_line("enter", "open queue viewer", theme),
help_line("d", "quick-open DLQ", theme),
Line::from(""),
Line::from(Span::styled(
"Logs tab",
Style::default().fg(app.theme.title),
)),
help_line(
"^R",
"request tail logs (10-20s wait for instance samples)",
theme,
),
help_line(
"s",
"open CW Logs streaming overlay (requires `:logs-stream on`)",
theme,
),
help_line("/", "regex filter visible lines", theme),
Line::from(""),
Line::from(Span::styled(
"esc / q to close help; from Normal mode `?` shows the full keymap",
Style::default().fg(theme.muted),
)),
];
let p = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
titled_block(&app.theme, "help — Detail", true, app.theme.title_alt)
.padding(Padding::uniform(1)),
);
f.render_widget(p, popup);
}
fn draw_help_dlq(f: &mut Frame, popup: Rect, app: &App) {
let theme = &app.theme;
let lines = vec![
Line::from(Span::styled(
"Queue viewer — keybindings",
Style::default()
.fg(app.theme.title)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
help_line("j / k", "move cursor", theme),
help_line("enter", "view full message body", theme),
help_line("r", "resend selected (DLQ → main) — DLQ view only", theme),
help_line("x", "delete selected message (Y/N confirm)", theme),
help_line(
"p",
"purge queue (strict typed-name confirm) — DLQ view only",
theme,
),
help_line("m", "toggle Main ↔ DLQ", theme),
help_line(
"^R",
"refetch messages (deeper peek with long-polling)",
theme,
),
help_line("esc / q", "close viewer", theme),
Line::from(""),
Line::from(Span::styled(
"Resend and purge are disabled in Main view — too dangerous on a live queue.",
Style::default().fg(theme.muted),
)),
];
let p = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
titled_block(&app.theme, "help — Queue", true, app.theme.title_alt)
.padding(Padding::uniform(1)),
);
f.render_widget(p, popup);
}
fn draw_help_action(f: &mut Frame, popup: Rect, app: &App) {
let theme = &app.theme;
let lines = vec![
Line::from(Span::styled(
"Action menu — keybindings",
Style::default()
.fg(app.theme.title)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
help_line("j / k", "move cursor between actions", theme),
help_line(
"enter",
"select; opens confirm modal (or picker for Swap)",
theme,
),
help_line("esc", "close menu", theme),
Line::from(""),
Line::from(Span::styled(
"Confirm modal",
Style::default().fg(app.theme.title),
)),
help_line("y / enter", "confirm and dispatch", theme),
help_line("n / esc", "cancel", theme),
help_line(
"(typing)",
"TypeName confirm (Terminate) — must match env name exactly",
theme,
),
Line::from(""),
Line::from(Span::styled(
"Parameterised actions (Deploy / Upgrade / Clone / Scale) close the menu",
Style::default().fg(theme.muted),
)),
Line::from(Span::styled(
"and prefill the command bar; type the arg and Enter to run.",
Style::default().fg(theme.muted),
)),
];
let p = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
titled_block(&app.theme, "help — Action", true, app.theme.title_alt)
.padding(Padding::uniform(1)),
);
f.render_widget(p, popup);
}
fn draw_help_saved_configs(f: &mut Frame, popup: Rect, app: &App) {
let theme = &app.theme;
let lines = vec![
Line::from(Span::styled(
"Saved configurations — keybindings",
Style::default()
.fg(app.theme.title)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
help_line("j / k / arrows", "move cursor up / down", theme),
help_line("g / G", "jump to top / bottom", theme),
help_line(
"enter / a",
"apply selected template to the currently-selected env",
theme,
),
help_line(
"i",
"inspect template — open its option settings as a sorted text dump",
theme,
),
help_line(
"c",
"close overlay + prefill `:config-save ` to save current env as a new template",
theme,
),
help_line(
"x",
"delete selected template (Y/N confirm — config templates are recreatable)",
theme,
),
help_line("?", "this help", theme),
help_line("esc / q", "close overlay", theme),
Line::from(""),
Line::from(Span::styled(
"Apply target = whichever env the table cursor is on.",
Style::default().fg(theme.muted),
)),
];
let p = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
titled_block(
&app.theme,
"help — Saved Configs",
true,
app.theme.title_alt,
)
.padding(Padding::uniform(1)),
);
f.render_widget(p, popup);
}
fn draw_help_shell(f: &mut Frame, popup: Rect, app: &App) {
let theme = &app.theme;
let lines = vec![
Line::from(Span::styled(
"Embedded shell — keybindings",
Style::default()
.fg(app.theme.title)
.add_modifier(Modifier::BOLD),
)),
Line::from(""),
Line::from(Span::styled(
"Almost every key is forwarded to the subprocess. Exceptions:",
Style::default().fg(theme.muted),
)),
Line::from(""),
help_line(
"F12",
"detach back to ebman (subprocess keeps running)",
theme,
),
help_line("^D / exit", "close the session", theme),
Line::from(""),
Line::from(Span::styled(
"Open from Instances tab → s on a selected instance.",
Style::default().fg(theme.muted),
)),
];
let p = Paragraph::new(lines).wrap(Wrap { trim: false }).block(
titled_block(&app.theme, "help — Shell", true, app.theme.title_alt)
.padding(Padding::uniform(1)),
);
f.render_widget(p, popup);
}
fn help_line(key: &str, desc: &str, theme: &Theme) -> Line<'static> {
let key_col = 16usize;
let formatted = if key.chars().count() < key_col {
format!(" {key:<width$}", width = key_col)
} else {
format!(" {key} ")
};
Line::from(vec![
Span::styled(
formatted,
Style::default()
.fg(theme.health_yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(desc.to_string(), Style::default().fg(theme.text)),
])
}
fn health_recommendation(env: &crate::aws::Environment, app: &App) -> Option<String> {
let history = app.history.get(&env.name)?;
if history.is_empty() {
return None;
}
let last = history.back()?.to_lowercase();
let is_bad = matches!(last.as_str(), "red" | "severe" | "yellow" | "warning");
if !is_bad {
return None;
}
let target = last.clone();
let consecutive = history
.iter()
.rev()
.take_while(|s| s.to_lowercase() == target)
.count();
if consecutive < 4 {
return None;
}
let secs = consecutive as u64 * app.refresh_interval.as_secs();
let approx = humanize_duration(secs);
let label = if target.eq_ignore_ascii_case("red") || target.eq_ignore_ascii_case("severe") {
"Red"
} else {
"Yellow"
};
Some(format!("≥ {approx} in {label}"))
}
fn humanize_duration(secs: u64) -> String {
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h{}m", secs / 3600, (secs % 3600) / 60)
} else {
format!("{}d{}h", secs / 86_400, (secs % 86_400) / 3600)
}
}
fn breadcrumb_line(app: &App) -> Line<'static> {
let theme = &app.theme;
let crumb_sep_glyph = if theme.icons == IconStyle::Powerline {
" \u{e0b1} "
} else {
" / "
};
let region = app.context.region.clone();
let mut spans: Vec<Span<'static>> = vec![Span::styled(
region,
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD),
)];
let env = match (app.mode, app.detail.as_ref()) {
(Mode::Detail, Some(d)) => Some((d.env_snapshot.application.clone(), d.env_name.clone())),
_ => app
.selected_env()
.map(|e| (e.application.clone(), e.name.clone())),
};
if let Some((app_name, env_name)) = env {
spans.push(Span::styled(
crumb_sep_glyph,
Style::default().fg(theme.muted),
));
spans.push(Span::styled(
app_name,
Style::default()
.fg(theme.title_alt)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::styled(
crumb_sep_glyph,
Style::default().fg(theme.muted),
));
spans.push(Span::styled(
env_name,
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
));
}
Line::from(spans)
}
fn kv<'a>(key: &'a str, value: &'a str, theme: &Theme) -> Vec<Span<'a>> {
vec![
Span::styled(format!("{key}: "), Style::default().fg(theme.muted)),
Span::styled(
value.to_string(),
Style::default().fg(theme.text).add_modifier(Modifier::BOLD),
),
]
}
fn sep(theme: &Theme) -> Span<'static> {
let glyph = if theme.icons == IconStyle::Powerline {
" \u{e0b1} "
} else {
" • "
};
Span::styled(glyph, Style::default().fg(theme.muted))
}
fn ieq_any(s: &str, options: &[&str]) -> bool {
options.iter().any(|o| s.eq_ignore_ascii_case(o))
}
fn cursor_marker(theme: &Theme) -> &'static str {
if theme.icons == IconStyle::Powerline {
"\u{e0b0} "
} else {
"▌ "
}
}
fn caret_glyph(theme: &Theme) -> &'static str {
if theme.icons == IconStyle::Ascii {
"_"
} else {
"\u{258e}"
}
}
fn separator_glyph(icons: IconStyle) -> &'static str {
match icons {
IconStyle::Ascii => ">",
_ => "▶",
}
}
fn warn_glyph(icons: IconStyle) -> &'static str {
match icons {
IconStyle::Ascii => "! ",
_ => "⚠ ",
}
}
fn hint_glyph(icons: IconStyle) -> &'static str {
match icons {
IconStyle::Ascii => "? ",
_ => "💡 ",
}
}
fn stripe_glyph(icons: IconStyle) -> &'static str {
match icons {
IconStyle::Ascii => "|",
_ => "▎",
}
}
fn sparkline_for(
samples: Option<&std::collections::VecDeque<String>>,
theme: &Theme,
pulse_last: bool,
) -> Line<'static> {
let Some(samples) = samples else {
return Line::from(Span::raw(" ".repeat(SPARKLINE_WIDTH)));
};
let pad = SPARKLINE_WIDTH.saturating_sub(samples.len());
let mut spans: Vec<Span<'static>> = Vec::with_capacity(SPARKLINE_WIDTH);
if pad > 0 {
spans.push(Span::raw(" ".repeat(pad)));
}
let start = samples.len().saturating_sub(SPARKLINE_WIDTH);
let visible: Vec<&String> = samples.iter().skip(start).collect();
let visible_len = visible.len();
for (i, h) in visible.iter().enumerate() {
let color = health_color(h, theme);
let darker = scale_rgb(color, 0.6);
let style = Style::default().fg(color).bg(darker);
let (glyph, style) = if pulse_last && i + 1 == visible_len {
(
"█",
style.add_modifier(Modifier::BOLD | Modifier::SLOW_BLINK),
)
} else {
("▇", style)
};
spans.push(Span::styled(glyph, style));
}
Line::from(spans)
}
fn scale_rgb(color: Color, factor: f32) -> Color {
let factor = factor.clamp(0.0, 1.0);
if let Color::Rgb(r, g, b) = color {
Color::Rgb(
(r as f32 * factor) as u8,
(g as f32 * factor) as u8,
(b as f32 * factor) as u8,
)
} else {
color
}
}
fn health_style(health: &str, theme: &Theme) -> Style {
Style::default()
.fg(health_color(health, theme))
.add_modifier(Modifier::BOLD)
}
fn health_color(health: &str, theme: &Theme) -> Color {
if ieq_any(health, &["green", "ok"]) {
theme.health_green
} else if ieq_any(health, &["yellow", "warning"]) {
theme.health_yellow
} else if ieq_any(health, &["red", "severe", "degraded"]) {
theme.health_red
} else if ieq_any(health, &["grey", "gray", "info", "no data", "pending"]) {
theme.health_grey
} else {
theme.text
}
}
fn redact(value: &str, on: bool) -> String {
if !on || value.is_empty() || value == "—" {
return value.to_string();
}
"▓".repeat(value.chars().count())
}
fn short_caller(arn: &str) -> String {
arn.splitn(6, ':').nth(5).unwrap_or(arn).to_string()
}
fn highlight_env_in_summary(
summary: &str,
env_name: &str,
body_style: Style,
name_style: Style,
) -> Line<'static> {
let needle = format!("'{env_name}'");
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled(" ".to_string(), body_style));
if let Some(idx) = summary.find(&needle) {
let before = &summary[..idx];
let after = &summary[idx + needle.len()..];
if !before.is_empty() {
spans.push(Span::styled(before.to_string(), body_style));
}
spans.push(Span::styled(format!(" {env_name} "), name_style));
if !after.is_empty() {
spans.push(Span::styled(after.to_string(), body_style));
}
} else {
spans.push(Span::styled(summary.to_string(), body_style));
}
Line::from(spans)
}
fn context_hint(app: &App) -> Option<String> {
if app.alerts > 0 {
return Some(
"`!` on a Red env opens :why (events + alarms + instances + recent deploys)".into(),
);
}
let in_flight = app
.pending_actions
.iter()
.filter(|p| p.completed.is_none())
.count();
if in_flight >= 3 {
return Some(format!(
"{in_flight} actions in flight — `:pending` to review"
));
}
if let Some(exp) = app.sso_expiry {
let remaining = exp.signed_duration_since(chrono::Utc::now());
if remaining > chrono::Duration::zero() && remaining < chrono::Duration::minutes(15) {
return Some(format!(
"SSO expires in {}m — `aws sso login --profile {}`",
remaining.num_minutes().max(0),
app.context.profile.as_deref().unwrap_or("default")
));
}
}
if !app.newly_added.is_empty() {
let n = app.newly_added.len();
let env_word = if n == 1 { "env" } else { "envs" };
return Some(format!("{n} new {env_word} this refresh (marked +)"));
}
None
}
fn summarize_in_flight(labels: &[&str]) -> String {
use std::collections::BTreeMap;
if labels.is_empty() {
return String::new();
}
let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
for l in labels {
let stem = l
.split_whitespace()
.next()
.unwrap_or("")
.to_ascii_lowercase();
let entry = counts.entry(label_stem(&stem)).or_insert(0);
*entry += 1;
}
let mut parts: Vec<String> = counts
.iter()
.map(|(name, n)| {
if *n > 1 {
format!("{name} ×{n}")
} else {
(*name).to_string()
}
})
.collect();
parts.sort();
let mut joined = parts.join(", ");
const MAX: usize = 25;
if joined.chars().count() > MAX {
joined = joined.chars().take(MAX - 1).collect::<String>();
joined.push('…');
}
joined
}
fn label_stem(word: &str) -> &'static str {
match word {
"rebuild" => "rebuild",
"restart" => "restart",
"swap" => "swap",
"terminate" => "terminate",
"deploy" => "deploy",
"upgrade" => "upgrade",
"clone" => "clone",
"scale" => "scale",
"abort" => "abort",
"save" => "config-save",
"delete" => "delete",
"apply" => "config-apply",
_ => "action",
}
}
fn summarize_group(envs: &[&Environment]) -> String {
if envs.is_empty() {
return String::new();
}
let total = envs.len();
let mut web = 0usize;
let mut worker = 0usize;
let mut red = 0usize;
let mut yellow = 0usize;
for e in envs {
match e.tier.as_str() {
"Web" => web += 1,
"Worker" => worker += 1,
_ => {}
}
match e.health.to_lowercase().as_str() {
"red" | "severe" | "degraded" => red += 1,
"yellow" | "warning" => yellow += 1,
_ => {}
}
}
let env_word = if total == 1 { "env" } else { "envs" };
let mut parts: Vec<String> = vec![format!("{total} {env_word}")];
if web > 0 && worker > 0 {
parts.push(format!("{web} Web"));
parts.push(format!("{worker} Worker"));
}
if red > 0 {
parts.push(format!("{red} red"));
}
if yellow > 0 {
parts.push(format!("{yellow} yellow"));
}
parts.join(" · ")
}
fn format_refresh_label(
last_refresh: Option<chrono::DateTime<chrono::Utc>>,
now: chrono::DateTime<chrono::Utc>,
refresh_interval: std::time::Duration,
) -> String {
let interval_s = refresh_interval.as_secs() as i64;
match last_refresh {
Some(t) => {
let ago = now.signed_duration_since(t).num_seconds().max(0);
let until = (interval_s - ago).max(0);
format!("{}s ago · next {}s", ago, until)
}
None => format!("— · next {interval_s}s"),
}
}
fn humanize_age(d: chrono::Duration) -> String {
let secs = d.num_seconds().max(0);
if secs < 60 {
format!("{}s", secs)
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86_400)
}
}
fn age_color(
updated: Option<chrono::DateTime<chrono::Utc>>,
now: chrono::DateTime<chrono::Utc>,
theme: &Theme,
) -> Color {
let Some(u) = updated else {
return theme.muted;
};
let dur = now.signed_duration_since(u);
if dur < chrono::Duration::zero() || dur < chrono::Duration::hours(24) {
theme.title_alt
} else if dur > chrono::Duration::days(30) {
theme.muted
} else {
theme.text
}
}
fn draw_shell(f: &mut Frame, area: Rect, app: &mut App) {
let Some(shell) = app.current_shell.as_ref() else {
return;
};
let theme = &app.theme;
let footer_rows: u16 = 1;
let outer_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(1), Constraint::Length(footer_rows)])
.split(area);
let title_text = format!(" ⌥ {} F12 detach ^D / exit close ", shell.label);
let block = rounded_block(theme, true)
.border_style(Style::default().fg(theme.title))
.title(Span::styled(
title_text,
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD),
));
let body = block.inner(outer_chunks[0]);
f.render_widget(block, outer_chunks[0]);
shell.resize(body.height, body.width);
let mut cursor_pos: Option<(u16, u16)> = None;
if let Ok(parser) = shell.parser.lock() {
let screen = parser.screen();
let (cur_row, cur_col) = screen.cursor_position();
let buf = f.buffer_mut();
for row in 0..body.height {
for col in 0..body.width {
let cell = screen.cell(row, col);
let target_x = body.x + col;
let target_y = body.y + row;
if target_x >= buf.area.x.saturating_add(buf.area.width)
|| target_y >= buf.area.y.saturating_add(buf.area.height)
{
continue;
}
let target = &mut buf[(target_x, target_y)];
match cell {
Some(c) => {
let sym = c.contents();
target.set_symbol(if sym.is_empty() { " " } else { &sym });
let mut style = Style::default();
style = style.fg(vt100_color_to_ratatui(c.fgcolor()));
style = style.bg(vt100_color_to_ratatui(c.bgcolor()));
let mut mods = Modifier::empty();
if c.bold() {
mods |= Modifier::BOLD;
}
if c.italic() {
mods |= Modifier::ITALIC;
}
if c.underline() {
mods |= Modifier::UNDERLINED;
}
if c.inverse() {
mods |= Modifier::REVERSED;
}
style = style.add_modifier(mods);
target.set_style(style);
}
None => {
target.set_symbol(" ");
target.set_style(Style::default());
}
}
}
}
if cur_row < body.height && cur_col < body.width && !screen.hide_cursor() {
cursor_pos = Some((body.x + cur_col, body.y + cur_row));
}
}
if let Some((cx, cy)) = cursor_pos {
f.set_cursor_position((cx, cy));
}
let footer = Line::from(Span::styled(
" SHELL keys forwarded to subprocess · F12 detach · ^D / exit closes ",
Style::default().fg(theme.muted),
));
f.render_widget(Paragraph::new(footer), outer_chunks[1]);
}
fn vt100_color_to_ratatui(c: vt100::Color) -> Color {
match c {
vt100::Color::Default => Color::Reset,
vt100::Color::Idx(i) => Color::Indexed(i),
vt100::Color::Rgb(r, g, b) => Color::Rgb(r, g, b),
}
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let v = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(v[1])[1]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn warn_glyph_falls_back_in_ascii() {
assert_eq!(super::warn_glyph(IconStyle::Ascii), "! ");
assert_eq!(super::warn_glyph(IconStyle::Unicode), "⚠ ");
assert_eq!(super::warn_glyph(IconStyle::Powerline), "⚠ ");
}
#[test]
fn hint_glyph_falls_back_in_ascii() {
assert_eq!(super::hint_glyph(IconStyle::Ascii), "? ");
assert_eq!(super::hint_glyph(IconStyle::Unicode), "💡 ");
}
#[test]
fn stripe_glyph_falls_back_in_ascii() {
assert_eq!(super::stripe_glyph(IconStyle::Ascii), "|");
assert_eq!(super::stripe_glyph(IconStyle::Unicode), "▎");
}
#[test]
fn prune_pills_keeps_first_under_width() {
let theme = crate::theme::Theme::dark();
let mut pills: Vec<(String, ratatui::style::Color, ratatui::style::Color)> = vec![
("ALERTS".into(), Color::Black, theme.health_red),
("PENDING".into(), Color::Black, theme.health_yellow),
("READ-ONLY".into(), Color::Black, theme.health_green),
];
super::prune_pills_to_width(&mut pills, &theme, 10);
assert!(!pills.is_empty());
assert_eq!(pills[0].0.split(' ').next().unwrap(), "ALERTS");
}
#[test]
fn prune_pills_marks_overflow_count_on_last_pill() {
let theme = crate::theme::Theme::dark();
let mut pills: Vec<(String, ratatui::style::Color, ratatui::style::Color)> = vec![
("ALERTS".into(), Color::Black, theme.health_red),
("PENDING".into(), Color::Black, theme.health_yellow),
("READ-ONLY".into(), Color::Black, theme.health_green),
("UPDATE".into(), Color::Black, theme.title_alt),
];
super::prune_pills_to_width(&mut pills, &theme, 10);
assert_eq!(pills.len(), 1);
assert!(
pills[0].0.contains("+3"),
"expected last-pill marker '+3' on survivor, got {:?}",
pills[0].0
);
}
#[test]
fn prune_pills_noop_when_chain_fits() {
let theme = crate::theme::Theme::dark();
let mut pills: Vec<(String, ratatui::style::Color, ratatui::style::Color)> = vec![
("A".into(), Color::Black, theme.health_red),
("B".into(), Color::Black, theme.health_yellow),
];
let before = pills.clone();
super::prune_pills_to_width(&mut pills, &theme, 1_000);
assert_eq!(pills, before, "wide budget should not trim or mark");
}
#[test]
fn hover_index_maps_column_to_point() {
use ratatui::layout::Rect;
let area = Rect::new(10, 0, 11, 5); assert_eq!(super::hover_index(10, area, 11), Some(0));
assert_eq!(super::hover_index(20, area, 11), Some(10));
assert_eq!(super::hover_index(15, area, 11), Some(5));
assert_eq!(super::hover_index(9, area, 11), None);
assert_eq!(super::hover_index(21, area, 11), None);
assert_eq!(super::hover_index(10, area, 0), None);
}
#[test]
fn series_anomaly_flags_5xx_spike() {
let v = vec![1.0, 1.0, 1.0, 1.0, 10.0];
assert!(super::series_anomaly_label("req5xx", &v).is_some());
}
#[test]
fn series_anomaly_quiet_when_stable() {
let v = vec![5.0, 5.0, 5.0, 5.0, 5.5];
assert!(super::series_anomaly_label("req5xx", &v).is_none());
}
#[test]
fn series_anomaly_ignores_unrelated_id() {
let v = vec![1.0, 1.0, 1.0, 1.0, 99.0];
assert!(super::series_anomaly_label("health", &v).is_none());
}
#[test]
fn series_anomaly_handles_short_series() {
let v = vec![1.0, 9.0];
assert!(super::series_anomaly_label("req5xx", &v).is_none());
}
#[test]
fn age_color_fresh_uses_title_alt() {
let t = Theme::dark();
let now = chrono::Utc::now();
let updated = now - chrono::Duration::hours(2);
assert_eq!(super::age_color(Some(updated), now, &t), t.title_alt);
}
#[test]
fn age_color_normal_uses_text() {
let t = Theme::dark();
let now = chrono::Utc::now();
let updated = now - chrono::Duration::days(5);
assert_eq!(super::age_color(Some(updated), now, &t), t.text);
}
#[test]
fn age_color_stale_uses_muted() {
let t = Theme::dark();
let now = chrono::Utc::now();
let updated = now - chrono::Duration::days(45);
assert_eq!(super::age_color(Some(updated), now, &t), t.muted);
}
#[test]
fn age_color_missing_uses_muted() {
let t = Theme::dark();
let now = chrono::Utc::now();
assert_eq!(super::age_color(None, now, &t), t.muted);
}
#[test]
fn age_color_future_clock_skew_is_fresh_not_stale() {
let t = Theme::dark();
let now = chrono::Utc::now();
let updated = now + chrono::Duration::seconds(30);
assert_eq!(super::age_color(Some(updated), now, &t), t.title_alt);
}
#[test]
fn age_color_boundary_at_24h_is_normal() {
let t = Theme::dark();
let now = chrono::Utc::now();
let updated = now - chrono::Duration::hours(24);
assert_eq!(super::age_color(Some(updated), now, &t), t.text);
}
#[test]
fn humanize_age_buckets() {
use chrono::Duration;
assert_eq!(humanize_age(Duration::seconds(45)), "45s");
assert_eq!(humanize_age(Duration::seconds(120)), "2m");
assert_eq!(humanize_age(Duration::seconds(3601)), "1h");
assert_eq!(humanize_age(Duration::seconds(2 * 86_400)), "2d");
assert_eq!(humanize_age(Duration::seconds(-30)), "0s");
}
#[test]
fn humanize_duration_buckets() {
assert_eq!(humanize_duration(15), "15s");
assert_eq!(humanize_duration(90), "1m");
assert_eq!(humanize_duration(3700), "1h1m");
assert_eq!(humanize_duration(2 * 86_400 + 3 * 3600), "2d3h");
}
#[test]
fn humanize_range_picks_unit() {
assert_eq!(humanize_range(900), "15m");
assert_eq!(humanize_range(3600), "1h");
assert_eq!(humanize_range(2 * 86_400), "2d");
}
#[test]
fn short_caller_extracts_principal() {
assert_eq!(
short_caller("arn:aws:iam::123456789012:user/alice"),
"user/alice"
);
assert_eq!(
short_caller("arn:aws:sts::123456789012:assumed-role/Foo/session-name"),
"assumed-role/Foo/session-name"
);
assert_eq!(short_caller("not-an-arn"), "not-an-arn");
}
#[test]
fn redact_passthrough_when_off() {
assert_eq!(redact("hello", false), "hello");
}
#[test]
fn redact_blocks_chars_when_on() {
let out = redact("hello", true);
assert_eq!(out.chars().count(), 5);
assert!(out.chars().all(|c| c == '▓'));
}
#[test]
fn redact_keeps_placeholder() {
assert_eq!(redact("—", true), "—");
assert_eq!(redact("", true), "");
}
#[test]
fn format_metric_branches() {
assert_eq!(format_metric("health", 12.0), "12");
assert_eq!(format_metric("p90", 0.250), "250ms");
assert_eq!(format_metric("p90", 1.5), "1.50s");
assert_eq!(format_metric("req4xx", 42.0), "42");
}
#[test]
fn micro_bar_renders() {
assert_eq!(micro_bar(0, 100, 10), "");
let half = micro_bar(50, 100, 10);
assert!(half.chars().count() <= 10);
assert!(half.chars().any(|c| c == '█'));
let full = micro_bar(100, 100, 10);
assert_eq!(full.chars().count(), 10);
}
#[test]
fn micro_bar_guards_invalid_inputs() {
assert_eq!(micro_bar(10, 0, 10), "");
assert_eq!(micro_bar(10, 100, 0), "");
assert_eq!(micro_bar(-5, 100, 10), "");
assert_eq!(micro_bar(999, 100, 5).chars().count(), 5);
}
#[test]
fn spinner_cycles_through_frames() {
let a = spinner(150, IconStyle::Unicode);
let b = spinner(199, IconStyle::Unicode);
assert_eq!(a, b);
assert_ne!(a, spinner(250, IconStyle::Unicode));
assert!(SPINNER_FRAMES.contains(&a));
let ascii = spinner(0, IconStyle::Ascii);
assert!(ASCII_SPINNER.contains(&ascii));
}
#[test]
fn visible_window_anchors_to_top_when_items_fit() {
assert_eq!(visible_window(0, 5, 10), (0, 5));
assert_eq!(visible_window(4, 5, 10), (0, 5));
}
#[test]
fn visible_window_slides_to_keep_cursor_visible() {
assert_eq!(visible_window(0, 20, 5), (0, 5));
assert_eq!(visible_window(1, 20, 5), (0, 5));
let (s, e) = visible_window(10, 20, 5);
assert!(s <= 10 && 10 < e, "expected cursor 10 inside [{s},{e})");
assert_eq!(e - s, 5);
assert_eq!(visible_window(19, 20, 5), (15, 20));
}
#[test]
fn visible_window_handles_empty_and_zero_budget() {
assert_eq!(visible_window(0, 0, 10), (0, 0));
let (s, e) = visible_window(3, 10, 0);
assert!(s <= 3 && 3 < e);
}
#[test]
fn cursor_marker_swaps_per_icon_style() {
let mut t = Theme::dark();
t.icons = IconStyle::Unicode;
assert_eq!(cursor_marker(&t), "▌ ");
t.icons = IconStyle::Ascii;
assert_eq!(cursor_marker(&t), "▌ ");
t.icons = IconStyle::Powerline;
assert!(cursor_marker(&t).contains('\u{e0b0}'));
}
#[test]
fn highlight_env_in_summary_breaks_at_quoted_name() {
let body = Style::default().fg(Color::White);
let name = Style::default().fg(Color::Red);
let line = highlight_env_in_summary(
"Rebuild env 'prod-api'? (terminates and recreates)",
"prod-api",
body,
name,
);
let env_spans: Vec<&Span> = line
.spans
.iter()
.filter(|s| s.content.contains("prod-api"))
.collect();
assert_eq!(env_spans.len(), 1);
assert!(
!env_spans[0].content.contains('\''),
"name span should not include the surrounding single quotes: {:?}",
env_spans[0].content
);
}
#[test]
fn highlight_env_in_summary_falls_back_when_name_missing() {
let body = Style::default().fg(Color::White);
let name = Style::default().fg(Color::Red);
let line =
highlight_env_in_summary("Some action with no env reference", "prod-api", body, name);
assert!(line.spans.iter().any(|s| s.content.contains("Some action")));
}
#[test]
fn summarize_in_flight_collapses_duplicates() {
let s = summarize_in_flight(&["Rebuild env", "Rebuild env", "Deploy version"]);
assert!(s.contains("rebuild ×2"), "got {s:?}");
assert!(s.contains("deploy"), "got {s:?}");
}
#[test]
fn summarize_in_flight_truncates() {
let s = summarize_in_flight(&[
"Terminate env",
"Rebuild env",
"Restart env",
"Deploy version",
"Swap CNAMEs",
]);
assert!(
s.chars().count() <= 25,
"got {} chars: {s:?}",
s.chars().count()
);
}
#[test]
fn summarize_in_flight_empty() {
assert_eq!(summarize_in_flight(&[]), "");
}
#[test]
fn summarize_group_omits_empty_buckets() {
fn e(tier: &str, health: &str) -> Environment {
Environment {
name: "n".into(),
application: "a".into(),
tier: tier.into(),
status: "Ready".into(),
health: health.into(),
cname: "".into(),
platform: "".into(),
version_label: "".into(),
updated: None,
id: None,
region: None,
arn: None,
}
}
let envs = [e("Web", "Green"), e("Web", "Green"), e("Web", "Red")];
let refs: Vec<&Environment> = envs.iter().collect();
let s = summarize_group(&refs);
assert!(s.contains("3 envs"));
assert!(s.contains("1 red"));
assert!(!s.contains("Worker"));
assert!(!s.contains("yellow"));
}
#[test]
fn summarize_group_shows_tier_split_when_both_present() {
fn e(tier: &str, health: &str) -> Environment {
Environment {
name: "n".into(),
application: "a".into(),
tier: tier.into(),
status: "Ready".into(),
health: health.into(),
cname: "".into(),
platform: "".into(),
version_label: "".into(),
updated: None,
id: None,
region: None,
arn: None,
}
}
let envs = [e("Web", "Green"), e("Worker", "Yellow"), e("Worker", "Red")];
let refs: Vec<&Environment> = envs.iter().collect();
let s = summarize_group(&refs);
assert!(s.contains("1 Web"));
assert!(s.contains("2 Worker"));
assert!(s.contains("1 red"));
assert!(s.contains("1 yellow"));
}
#[test]
fn summarize_group_empty_input() {
assert_eq!(summarize_group(&[]), "");
}
#[test]
fn action_glyph_is_distinct_per_action_per_icon_style() {
use crate::app::ACTIONS;
use std::collections::HashSet;
for icons in [IconStyle::Unicode, IconStyle::Ascii, IconStyle::Powerline] {
let glyphs: HashSet<&str> = ACTIONS.iter().map(|a| a.glyph(icons)).collect();
assert!(
glyphs.len() >= ACTIONS.len() / 2,
"too many action-glyph collisions in {icons:?}: {glyphs:?}"
);
}
}
#[test]
fn format_refresh_label_relative_with_recent_refresh() {
let interval = std::time::Duration::from_secs(15);
let last = chrono::Utc::now() - chrono::Duration::seconds(3);
let now = chrono::Utc::now();
let label = format_refresh_label(Some(last), now, interval);
assert!(label.starts_with("3s ago") || label.starts_with("2s ago"));
assert!(label.contains("next 1") || label.contains("next 12") || label.contains("next 13"));
}
#[test]
fn format_refresh_label_clamps_overdue_to_zero() {
let interval = std::time::Duration::from_secs(15);
let now = chrono::Utc::now();
let last = now - chrono::Duration::seconds(30);
let label = format_refresh_label(Some(last), now, interval);
assert!(label.contains("next 0s"), "got {label:?}");
}
#[test]
fn format_refresh_label_handles_no_prior_refresh() {
let label =
format_refresh_label(None, chrono::Utc::now(), std::time::Duration::from_secs(15));
assert_eq!(label, "— · next 15s");
}
#[test]
fn caret_glyph_falls_back_to_underscore_on_ascii() {
let mut t = Theme::dark();
t.icons = IconStyle::Ascii;
assert_eq!(caret_glyph(&t), "_");
t.icons = IconStyle::Unicode;
assert_eq!(caret_glyph(&t), "\u{258e}");
t.icons = IconStyle::Powerline;
assert_eq!(caret_glyph(&t), "\u{258e}");
}
#[test]
fn scale_rgb_darkens_proportionally_and_passes_through_named() {
assert_eq!(
super::scale_rgb(Color::Rgb(200, 100, 50), 0.5),
Color::Rgb(100, 50, 25)
);
assert_eq!(
super::scale_rgb(Color::Rgb(200, 100, 50), 1.0),
Color::Rgb(200, 100, 50)
);
assert_eq!(
super::scale_rgb(Color::Rgb(255, 255, 255), 0.0),
Color::Rgb(0, 0, 0)
);
assert_eq!(
super::scale_rgb(Color::Rgb(200, 100, 50), 2.0),
Color::Rgb(200, 100, 50)
);
assert_eq!(super::scale_rgb(Color::Red, 0.5), Color::Red);
}
#[test]
fn truncate_for_display_handles_short_long_and_multibyte() {
assert_eq!(super::truncate_for_display("hello", 10), "hello");
assert_eq!(super::truncate_for_display("0123456789", 10), "0123456789");
assert_eq!(super::truncate_for_display("0123456789", 5), "0123…");
assert_eq!(super::truncate_for_display("éééééééé", 4), "ééé…");
}
#[test]
fn separator_glyph_falls_back_to_ascii_chevron() {
assert_eq!(super::separator_glyph(IconStyle::Ascii), ">");
assert_eq!(super::separator_glyph(IconStyle::Unicode), "▶");
assert_eq!(super::separator_glyph(IconStyle::Powerline), "▶");
}
#[test]
fn pill_chain_uses_left_wedge_for_lead_in_in_powerline_mode() {
let mut t = Theme::dark();
t.icons = IconStyle::Powerline;
let pills = vec![("ALERT".to_string(), Color::White, Color::Red)];
let spans = pill_chain(&pills, &t);
let first_glyph: String = spans[0].content.to_string();
assert!(
first_glyph.contains('\u{e0b2}'),
"expected U+E0B2 left-pointing wedge as lead-in, got {first_glyph:?}"
);
let last_glyph: String = spans.last().unwrap().content.to_string();
assert!(
last_glyph.contains('\u{e0b0}'),
"expected U+E0B0 right-pointing wedge as trail-out, got {last_glyph:?}"
);
}
#[test]
fn pill_chain_no_powerline_glyphs_in_unicode_mode() {
let mut t = Theme::dark();
t.icons = IconStyle::Unicode;
let pills = vec![("ALERT".to_string(), Color::White, Color::Red)];
let spans = pill_chain(&pills, &t);
for s in &spans {
let body = s.content.to_string();
assert!(
!body.contains('\u{e0b0}') && !body.contains('\u{e0b2}'),
"non-Powerline mode emitted a Powerline triangle: {body:?}"
);
}
}
#[test]
fn sep_uses_powerline_glyph_when_opted_in() {
let mut t = Theme::dark();
t.icons = IconStyle::Unicode;
let unicode_sep = sep(&t).content.to_string();
assert!(unicode_sep.contains('•'));
t.icons = IconStyle::Powerline;
let pl_sep = sep(&t).content.to_string();
assert!(
pl_sep.contains('\u{e0b1}'),
"expected U+E0B1 thin separator, got {pl_sep:?}"
);
t.icons = IconStyle::Ascii;
assert!(sep(&t).content.to_string().contains('•'));
}
#[test]
fn tab_icon_is_distinct_per_tab() {
for icons in [IconStyle::Unicode, IconStyle::Ascii, IconStyle::Powerline] {
use std::collections::HashSet;
let tabs = [
DetailTab::Events,
DetailTab::Instances,
DetailTab::Metrics,
DetailTab::Queue,
DetailTab::Config,
];
let seen: HashSet<&str> = tabs.iter().map(|t| tab_icon(*t, icons)).collect();
assert_eq!(seen.len(), tabs.len(), "icons collide for {icons:?}");
}
}
#[test]
fn titled_block_decorates_per_icon_style() {
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::Widget;
let mut t = Theme::dark();
let b = titled_block(&t, "ebman", true, t.title);
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 3));
b.render(buf.area, &mut buf);
let rendered = buffer_to_string(&buf);
assert!(rendered.contains("◆"));
assert!(rendered.contains("ebman"));
t.icons = IconStyle::Ascii;
let b2 = titled_block(&t, "ebman", true, t.title);
let mut buf = Buffer::empty(Rect::new(0, 0, 30, 3));
b2.render(buf.area, &mut buf);
let rendered = buffer_to_string(&buf);
assert!(rendered.contains("[ ebman ]"));
assert!(!rendered.contains("◆"));
}
#[test]
fn pill_wraps_text_with_padding() {
let s = pill("READY", Color::Black, Color::Green);
assert_eq!(s.content.as_ref(), " READY ");
}
#[test]
fn health_dot_falls_back_to_ascii() {
let mut t = Theme::dark();
let dot = health_dot("green", &t);
assert_eq!(dot.content.as_ref(), "●");
t.icons = IconStyle::Ascii;
let dot = health_dot("green", &t);
assert_eq!(dot.content.as_ref(), "*");
}
#[test]
fn header_dimensions_merges_pills_when_room_to_spare() {
let (rows, merge) = header_dimensions(60, 20, 120, false);
assert!(merge, "wide window should merge pills onto info row");
assert_eq!(rows, 5, "merged layout uses 5 rows (2 borders + 3 content)");
}
#[test]
fn header_dimensions_keeps_pill_row_when_too_narrow() {
let (rows, merge) = header_dimensions(60, 30, 80, false);
assert!(!merge, "narrow window should keep pills on their own row");
assert_eq!(rows, 6, "split layout adds one row for the pill chain");
}
#[test]
fn header_dimensions_with_no_pills_uses_compact_layout() {
let (rows, merge) = header_dimensions(60, 0, 80, false);
assert!(!merge);
assert_eq!(rows, 5);
}
#[test]
fn header_dimensions_adds_row_for_saved_filters() {
let (rows, _) = header_dimensions(60, 0, 200, true);
assert_eq!(rows, 6, "saved-filter chip bar adds one row");
let (rows_with_pills, merged) = header_dimensions(60, 20, 200, true);
assert!(merged);
assert_eq!(rows_with_pills, 6, "merged + filters = 5 + 1");
}
#[test]
fn header_dimensions_boundary_is_inclusive() {
let (_, merge) = header_dimensions(50, 48, 100, false);
assert!(merge);
let (_, merge) = header_dimensions(50, 49, 100, false);
assert!(!merge);
}
fn buffer_to_string(buf: &ratatui::buffer::Buffer) -> String {
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
let cell = &buf[(x, y)];
out.push_str(cell.symbol());
}
out.push('\n');
}
out
}
}