use super::*;
pub(crate) 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
}
pub(crate) 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(incident) = app.incident.as_ref() {
let age = (chrono::Utc::now() - incident.started_at)
.to_std()
.unwrap_or_default();
let glyph = incident_glyph(theme);
let label = if incident.headline.is_empty() {
format!("{glyph}INCIDENT ({})", crate::app::humanize_short_age(age))
} else {
format!(
"{glyph}INCIDENT ({}): {}",
crate::app::humanize_short_age(age),
truncate_for_display(&incident.headline, 60)
)
};
chain.push((label, 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.label, remaining),
fg(theme.health_red),
theme.health_red,
));
}
if let Some((env, remaining)) =
crate::app::soonest_armed_rollback(&app.armed_watchdogs, chrono::Utc::now())
{
let label = if app.armed_watchdogs.len() == 1 {
format!("⏱ rollback {env} in {remaining}")
} else {
format!(
"⏱ {} rollbacks armed (next: {env} in {remaining})",
app.armed_watchdogs.len()
)
};
chain.push((label, fg(theme.health_yellow), theme.health_yellow));
}
if let Some((env, remaining)) =
crate::app::soonest_watching_deploy(&app.watching_deploys, chrono::Utc::now())
{
let label = if app.watching_deploys.len() == 1 {
format!("👁 watching {env} {remaining}")
} else {
format!(
"👁 {} watching (next: {env} {remaining})",
app.watching_deploys.len()
)
};
chain.push((label, fg(theme.title), theme.title));
}
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.view.redact {
chain.push((
"REDACT".into(),
fg(theme.health_yellow),
theme.health_yellow,
));
}
if app.view.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
}
pub(crate) 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.saved_views.is_empty())
}
pub(crate) 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);
}
}
}
pub(crate) 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)
}
pub(crate) fn estimated_info_row_width(app: &App) -> usize {
const STATUS_SLOT: usize = 10;
let sep_w = 5; let sort_dir = if app.view.sort_desc() { "↓" } else { "↑" };
let sort_label = format!("{}{}", app.view.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.view.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.view.filter().is_empty() {
w += sep_w + "Filter: ".chars().count() + app.view.filter().text().chars().count();
}
w
}
pub(crate) 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.view.redact,
);
let caller = redact(
&app.context
.caller_arn
.as_deref()
.map(short_caller)
.unwrap_or_else(|| "—".into()),
app.view.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.view.sort_desc() { "↓" } else { "↑" };
let sort_label = format!("{}{}", app.view.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 {
glyph(theme.icons, "▲", "^")
} else {
glyph(theme.icons, "▼", "v")
};
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.view.filter().is_empty() {
line2.push(sep(theme));
let filter_text = app.view.filter().text().to_string();
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.saved_views.is_empty() {
let mut chips: Vec<Span> = vec![Span::styled("Views: ", Style::default().fg(theme.muted))];
if theme.icons == IconStyle::Powerline {
let pills: Vec<(String, Color, Color)> = app
.saved_views
.iter()
.map(|(name, encoded)| {
let active = !app.view.filter().is_empty()
&& crate::app::view_filter_value(encoded) == app.view.filter().text();
let (fg, bg) = if active {
(theme.contrast_text(theme.title_alt), 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, encoded) in app.saved_views.iter() {
let active = !app.view.filter().is_empty()
&& crate::app::view_filter_value(encoded) == app.view.filter().text();
chips.push(Span::styled(
format!(" {name} "),
if active {
Style::default()
.fg(theme.contrast_text(theme.title_alt))
.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, theme.contrast_text(theme.title), 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]);
}
pub(crate) fn breadcrumb_line(app: &App) -> Line<'static> {
let theme = &app.theme;
let crumb_sep_glyph = if theme.icons == IconStyle::Powerline {
" \u{e0b1} "
} else {
" / "
};
let env = match (app.mode, app.detail.as_ref()) {
(Mode::Detail, Some(d)) => Some((
d.env_snapshot.application.clone(),
d.env_name.clone(),
app.region_for(&d.env_snapshot),
)),
_ => app
.selected_env()
.map(|e| (e.application.clone(), e.name.clone(), app.region_for(e))),
};
let region = env
.as_ref()
.map(|(_, _, r)| r.clone())
.unwrap_or_else(|| app.context.region.clone());
let mut spans: Vec<Span<'static>> = vec![Span::styled(
region,
Style::default()
.fg(theme.title)
.add_modifier(Modifier::BOLD),
)];
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)
}
pub(crate) fn short_caller(arn: &str) -> String {
arn.splitn(6, ':').nth(5).unwrap_or(arn).to_string()
}
pub(crate) 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)
}
pub(crate) 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
}
pub(crate) 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
}
pub(crate) 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",
}
}
pub(crate) 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"),
}
}