use std::{
collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
sync::Arc,
time::{Duration, Instant},
};
use color_eyre::eyre::{Result, WrapErr};
use crossterm::event::{
Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
MouseEventKind,
};
use futures::StreamExt;
use ratatui::{
layout::Rect,
widgets::{ListState, TableState},
};
use tokio::sync::mpsc;
use tui_common::TextInput;
use crate::{
aws::{
AppVersion, Application, AwsClient, AwsContext, CwAlarm, Environment, Event as EbEvent,
Identity, Instance, MetricSeries, QueueMessage, WorkerQueues,
},
config::Config,
profiles,
state::{self, PersistedState},
theme::{IconStyle, Theme},
ui, Tui,
};
pub use crate::mode_action::{
Action, ActionFlow, ConfirmKind, ConfirmModal, DryRunInfo, ParameterisedAction, ACTIONS,
};
pub use crate::mode_detail::{
config_editable_items, health_items, ConfigEdit, ConfigEditMode, ConfigItem, ConfigItemKind,
DetailState, DetailTab, EventLevel, EventWindow, HealthItem, LogTail, LogTailStage,
};
mod dispatch; mod input; mod mode_keys; mod msg; mod palette;
mod cmd_action; mod cmd_alarms; mod cmd_config_template; mod cmd_cost; mod cmd_inspect; mod cmd_misc; mod cmd_nav; mod cmd_ops; mod cmd_option; mod cmd_overlay; mod cmd_settings; mod cmd_view; mod cmd_write;
mod action_flow; mod apps_menu; mod config_edit; mod detail_nav; mod export; mod forms; mod mode_dlq_handlers; mod open_overlay; mod shell_session; mod view; mod view_state;
mod spawn_batch; mod spawn_deploy; mod spawn_detail; mod spawn_dlq; mod spawn_refresh; mod spawn_rollout; mod spawn_tail; mod spawn_why_red;
mod config_diff; mod cost; mod deploy_math; mod env_edit; mod render; mod safety; mod saved_views; mod tail; mod text; mod types;
pub use config_diff::*;
pub use cost::*;
pub use deploy_math::*;
pub use env_edit::*;
pub use render::*;
pub use saved_views::*;
pub use text::*;
pub use types::*;
pub use view_state::ViewState;
pub use crate::mode_dlq::{DlqState, QueueView};
pub(crate) use tail::tail_window_start;
pub use tail::TailView;
pub fn builtin_commands() -> Vec<&'static str> {
crate::commands::all_names()
}
pub struct App {
pub context: AwsContext,
pub scope: Scope,
pub applications: Vec<Application>,
pub app_table_state: TableState,
pub environments: Vec<Environment>,
pub table_state: TableState,
pub table_area: Rect,
pub mode: Mode,
pub view: ViewState,
pub load_state: LoadState,
pub loading_since: Option<Instant>,
pub refresh_interval: Duration,
pub loading_visible_until: Option<Instant>,
pub last_refresh: Option<chrono::DateTime<chrono::Utc>>,
pub status_message: Option<String>,
pub error_message: Option<String>,
pub picker: Option<Picker>,
pub override_profile: Option<String>,
pub override_region: Option<String>,
pub history: HashMap<String, VecDeque<String>>,
pub command_input: TextInput,
pub completion: CompletionState,
pub quickjump_input: TextInput,
pub extra_regions: Vec<String>,
pub event_panel: EventPanel,
pub multi_selected: BTreeSet<String>,
pub apps_selected: BTreeSet<String>,
pub focus: Focus,
pub multi_regions: Vec<String>,
pub detail: Option<DetailState>,
pub action_flow: Option<ActionFlow>,
pub dlq: Option<DlqState>,
pub theme: Arc<Theme>,
pub help: HelpState,
pub hover_row: Option<usize>,
pub alerts: usize, pub worker_dlq_depths: std::collections::HashMap<String, i64>,
pub worker_dlq_stale: std::collections::HashSet<String>,
pub(crate) rebuild_epoch: u64,
pub(crate) env_regions: std::collections::HashMap<String, String>,
pub(crate) aws_built_at: Instant,
pub(crate) detail_fetch_started: Option<Instant>,
pub(crate) aws_refresh_in_flight: bool,
pub(crate) env_tag_cache: std::collections::HashMap<String, (Vec<String>, std::time::Instant)>,
pub(crate) env_health_cache: std::collections::HashMap<String, (i64, std::time::Instant)>,
pub(crate) deploy_snapshots: std::collections::HashMap<String, DeploySnapshot>,
pub(crate) armed_watchdogs: std::collections::HashMap<String, ArmedWatchdog>,
pub(crate) watching_deploys: std::collections::HashMap<String, WatchingDeploy>,
pub(crate) deploy_freeze: Option<DeployFreeze>,
pub(crate) incident: Option<Incident>,
pub(crate) tf_state: Option<crate::terraform::TfState>,
pub(crate) tf_managed_envs: std::collections::HashSet<String>,
pub(crate) undo_history: std::collections::VecDeque<UndoEntry>,
pub(crate) promotion_history: Vec<PromotionRecord>,
pub demo_mode: bool,
pub env_instance_counts: std::collections::HashMap<String, crate::aws::EnvInstanceCounts>,
pub cost_enabled: bool,
pub costs: std::collections::HashMap<String, f64>,
pub costs_fetched_at: Option<chrono::DateTime<chrono::Utc>>,
pub last_yanked_cli: Option<String>,
pub costs_complete: bool,
pub latest_stacks: std::collections::HashMap<String, String>,
pub frozen: bool, pub first_run_hint: bool,
pub current_overlay: Option<Overlay>,
pub message_log: VecDeque<(chrono::DateTime<chrono::Utc>, MsgKind, String)>,
pub toasts: VecDeque<Toast>,
pub palette_input: TextInput,
pub palette_items: Vec<PaletteItem>,
pub palette_filtered: Vec<usize>,
pub palette_state: ListState,
pub read_only: bool,
pub pinned: BTreeSet<String>,
pub pinned_apps: BTreeSet<String>,
pub aliases: BTreeMap<String, String>,
pub saved_views: BTreeMap<String, String>,
pub custom_metrics: BTreeMap<String, crate::state::CustomMetricSpec>,
pub log_reload: Option<crate::LogReloadHandle>,
pub log_directive: String,
pub plugins: BTreeMap<String, crate::plugins::Plugin>,
pub status_snapshot_at_refresh: Option<(Option<String>, Option<String>)>,
pub status_message_pinned: bool,
pub throttle_until: Option<Instant>,
pub consecutive_throttles: u32,
pub sso_expiry: Option<chrono::DateTime<chrono::Utc>>,
pub pending_actions: std::collections::VecDeque<PendingAction>,
pub pending_dispatch: Option<PendingDispatch>,
pub form: Option<crate::form::Form>,
pub log_tail_task: Option<tokio::task::JoinHandle<()>>,
pub log_tail_session: u64,
pub event_tail_task: Option<tokio::task::JoinHandle<()>>,
pub event_tail_session: u64,
pub why_red_session: u64,
pub why_items: Vec<WhyItem>,
pub update_available: Option<crate::update_check::LatestRelease>,
pub reload_requested: bool,
pub pending_shell_target: Option<String>,
pub pending_env_edit: Option<(String, Vec<(String, String)>)>,
pub current_shell: Option<Box<crate::shell::ShellSession>>,
pub shell_return_mode: Mode,
pub last_rendered_buffer: Option<ratatui::buffer::Buffer>,
pub notify_bell: bool,
pub cfg: ResolvedConfig,
pub newly_red: HashSet<String>,
pub newly_added: HashSet<String>,
pub health_delta: Vec<(String, i32)>,
pub status_delta: Vec<(String, i32)>,
prev_alerts: usize,
prev_health: HashMap<String, String>,
prev_status: HashMap<String, String>,
pending_select: Option<String>,
aws: Arc<AwsClient>,
generation: u64,
msg_tx: mpsc::UnboundedSender<AppMsg>,
msg_rx: mpsc::UnboundedReceiver<AppMsg>,
quit: bool,
}
pub(crate) enum AppMsg {
Refresh {
gen: u64,
result: Result<Vec<Environment>, String>,
partial_errors: Vec<String>,
},
Applications {
gen: u64,
result: Result<Vec<Application>, String>,
},
AppLatestVersions {
gen: u64,
results: Vec<(
String,
Option<String>,
Option<chrono::DateTime<chrono::Utc>>,
)>,
},
WorkerQueueCheck {
gen: u64,
results: Vec<(String, Result<Option<i64>, String>)>,
},
EnvInstanceCountsCheck {
gen: u64,
results: Vec<(String, crate::aws::EnvInstanceCounts)>,
},
Rebuild {
epoch: u64,
result: Result<Box<AwsClient>, String>,
},
ClientRefreshed {
epoch: u64,
result: Result<Box<AwsClient>, String>,
},
Identity {
gen: u64,
result: Result<Identity, String>,
},
Events {
gen: u64,
result: Result<Vec<EbEvent>, String>,
},
DetailEvents {
gen: u64,
env_name: String,
result: Result<Vec<EbEvent>, String>,
},
DetailInstances {
gen: u64,
env_name: String,
result: Result<Vec<Instance>, String>,
},
DetailQueues {
gen: u64,
env_name: String,
result: Result<WorkerQueues, String>,
},
DetailMetrics {
gen: u64,
env_name: String,
result: Result<Vec<MetricSeries>, String>,
},
DetailTags {
gen: u64,
env_name: String,
result: Result<Vec<(String, String)>, String>,
},
DetailEnvVars {
gen: u64,
env_name: String,
result: Result<Vec<(String, String)>, String>,
},
DetailLogGroups {
gen: u64,
env_name: String,
groups: Vec<String>,
},
DetailAlarms {
gen: u64,
env_name: String,
result: Result<Vec<crate::aws::CwAlarm>, String>,
},
CostsFetched {
gen: u64,
account: Option<String>,
region: String,
result: Result<crate::aws::EnvCosts, String>,
},
SolutionStacks {
gen: u64,
result: Result<Vec<String>, String>,
},
DetailRecentVersions {
gen: u64,
env_name: String,
result: Result<Vec<crate::aws::AppVersion>, String>,
},
FormPrefilled {
gen: u64,
env_name: String,
settings: Result<Vec<(String, String, String)>, String>,
},
FormMultiSelectLoaded {
gen: u64,
env_name: String,
field_key: String,
result: Result<MultiSelectOptions, String>,
},
DeployFromLocal {
gen: u64,
env_name: String,
label: String,
summary: String,
result: Result<(), String>,
},
LogTailOpened {
gen: u64,
session_id: u64,
env_name: String,
log_group: String,
since_ms: i64,
},
LogTailEvents {
gen: u64,
session_id: u64,
next_since_ms: i64,
result: Result<Vec<crate::aws::LogEvent>, String>,
},
EventTailOpened { gen: u64, session_id: u64 },
EventTailEvents {
gen: u64,
session_id: u64,
result: Result<Vec<crate::aws::Event>, String>,
},
WhyRedEvents {
gen: u64,
session_id: u64,
result: Result<Vec<crate::aws::Event>, String>,
},
WhyRedAlarms {
gen: u64,
session_id: u64,
result: Result<Vec<crate::aws::CwAlarm>, String>,
},
WhyRedInstances {
gen: u64,
session_id: u64,
result: Result<Vec<crate::aws::Instance>, String>,
},
WhyRedDeploys {
gen: u64,
session_id: u64,
result: Result<Vec<crate::aws::AppVersion>, String>,
},
WhyRedQueues {
gen: u64,
session_id: u64,
result: Result<crate::aws::WorkerQueues, String>,
},
WhyRedDlqMessages {
gen: u64,
session_id: u64,
result: Result<Vec<crate::aws::QueueMessage>, String>,
},
DryRunResult {
gen: u64,
env_name: String,
result: Result<Vec<Instance>, String>,
},
EnvVarsForEdit {
gen: u64,
env_name: String,
result: Result<Vec<(String, String)>, String>,
},
PreflightEvents {
gen: u64,
env_name: String,
result: Result<Vec<EbEvent>, String>,
},
VersionPreview {
gen: u64,
env_name: String,
result: Result<String, String>,
},
HealthCheckProbe {
gen: u64,
env_name: String,
result: Result<(), String>,
},
UnavailabilityEstimate {
gen: u64,
env_name: String,
line: Option<(String, bool)>,
},
ConfirmModalLint {
gen: u64,
env_name: String,
issues: Vec<crate::lint::Issue>,
},
LintInputsCached {
gen: u64,
env_name: String,
tags: Option<Vec<String>>,
healthy: Option<i64>,
},
RolloutPreflight {
gen: u64,
region: String,
result: Result<String, String>,
},
RolloutDispatched {
gen: u64,
region: String,
result: Result<(), String>,
},
UndoCaptured { gen: u64, entry: UndoEntry },
RollbackTarget {
gen: u64,
env_name: String,
current_version: String,
result: Result<Vec<EbEvent>, String>,
},
Alarms {
gen: u64,
env_name: String,
result: Result<Vec<CwAlarm>, String>,
},
DlqMessages {
gen: u64,
env_name: String,
queue_url: String,
result: Result<Vec<QueueMessage>, String>,
},
DlqActionResult {
gen: u64,
env_name: String,
result: Result<DlqOp, String>,
},
ActionResult {
gen: u64,
action: Action,
env_name: String,
result: Result<(), String>,
},
DetailLogsProgress {
gen: u64,
env_name: String,
stage: LogTailStage,
attempt: u32,
},
DetailLogs {
gen: u64,
env_name: String,
result: Result<Vec<(String, String)>, String>,
},
TextOverlay {
gen: u64,
title: String,
body: String,
},
AppVersions {
gen: u64,
application: String,
deployed_label: Option<String>,
result: Result<Vec<AppVersion>, String>,
},
UpdateCheck(Option<crate::update_check::LatestRelease>),
AutoRollbackCheck { gen: u64, env_name: String },
TagUpdate {
gen: u64,
env_name: String,
summary: String,
result: Result<(), String>,
},
OptionSettingsUpdate {
gen: u64,
env_name: String,
summary: String,
result: Result<(), String>,
},
AlarmOp {
gen: u64,
verb: &'static str,
alarm_name: String,
env_name: String,
result: Result<(), String>,
},
DeleteAppVersion {
gen: u64,
application: String,
label: String,
force: bool,
result: Result<(), String>,
},
}
#[derive(Debug, Clone)]
pub enum DlqOp {
Resent {
message_id: String,
},
Deleted {
message_id: String,
},
Purged,
Replayed {
count: usize,
failures: usize,
},
}
fn is_first_run() -> bool {
let no_state = !crate::util::config_file("state.toml").exists();
let home = std::env::var_os("HOME")
.map(std::path::PathBuf::from)
.unwrap_or_default();
let no_creds = !home.join(".aws").join("credentials").exists()
&& !home.join(".aws").join("config").exists();
no_state && no_creds
}
async fn init_client(
profile: Option<String>,
region: Option<String>,
) -> Result<(AwsClient, Option<String>, Option<String>, Option<String>)> {
let (mut client, used_profile, used_region) =
match AwsClient::with(profile.clone(), region.clone()).await {
Ok(c) => (c, profile, region),
Err(e) if profile.is_some() || region.is_some() => {
tracing::warn!(
error = %e,
profile = ?profile,
region = ?region,
"persisted profile/region failed to resolve — falling back to env defaults"
);
let c = AwsClient::with(None, None).await?;
(c, None, None)
}
Err(e) => return Err(e),
};
let warning = match client.verify_identity().await {
Ok(id) => {
client.context.account_id = id.account_id;
client.context.caller_arn = id.caller_arn;
None
}
Err(e) => {
tracing::warn!(
error = %e,
"sts:GetCallerIdentity failed — proceeding without identity. EB describe perms may still be available."
);
Some(format!("identity unknown ({e}); EB calls may still work"))
}
};
Ok((client, used_profile, used_region, warning))
}
impl App {
pub async fn new(config: Config) -> Result<Self> {
crate::audit::set_notify_webhook(config.notify_webhook.clone());
let explain_settings = crate::llm::Settings::from_config(&config);
let persisted = state::load();
let project = crate::project::load_from_cwd();
let project_profile = project.as_ref().and_then(|p| p.profile.clone());
let project_region = project.as_ref().and_then(|p| p.region.clone());
let eb_cli = crate::eb_cli::load_from_cwd();
let eb_cli_profile = eb_cli.as_ref().and_then(|c| c.profile.clone());
let eb_cli_region = eb_cli.as_ref().and_then(|c| c.region.clone());
tracing::info!(
target: "ebman::state",
persisted_profile = ?persisted.profile,
persisted_region = ?persisted.region,
project_profile = ?project_profile,
project_region = ?project_region,
eb_cli_profile = ?eb_cli_profile,
eb_cli_region = ?eb_cli_region,
"state::load"
);
let effective_profile = project_profile
.or(eb_cli_profile)
.or_else(|| persisted.profile.clone());
let effective_region = project_region
.or(eb_cli_region)
.or_else(|| persisted.region.clone());
let (aws, override_profile, override_region, identity_warning) =
init_client(effective_profile, effective_region).await?;
let aws = Arc::new(aws);
let context = aws.context.clone();
tracing::info!(
target: "ebman::state",
override_profile = ?override_profile,
override_region = ?override_region,
context_region = %context.region,
context_profile = ?context.profile,
"init_client returned"
);
let (msg_tx, msg_rx) = mpsc::unbounded_channel();
let mut table_state = TableState::default();
table_state.select(Some(0));
let (sort_key, sort_desc) = parse_sort(persisted.sort.as_deref());
let redact = persisted.redact.or(config.redact_default).unwrap_or(false);
let grouped = persisted
.grouped
.or(config.grouped_default)
.unwrap_or(false);
let events_visible = persisted.events_visible.unwrap_or(false);
let event_time_format = persisted.event_time_format.unwrap_or_default();
let refresh_interval = config.refresh_interval;
let mut app_table_state = TableState::default();
app_table_state.select(Some(0));
let names = builtin_commands();
let plugins_loaded = crate::plugins::load(&names);
for w in &plugins_loaded.warnings {
tracing::warn!(target: "ebman::plugins", "{}", w);
}
let plugin_startup_warning = if plugins_loaded.warnings.is_empty() {
None
} else {
Some(format!("plugins: {}", plugins_loaded.warnings.join("; ")))
};
let mut app = Self {
context,
scope: Scope::Envs,
applications: Vec::new(),
app_table_state,
environments: Vec::new(),
table_state,
table_area: Rect::default(),
mode: Mode::Normal,
view: ViewState::new(
persisted.filter.unwrap_or_default().into(),
grouped,
sort_key,
sort_desc,
redact,
persisted.hidden_cols,
),
load_state: LoadState::Idle,
loading_since: None,
refresh_interval,
loading_visible_until: None,
last_refresh: None,
status_message: None,
error_message: None,
picker: None,
override_profile,
override_region,
history: HashMap::new(),
command_input: TextInput::new(),
completion: CompletionState::default(),
quickjump_input: TextInput::new(),
extra_regions: config.extra_regions,
event_panel: EventPanel {
events: Vec::new(),
visible: events_visible,
time_format: event_time_format,
for_env: None,
scroll: 0,
area: None,
drag_origin: None,
cursor: None,
height: 10,
},
multi_selected: BTreeSet::new(),
apps_selected: BTreeSet::new(),
focus: Focus::Table,
multi_regions: Vec::new(),
detail: None,
action_flow: None,
dlq: None,
theme: {
let (mut t, warning) = Theme::resolve(&config.theme);
if let Some(w) = warning {
tracing::warn!("{w}");
}
match config.icons.trim().to_ascii_lowercase().as_str() {
"ascii" => t.icons = IconStyle::Ascii,
"powerline" | "nerd" | "nerdfont" => t.icons = IconStyle::Powerline,
_ => {}
}
Arc::new(t)
},
help: HelpState {
scroll: 0,
max_scroll: 0,
topic: HelpTopic::Global,
pre_mode: None,
pre_overlay: None,
},
hover_row: None,
alerts: 0,
worker_dlq_depths: std::collections::HashMap::new(),
worker_dlq_stale: std::collections::HashSet::new(),
rebuild_epoch: 0,
aws_built_at: Instant::now(),
env_regions: std::collections::HashMap::new(),
detail_fetch_started: None,
aws_refresh_in_flight: false,
env_tag_cache: std::collections::HashMap::new(),
env_health_cache: std::collections::HashMap::new(),
deploy_snapshots: persisted
.deploy_snapshots
.iter()
.filter_map(
|(env, raw)| match DeploySnapshot::parse_persisted(env, raw) {
Some(snap) => Some((env.clone(), snap)),
None => {
tracing::warn!(
target: "ebman::state",
env = %env,
raw = %raw,
"malformed deploy_snapshot entry in state.toml — skipping"
);
None
}
},
)
.collect(),
armed_watchdogs: std::collections::HashMap::new(),
watching_deploys: std::collections::HashMap::new(),
deploy_freeze: None,
incident: None,
tf_state: crate::terraform::load_from_cwd(),
tf_managed_envs: std::collections::HashSet::new(),
undo_history: std::collections::VecDeque::new(),
promotion_history: Vec::new(),
demo_mode: false,
env_instance_counts: std::collections::HashMap::new(),
cost_enabled: persisted.cost_enabled.unwrap_or(false),
costs: std::collections::HashMap::new(),
costs_complete: true,
last_yanked_cli: None,
costs_fetched_at: None,
latest_stacks: std::collections::HashMap::new(),
frozen: false,
first_run_hint: !crate::state::file_exists(),
current_overlay: None,
message_log: VecDeque::with_capacity(MESSAGE_LOG_CAP),
toasts: VecDeque::with_capacity(TOAST_CAP),
palette_input: TextInput::new(),
palette_items: Vec::new(),
palette_filtered: Vec::new(),
palette_state: ListState::default(),
read_only: false,
pinned: persisted.pinned,
pinned_apps: persisted.pinned_apps,
aliases: persisted.aliases,
saved_views: persisted.saved_views,
custom_metrics: persisted.custom_metrics,
log_reload: None,
log_directive: std::env::var("RUST_LOG")
.unwrap_or_else(|_| "info,aws=warn,hyper=warn".to_string()),
plugins: plugins_loaded.plugins,
status_snapshot_at_refresh: None,
status_message_pinned: false,
throttle_until: None,
consecutive_throttles: 0,
sso_expiry: crate::sso::latest_session_expiry(),
pending_actions: std::collections::VecDeque::with_capacity(PENDING_CAP),
pending_dispatch: None,
form: None,
log_tail_task: None,
log_tail_session: 0,
event_tail_task: None,
event_tail_session: 0,
why_red_session: 0,
why_items: Vec::new(),
update_available: None,
reload_requested: false,
pending_shell_target: None,
pending_env_edit: None,
current_shell: None,
shell_return_mode: Mode::Normal,
last_rendered_buffer: None,
notify_bell: config.notify_bell,
cfg: ResolvedConfig {
notify_webhook: config.notify_webhook.clone(),
command_aliases: config.command_aliases.clone(),
lint_disable: config.lint_disable.clone(),
explain_settings,
required_tags: config.required_tags,
alarm_dimensions: config.alarm_dimensions,
passthrough: config.passthrough,
cfg_icons_raw: config.icons.clone(),
profile_themes: config.profile_themes.clone(),
runbooks: config.runbooks.clone(),
safety_envs: config.safety_envs.clone(),
safety_accounts: config.safety_accounts.clone(),
accounts: config.accounts.clone(),
base_theme_name: config.theme.clone(),
},
newly_red: HashSet::new(),
newly_added: HashSet::new(),
health_delta: Vec::new(),
status_delta: Vec::new(),
prev_alerts: 0,
prev_health: HashMap::new(),
prev_status: HashMap::new(),
pending_select: persisted.selected_env,
aws,
generation: 0,
msg_tx,
msg_rx,
quit: false,
};
app.rebuild_view();
if let Some(w) = plugin_startup_warning {
app.error_message = Some(w);
} else if let Some(w) = identity_warning {
app.status_message = Some(format!(
"{w} — try `aws sso login` or `:profile NAME` to switch creds"
));
app.status_message_pinned = true;
}
if is_first_run() {
app.current_overlay = Some(Overlay::Whatsnew(WELCOME_OVERLAY.into()));
}
app.maybe_apply_profile_theme();
if let Some(proj) = project {
if let Some(filter) = proj.filter {
app.view.set_filter(filter);
} else if let Some(app_name) = proj.application {
app.view.set_filter(app_name);
}
app.cfg.runbooks.extend(proj.runbooks);
}
if app.view.filter().is_empty() {
if let Some(eb) = eb_cli {
if let Some(app_name) = eb.application {
app.view.set_filter(app_name);
}
}
}
app.rebuild_view();
app.refresh_tf_managed_envs();
Ok(app)
}
pub fn new_demo(config: Config) -> Self {
DEMO_QUIET_AWS_ERRORS.store(true, std::sync::atomic::Ordering::Relaxed);
let mut app = Self::for_tests(crate::aws::AwsClient::stub(), config);
app.demo_mode = true;
crate::demo_fixture::install(&mut app);
app
}
pub(crate) fn for_tests(aws: crate::aws::AwsClient, config: Config) -> Self {
let aws = Arc::new(aws);
let context = aws.context.clone();
let (msg_tx, msg_rx) = mpsc::unbounded_channel();
let explain_settings = crate::llm::Settings::from_config(&config);
let mut table_state = TableState::default();
table_state.select(Some(0));
let mut app_table_state = TableState::default();
app_table_state.select(Some(0));
let mut app = Self {
context,
scope: Scope::Envs,
applications: Vec::new(),
app_table_state,
environments: Vec::new(),
table_state,
table_area: Rect::default(),
mode: Mode::Normal,
view: ViewState::new(
TextInput::new(),
config.grouped_default.unwrap_or(false),
SortKey::App,
false,
config.redact_default.unwrap_or(false),
BTreeSet::new(),
),
load_state: LoadState::Idle,
loading_since: None,
refresh_interval: config.refresh_interval,
loading_visible_until: None,
last_refresh: None,
status_message: None,
error_message: None,
picker: None,
override_profile: None,
override_region: None,
history: HashMap::new(),
command_input: TextInput::new(),
completion: CompletionState::default(),
quickjump_input: TextInput::new(),
extra_regions: config.extra_regions.clone(),
event_panel: EventPanel {
events: Vec::new(),
visible: false,
time_format: EventTimeFormat::default(),
for_env: None,
scroll: 0,
area: None,
drag_origin: None,
cursor: None,
height: 10,
},
multi_selected: BTreeSet::new(),
apps_selected: BTreeSet::new(),
focus: Focus::Table,
multi_regions: Vec::new(),
detail: None,
action_flow: None,
dlq: None,
theme: {
let (mut t, _w) = Theme::resolve(&config.theme);
match config.icons.trim().to_ascii_lowercase().as_str() {
"ascii" => t.icons = IconStyle::Ascii,
"powerline" | "nerd" | "nerdfont" => t.icons = IconStyle::Powerline,
_ => {}
}
Arc::new(t)
},
help: HelpState {
scroll: 0,
max_scroll: 0,
topic: HelpTopic::Global,
pre_mode: None,
pre_overlay: None,
},
hover_row: None,
alerts: 0,
worker_dlq_depths: std::collections::HashMap::new(),
worker_dlq_stale: std::collections::HashSet::new(),
rebuild_epoch: 0,
aws_built_at: Instant::now(),
env_regions: std::collections::HashMap::new(),
detail_fetch_started: None,
aws_refresh_in_flight: false,
env_tag_cache: std::collections::HashMap::new(),
env_health_cache: std::collections::HashMap::new(),
deploy_snapshots: std::collections::HashMap::new(),
armed_watchdogs: std::collections::HashMap::new(),
watching_deploys: std::collections::HashMap::new(),
deploy_freeze: None,
incident: None,
tf_state: None,
tf_managed_envs: std::collections::HashSet::new(),
undo_history: std::collections::VecDeque::new(),
promotion_history: Vec::new(),
demo_mode: false,
env_instance_counts: std::collections::HashMap::new(),
cost_enabled: false,
costs: std::collections::HashMap::new(),
costs_complete: true,
last_yanked_cli: None,
costs_fetched_at: None,
latest_stacks: std::collections::HashMap::new(),
frozen: false,
first_run_hint: false,
current_overlay: None,
message_log: VecDeque::with_capacity(MESSAGE_LOG_CAP),
toasts: VecDeque::with_capacity(TOAST_CAP),
palette_input: TextInput::new(),
palette_items: Vec::new(),
palette_filtered: Vec::new(),
palette_state: ListState::default(),
read_only: false,
pinned: BTreeSet::new(),
pinned_apps: BTreeSet::new(),
aliases: std::collections::BTreeMap::new(),
saved_views: std::collections::BTreeMap::new(),
custom_metrics: std::collections::BTreeMap::new(),
log_reload: None,
log_directive: "info".to_string(),
plugins: std::collections::BTreeMap::new(),
status_snapshot_at_refresh: None,
status_message_pinned: false,
throttle_until: None,
consecutive_throttles: 0,
sso_expiry: None,
pending_actions: std::collections::VecDeque::with_capacity(PENDING_CAP),
pending_dispatch: None,
form: None,
log_tail_task: None,
log_tail_session: 0,
event_tail_task: None,
event_tail_session: 0,
why_red_session: 0,
why_items: Vec::new(),
update_available: None,
reload_requested: false,
pending_shell_target: None,
pending_env_edit: None,
current_shell: None,
shell_return_mode: Mode::Normal,
last_rendered_buffer: None,
notify_bell: config.notify_bell,
cfg: ResolvedConfig {
notify_webhook: config.notify_webhook.clone(),
command_aliases: config.command_aliases.clone(),
lint_disable: config.lint_disable.clone(),
explain_settings,
required_tags: config.required_tags.clone(),
alarm_dimensions: config.alarm_dimensions.clone(),
passthrough: config.passthrough.clone(),
cfg_icons_raw: config.icons.clone(),
profile_themes: config.profile_themes.clone(),
runbooks: config.runbooks.clone(),
safety_envs: config.safety_envs.clone(),
safety_accounts: config.safety_accounts.clone(),
accounts: config.accounts.clone(),
base_theme_name: config.theme.clone(),
},
newly_red: HashSet::new(),
newly_added: HashSet::new(),
health_delta: Vec::new(),
status_delta: Vec::new(),
prev_alerts: 0,
prev_health: HashMap::new(),
prev_status: HashMap::new(),
pending_select: None,
aws,
generation: 0,
msg_tx,
msg_rx,
quit: false,
};
app.rebuild_view();
app
}
pub async fn run(
&mut self,
terminal: &mut Tui,
mut control_rx: Option<mpsc::UnboundedReceiver<crate::control::ControlOp>>,
) -> Result<()> {
let mut events = EventStream::new();
let mut ticker = tokio::time::interval(self.refresh_interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut anim = tokio::time::interval(Duration::from_millis(100));
anim.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut shell_tick = tokio::time::interval(Duration::from_millis(30));
shell_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
.map_err(|e| color_eyre::eyre::eyre!("install SIGINT handler: {e}"))?;
let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.map_err(|e| color_eyre::eyre::eyre!("install SIGTERM handler: {e}"))?;
let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())
.map_err(|e| color_eyre::eyre::eyre!("install SIGHUP handler: {e}"))?;
let mut prev_mode = self.mode;
self.spawn_refresh();
self.spawn_update_check();
loop {
self.refresh_events_if_selection_changed();
if (self.mode == Mode::Shell) != (prev_mode == Mode::Shell) {
let _ = terminal.clear();
}
prev_mode = self.mode;
let mut snapshot: Option<ratatui::buffer::Buffer> = None;
terminal.draw(|f| {
ui::draw(f, self);
snapshot = Some(f.buffer_mut().clone());
})?;
self.last_rendered_buffer = snapshot;
if self.quit {
break;
}
let prev_status = self.status_message.clone();
let prev_error = self.error_message.clone();
tokio::select! {
_ = sigint.recv() => {
tracing::info!(target: "ebman", "received SIGINT, shutting down gracefully");
self.quit = true;
}
_ = sigterm.recv() => {
tracing::info!(target: "ebman", "received SIGTERM, shutting down gracefully");
self.quit = true;
}
_ = sighup.recv() => {
tracing::info!(target: "ebman", "received SIGHUP, shutting down gracefully");
self.quit = true;
}
maybe_event = events.next() => {
match maybe_event {
Some(Ok(event)) => self.handle_event(event),
Some(Err(e)) => {
self.error_message = Some(format!("input error: {e}"));
}
None => break,
}
}
_ = ticker.tick() => {
self.sso_expiry = crate::sso::latest_session_expiry();
self.spawn_home_client_refresh();
let now = Instant::now();
let backed_off = self
.throttle_until
.map(|t| now < t)
.unwrap_or(false);
if !self.frozen && !backed_off {
self.spawn_refresh();
if matches!(self.mode, Mode::Detail) {
if let Some(d) = self.detail.as_ref() {
if d.auto_refresh {
self.detail_refresh_active_tab();
}
}
}
} else if backed_off && self.throttle_until.is_some_and(|t| now >= t) {
self.throttle_until = None;
}
}
_ = shell_tick.tick(), if self.current_shell.is_some() => {
if let Some(shell) = self.current_shell.as_ref() {
shell.tick_demo_typer();
}
}
_ = anim.tick(), if self.loading_since.is_some()
|| !self.toasts.is_empty()
|| self.pending_dispatch.is_some()
|| !self.armed_watchdogs.is_empty()
|| !self.watching_deploys.is_empty()
|| matches!(self.current_overlay, Some(Overlay::About(_)))
|| self.loading_visible_until.map(|t| Instant::now() < t).unwrap_or(false) => {
}
Some(msg) = self.msg_rx.recv() => {
self.handle_msg(msg);
}
Some(op) = async {
match control_rx.as_mut() {
Some(rx) => rx.recv().await,
None => std::future::pending().await,
}
} => {
self.handle_control_op(op, terminal);
}
}
if self.status_message != prev_status {
if let Some(s) = self.status_message.clone() {
self.log_message(MsgKind::Info, s.clone());
self.push_toast(ToastKind::Info, s);
}
}
if self.error_message != prev_error {
if let Some(s) = self.error_message.clone() {
self.log_message(MsgKind::Error, s.clone());
self.push_toast(ToastKind::Error, s);
}
}
let now = Instant::now();
while self
.toasts
.front()
.map(|t| now.duration_since(t.shown_at) > t.ttl())
.unwrap_or(false)
{
self.toasts.pop_front();
}
self.expire_pending();
self.tick_pending_dispatch();
if let Some(target) = self.pending_shell_target.take() {
self.open_embedded_shell(terminal, &target)?;
}
if let Some((env_name, vars)) = self.pending_env_edit.take() {
if let Err(e) = self.run_env_editor(terminal, &env_name, &vars) {
self.error_message = Some(format!("env-edit: {e}"));
}
}
if matches!(self.mode, Mode::Shell)
&& self.current_shell.as_ref().is_some_and(|s| s.is_dead())
{
self.close_shell_session();
}
}
self.persist_state();
Ok(())
}
pub fn pin_status(&mut self, msg: impl Into<String>) {
self.status_message = Some(msg.into());
self.status_message_pinned = true;
}
pub fn pin_error(&mut self, msg: impl Into<String>) {
self.error_message = Some(msg.into());
self.status_message_pinned = true;
}
fn push_toast(&mut self, kind: ToastKind, text: String) {
if let Some(existing) = self
.toasts
.iter_mut()
.find(|t| t.text == text && t.kind == kind)
{
existing.shown_at = Instant::now();
return;
}
if let Some(new_key) = delta_toast_key(&text) {
if let Some(existing) = self.toasts.iter_mut().find(|t| {
t.kind == kind
&& delta_toast_key(&t.text)
.map(|k| k == new_key)
.unwrap_or(false)
}) {
existing.text = text;
existing.shown_at = Instant::now();
return;
}
}
while self.toasts.len() >= TOAST_CAP {
self.toasts.pop_front();
}
self.toasts.push_back(Toast {
text,
kind,
shown_at: Instant::now(),
});
}
fn log_message(&mut self, kind: MsgKind, text: String) {
if self.message_log.len() >= MESSAGE_LOG_CAP {
self.message_log.pop_front();
}
self.message_log.push_back((chrono::Utc::now(), kind, text));
}
fn format_message_log(&self) -> String {
let mut out = String::new();
let account = self
.context
.account_id
.as_deref()
.map(|a| redact_for_log(a, self.view.redact))
.unwrap_or_else(|| "—".into());
let profile = self.context.profile.as_deref().unwrap_or("default");
out.push_str(&format!(
"context: account={account} · profile={profile} · region={}\n",
self.context.region
));
if self.message_log.is_empty() {
out.push_str("─────────────────────────────────\n\n");
out.push_str("no messages yet\n");
return out;
}
out.push_str("recent messages (most recent last)\n");
out.push_str("─────────────────────────────────\n\n");
for (when, kind, text) in &self.message_log {
let when = when.with_timezone(&chrono::Local).format("%H:%M:%S");
let tag = match kind {
MsgKind::Info => "INFO",
MsgKind::Error => "ERR ",
};
out.push_str(&format!("{when} {tag} {text}\n"));
}
out
}
fn maybe_apply_profile_theme(&mut self) {
let profile = self.context.profile.as_deref().unwrap_or("default");
let target_name = self
.cfg
.profile_themes
.get(profile)
.cloned()
.unwrap_or_else(|| self.cfg.base_theme_name.clone());
if self.theme.name == target_name {
return;
}
let (mut t, warning) = Theme::resolve(&target_name);
if let Some(w) = warning {
tracing::warn!("{w}");
}
t.icons = self.theme.icons;
self.theme = Arc::new(t);
self.view.invalidate();
}
fn apply_config_live(&mut self, cfg: &Config) {
let (mut t, warning) = Theme::resolve(&cfg.theme);
if let Some(w) = warning {
tracing::warn!("{w}");
}
let icons_raw = cfg.icons.clone();
let resolved_icons = if icons_raw.eq_ignore_ascii_case("auto") {
self.theme.icons
} else {
match icons_raw.trim().to_ascii_lowercase().as_str() {
"ascii" => IconStyle::Ascii,
"powerline" | "nerd" | "nerdfont" => IconStyle::Powerline,
_ => IconStyle::Unicode,
}
};
t.icons = resolved_icons;
self.theme = Arc::new(t);
self.cfg.cfg_icons_raw = icons_raw;
self.refresh_interval = cfg.refresh_interval;
self.extra_regions = cfg.extra_regions.clone();
self.notify_bell = cfg.notify_bell;
self.cfg.required_tags = cfg.required_tags.clone();
self.cfg.alarm_dimensions = cfg.alarm_dimensions.clone();
self.rebuild_view();
}
pub fn persist_state(&self) {
if self.demo_mode {
return;
}
let selected = self.selected_env().map(|e| e.name.clone());
let region = self.override_region.clone().or_else(|| {
if !self.context.region.is_empty() && self.context.region != "unknown" {
Some(self.context.region.clone())
} else {
None
}
});
let profile = self
.override_profile
.clone()
.or_else(|| self.context.profile.clone());
tracing::debug!(
target: "ebman::state",
override_region = ?self.override_region,
context_region = %self.context.region,
persisted_region = ?region,
override_profile = ?self.override_profile,
context_profile = ?self.context.profile,
persisted_profile = ?profile,
"persist_state"
);
state::save(&PersistedState {
profile,
region,
filter: if self.view.filter().is_empty() {
None
} else {
Some(self.view.filter().text().to_string())
},
sort: Some(format!(
"{}:{}",
self.view.sort_key().label(),
if self.view.sort_desc() { "desc" } else { "asc" }
)),
grouped: Some(self.view.grouped()),
redact: Some(self.view.redact),
events_visible: Some(self.event_panel.visible),
event_time_format: Some(self.event_panel.time_format),
selected_env: selected,
pinned: self.pinned.clone(),
pinned_apps: self.pinned_apps.clone(),
cost_enabled: Some(self.cost_enabled),
aliases: self.aliases.clone(),
saved_views: self.saved_views.clone(),
deploy_snapshots: self
.deploy_snapshots
.iter()
.map(|(env, snap)| (env.clone(), snap.to_persisted()))
.collect(),
hidden_cols: self.view.hidden_cols.clone(),
custom_metrics: self.custom_metrics.clone(),
});
}
pub fn selected_env(&self) -> Option<&Environment> {
let sel = self.table_state.selected()?;
match self.display_rows().get(sel)? {
DisplayRow::Env(i) => self.environments.get(*i),
DisplayRow::Separator => None,
}
}
fn format_aws_error(&self, op: &str, msg: &str) -> String {
let profile = self
.override_profile
.clone()
.or_else(|| self.context.profile.clone())
.unwrap_or_else(|| "default".into());
if let Some(rewritten) = crate::aws::rewrite_credential_error(&profile, msg) {
return match rewritten {
crate::aws::CredentialHint::Expired(text) => {
format!("{text} (or refresh your creds, then press Ctrl-R)")
}
crate::aws::CredentialHint::Invalid(text) => {
format!("{text} (or press `p` to pick a different profile)")
}
};
}
format!("{op} failed: {msg}")
}
}
fn is_text_input(key: &KeyEvent) -> bool {
let m = key.modifiers;
!m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
}
#[derive(Debug, Clone, Copy)]
pub enum YankKind {
Cname,
Name,
}
#[derive(Debug, Clone, Copy)]
pub enum DisplayRow {
Env(usize),
Separator,
}
async fn collect_tail_logs(
aws: Arc<AwsClient>,
env_name: String,
tx: mpsc::UnboundedSender<AppMsg>,
gen: u64,
) -> std::result::Result<Vec<(String, String)>, String> {
const POLL_ATTEMPTS: u32 = 12;
const POLL_INTERVAL: Duration = Duration::from_secs(2);
aws.request_env_info_tail(&env_name)
.await
.map_err(|e| flatten_err("request_env_info_tail", e))?;
let _ = tx.send(AppMsg::DetailLogsProgress {
gen,
env_name: env_name.clone(),
stage: LogTailStage::Polling,
attempt: 0,
});
let mut urls: Vec<(String, String)> = Vec::new();
for attempt in 1..=POLL_ATTEMPTS {
tokio::time::sleep(POLL_INTERVAL).await;
urls = aws
.retrieve_env_info_tail(&env_name)
.await
.map_err(|e| flatten_err("retrieve_env_info_tail", e))?;
if !urls.is_empty() {
break;
}
let _ = tx.send(AppMsg::DetailLogsProgress {
gen,
env_name: env_name.clone(),
stage: LogTailStage::Polling,
attempt,
});
}
if urls.is_empty() {
return Err(format!(
"no tail samples uploaded after {}s — instance role may lack s3:PutObject on the EB info bucket",
POLL_ATTEMPTS as u64 * POLL_INTERVAL.as_secs()
));
}
let _ = tx.send(AppMsg::DetailLogsProgress {
gen,
env_name: env_name.clone(),
stage: LogTailStage::Fetching,
attempt: 0,
});
let mut out = Vec::with_capacity(urls.len());
for (instance_id, url) in urls {
match AwsClient::fetch_url_text(&url).await {
Ok(text) => out.push((instance_id, text)),
Err(e) => out.push((instance_id, format!("(fetch failed: {e})"))),
}
}
Ok(out)
}
pub fn compute_traffic_warning(env: &Environment) -> Option<String> {
let status_lower = env.status.to_lowercase();
if status_lower.contains("updating") || status_lower.contains("launching") {
return Some(format!("ACTIVE DEPLOY: status={}", env.status));
}
if status_lower.contains("terminating") {
return Some(format!("env is {} already", env.status));
}
if let Some(updated) = env.updated {
let dur = chrono::Utc::now().signed_duration_since(updated);
if dur >= chrono::Duration::zero() && dur < chrono::Duration::minutes(5) {
return Some(format!(
"RECENT CHANGE: updated {}s ago",
dur.num_seconds().max(0)
));
}
}
if env.health.eq_ignore_ascii_case("Red") || env.health.eq_ignore_ascii_case("Severe") {
return Some(format!("env is currently {}", env.health));
}
None
}
pub(crate) fn compute_red_alerts(
envs: &[crate::aws::Environment],
worker_dlq_depths: &std::collections::HashMap<String, i64>,
) -> usize {
envs.iter()
.filter(|e| {
let eb_red =
e.health.eq_ignore_ascii_case("Red") || e.health.eq_ignore_ascii_case("Severe");
let dlq_red = e.tier.eq_ignore_ascii_case("Worker")
&& worker_dlq_depths.get(&e.name).copied().unwrap_or(0) > 0;
eb_red || dlq_red
})
.count()
}
pub(crate) fn is_throttling_error(msg: &str) -> bool {
let lower = msg.to_lowercase();
[
"throttling",
"throttlingexception",
"requestlimitexceeded",
"too many requests",
"rate exceeded",
]
.iter()
.any(|needle| lower.contains(needle))
}
pub fn compute_loading_linger_target(
loading_since: Option<Instant>,
threshold: Duration,
linger: Duration,
now: Instant,
) -> Option<Instant> {
let elapsed = loading_since.map(|t| now.duration_since(t))?;
if elapsed >= threshold {
Some(now + linger)
} else {
None
}
}
fn throttle_backoff(base: Duration, consecutive: u32) -> Duration {
const MAX_BACKOFF: Duration = Duration::from_secs(300);
let factor: u32 = 2u32.saturating_pow(consecutive.min(6).saturating_add(1));
let scaled = base.saturating_mul(factor);
scaled.min(MAX_BACKOFF)
}
fn assign_app_colors<'a>(
names: impl IntoIterator<Item = &'a str>,
palette: &[ratatui::style::Color],
) -> HashMap<String, ratatui::style::Color> {
let mut out: HashMap<String, ratatui::style::Color> = HashMap::new();
if palette.is_empty() {
return out;
}
for name in names {
if !out.contains_key(name) {
let idx = out.len() % palette.len();
out.insert(name.to_string(), palette[idx]);
}
}
out
}
impl App {
fn yank_event_at(&mut self, idx: usize) {
let Some(ev) = self.event_panel.events.get(idx) else {
self.event_panel.cursor = None;
return;
};
let when = ev
.at
.map(|t| {
t.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S")
.to_string()
})
.unwrap_or_else(|| "—".into());
let line = format!("{when} [{}] {} {}", ev.severity, ev.env, ev.message);
match yank(&line) {
Ok(()) => {
self.status_message = Some(format!(
"yanked event line ({} chars)",
line.chars().count()
));
}
Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
}
}
}
pub(crate) fn build_undo_entry(
env_name: &str,
original_summary: &str,
to_set: &[(String, String, String)],
to_remove: &[(String, String)],
pre_write: &[(String, String, String)],
) -> UndoEntry {
let lookup = |ns: &str, name: &str| -> Option<&String> {
pre_write
.iter()
.find(|(n, k, _)| n == ns && k == name)
.map(|(_, _, v)| v)
};
let mut reverse_set: Vec<(String, String, String)> = Vec::new();
let mut reverse_remove: Vec<(String, String)> = Vec::new();
for (ns, name, _) in to_set {
match lookup(ns, name) {
Some(prev) if !prev.is_empty() => {
reverse_set.push((ns.clone(), name.clone(), prev.clone()));
}
_ => {
reverse_remove.push((ns.clone(), name.clone()));
}
}
}
for (ns, name) in to_remove {
if let Some(prev) = lookup(ns, name) {
if !prev.is_empty() {
reverse_set.push((ns.clone(), name.clone(), prev.clone()));
}
}
}
UndoEntry {
env_name: env_name.to_string(),
to_set: reverse_set,
to_remove: reverse_remove,
original_summary: original_summary.to_string(),
captured_at: chrono::Utc::now(),
}
}
fn yank(text: &str) -> std::result::Result<(), String> {
#[cfg(test)]
{
let _ = text;
Ok(())
}
#[cfg(not(test))]
{
let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?;
cb.set_text(text.to_string()).map_err(|e| e.to_string())
}
}
#[derive(Copy, Clone, Debug)]
pub(crate) enum MultiSelectFlavour {
Subnets,
ElbSubnets,
SecurityGroups,
}
async fn load_multi_select(
aws: Arc<crate::aws::AwsClient>,
app_name: &str,
env_name: &str,
flavour: MultiSelectFlavour,
) -> Result<MultiSelectOptions, String> {
let ctx = aws
.fetch_env_vpc_context(app_name, env_name)
.await
.map_err(|e| flatten_err("fetch_env_vpc_context", e))?;
let Some(vpc_id) = ctx.vpc_id.as_deref() else {
return Err("env has no VPC id in its option settings — using account-default VPC?".into());
};
match flavour {
MultiSelectFlavour::Subnets | MultiSelectFlavour::ElbSubnets => {
let subnets = aws
.list_subnets_in_vpc(vpc_id)
.await
.map_err(|e| flatten_err("list_subnets_in_vpc", e))?;
let mut options = Vec::with_capacity(subnets.len());
let mut annotations = Vec::with_capacity(subnets.len());
for s in subnets {
options.push(s.id.clone());
let mut annot = format!("({} · {}", s.availability_zone, s.cidr_block);
if let Some(name) = s.name_tag.as_ref().filter(|n| !n.is_empty()) {
annot.push_str(" · ");
annot.push_str(name);
}
annot.push(')');
annotations.push(annot);
}
let initial = match flavour {
MultiSelectFlavour::ElbSubnets => ctx.elb_subnets,
_ => ctx.subnets,
};
Ok(MultiSelectOptions {
options,
annotations,
initial,
})
}
MultiSelectFlavour::SecurityGroups => {
let groups = aws
.list_security_groups_in_vpc(vpc_id)
.await
.map_err(|e| flatten_err("list_security_groups_in_vpc", e))?;
let mut options = Vec::with_capacity(groups.len());
let mut annotations = Vec::with_capacity(groups.len());
for g in groups {
options.push(g.id.clone());
let desc_suffix = if g.description.is_empty() {
String::new()
} else {
format!(" — {}", g.description)
};
annotations.push(format!("({}{desc_suffix})", g.group_name));
}
Ok(MultiSelectOptions {
options,
annotations,
initial: ctx.security_groups,
})
}
}
}
async fn load_listener_certs(
aws: Arc<crate::aws::AwsClient>,
app_name: &str,
env_name: &str,
port: &str,
) -> Result<MultiSelectOptions, String> {
let certs = aws
.list_certificates()
.await
.map_err(|e| flatten_err("list_certificates", e))?;
let listeners = aws
.fetch_env_listeners(app_name, env_name)
.await
.map_err(|e| flatten_err("fetch_env_listeners", e))?;
let initial: Vec<String> = listeners
.iter()
.find(|(p, opt, _)| p == port && opt == "SSLCertificateArns")
.map(|(_, _, v)| crate::util::split_csv(v))
.unwrap_or_default();
let mut options = Vec::with_capacity(certs.len());
let mut annotations = Vec::with_capacity(certs.len());
for c in certs {
options.push(c.arn);
annotations.push(if c.domain.is_empty() {
String::new()
} else {
format!("({})", c.domain)
});
}
Ok(MultiSelectOptions {
options,
annotations,
initial,
})
}
pub(crate) static DEMO_QUIET_AWS_ERRORS: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
fn flatten_err(op: &str, e: color_eyre::eyre::Report) -> String {
if DEMO_QUIET_AWS_ERRORS.load(std::sync::atomic::Ordering::Relaxed) {
tracing::debug!(target: "ebman::aws", op = op, error = ?e, "aws call failed (demo stub)");
} else {
tracing::error!(target: "ebman::aws", op = op, error = ?e, "aws call failed");
}
flatten_err_to_string(&e)
}
pub(crate) fn flatten_err_to_string(e: &color_eyre::eyre::Report) -> String {
let display = e.to_string();
let dbg_lower = format!("{e:?}").to_lowercase();
const THROTTLING_TOKENS: &[&str] = &[
"throttling",
"throttlingexception",
"requestlimitexceeded",
"too many requests",
"rate exceeded",
];
if THROTTLING_TOKENS.iter().any(|t| dbg_lower.contains(t)) {
return format!("ThrottlingException: {display}");
}
const ACCESS_TOKENS: &[&str] = &[
"accessdenied",
"accessdeniedexception",
"unauthorizedoperation",
"not authorized to perform",
];
if ACCESS_TOKENS.iter().any(|t| dbg_lower.contains(t)) {
return format!("AccessDenied: {display}");
}
const NOTFOUND_TOKENS: &[&str] = &[
"resourcenotfoundexception",
"nosuchentity",
"nosuchbucket",
"nosuchkey",
"queuedoesnotexist",
"environmentnotfound",
"applicationversionnotfound",
];
if NOTFOUND_TOKENS.iter().any(|t| dbg_lower.contains(t)) {
return format!("NotFound: {display}");
}
const DEPENDENCY_TOKENS: &[&str] = &[
"dependencyviolation",
"resourceinuse",
"operationinprogressexception",
"invalidrequestexception",
];
if DEPENDENCY_TOKENS.iter().any(|t| dbg_lower.contains(t)) {
return format!("Conflict: {display}");
}
if dbg_lower.contains("expiredtoken") || dbg_lower.contains("tokenexpired") {
return format!("ExpiredToken: {display}");
}
display
}
fn build_palette_items(app: &App) -> Vec<PaletteItem> {
let mut out: Vec<PaletteItem> = Vec::new();
for c in crate::commands::COMMANDS {
match c.kind {
crate::commands::CommandKind::ZeroArg => {
out.push(PaletteItem {
label: format!(":{}", c.name),
detail: c.help.to_string(),
kind_tag: "cmd",
action: PaletteAction::RunCommand(c.name.to_string()),
});
}
crate::commands::CommandKind::Prefill(prefix) => {
out.push(PaletteItem {
label: format!(":{}", prefix.trim_end()),
detail: c.help.to_string(),
kind_tag: "cmd",
action: PaletteAction::PrefillCommand(prefix.to_string()),
});
}
}
}
for e in &app.environments {
let alias = app
.aliases
.get(&e.name)
.map(|a| format!(" ({a})"))
.unwrap_or_default();
out.push(PaletteItem {
label: e.name.clone(),
detail: format!("env in {}{alias} · {}", e.application, e.health),
kind_tag: "env",
action: PaletteAction::JumpEnv(e.name.clone()),
});
}
for name in app.saved_views.keys() {
out.push(PaletteItem {
label: format!("view: {name}"),
detail: "load saved view".into(),
kind_tag: "view",
action: PaletteAction::LoadView(name.clone()),
});
}
for (name, plugin) in &app.plugins {
out.push(PaletteItem {
label: format!(":{name}"),
detail: plugin
.description
.clone()
.unwrap_or_else(|| format!("plugin: {}", plugin.template)),
kind_tag: "plugin",
action: PaletteAction::RunCommand(name.clone()),
});
}
out
}
fn palette_score(needle: &str, label: &str, detail: &str) -> Option<isize> {
if needle.is_empty() {
return Some(0);
}
let l = label.to_lowercase();
let d = detail.to_lowercase();
if let Some(i) = l.find(needle) {
return Some(i as isize);
}
if let Some(i) = d.find(needle) {
return Some(1_000 + i as isize);
}
None
}
fn bucket_delta<F>(
prev: &HashMap<String, String>,
next: &[Environment],
accessor: F,
) -> Vec<(String, i32)>
where
F: Fn(&Environment) -> String,
{
let mut prev_counts: BTreeMap<String, i32> = BTreeMap::new();
let mut next_counts: BTreeMap<String, i32> = BTreeMap::new();
for e in next {
if let Some(prev_bucket) = prev.get(&e.name) {
*prev_counts.entry(prev_bucket.clone()).or_insert(0) += 1;
*next_counts.entry(accessor(e)).or_insert(0) += 1;
}
}
let mut keys: BTreeMap<String, ()> = BTreeMap::new();
for k in prev_counts.keys().chain(next_counts.keys()) {
keys.insert(k.clone(), ());
}
keys.into_keys()
.filter_map(|k| {
let p = *prev_counts.get(&k).unwrap_or(&0);
let n = *next_counts.get(&k).unwrap_or(&0);
let d = n - p;
if d != 0 {
Some((k, d))
} else {
None
}
})
.collect()
}
#[allow(clippy::too_many_arguments)]
fn finish_deploy_from_local(
tx: &tokio::sync::mpsc::UnboundedSender<AppMsg>,
gen: u64,
env_name: String,
label: String,
summary: String,
account: Option<&str>,
profile: Option<&str>,
region: &str,
result: Result<(), String>,
) {
crate::audit::append_action_completed(
account,
profile,
region,
"DeployFromLocal",
&env_name,
result.as_ref().map(|_| ()).map_err(|e| e.as_str()),
&[("label", &label)],
);
let _ = tx.send(AppMsg::DeployFromLocal {
gen,
env_name,
label,
summary,
result,
});
}
fn build_describe_cli(env_name: &str, region: &str, profile: Option<&str>) -> String {
let env_q = shell_quote(env_name);
let mut out = format!(
"aws elasticbeanstalk describe-environments --environment-names {env_q} --region {region}"
);
if let Some(p) = profile {
out.push_str(&format!(" --profile {}", shell_quote(p)));
}
out
}
fn write_audit_entry(
account: Option<&str>,
profile: Option<&str>,
region: &str,
action: Action,
env: &str,
swap_with: Option<&str>,
) {
let target = match swap_with {
Some(other) => format!("{env} ↔ {other}"),
None => env.to_string(),
};
crate::audit::append_action_dispatched(
account,
profile,
region,
&format!("{action:?}"),
&target,
&[],
);
}
fn write_audit_outcome(
account: Option<&str>,
profile: Option<&str>,
region: &str,
action: Action,
env: &str,
result: Result<(), &str>,
) {
crate::audit::append_action_completed(
account,
profile,
region,
&format!("{action:?}"),
env,
result,
&[],
);
}
#[derive(Clone)]
pub struct PendingDispatch {
pub deadline: Instant,
pub label: String,
pub target: String,
pub kind: PendingDispatchKind,
}
#[allow(clippy::large_enum_variant)]
#[derive(Clone)]
pub enum PendingDispatchKind {
Single { modal: ConfirmModal },
BatchAction {
action: Action,
env_names: Vec<String>,
},
BatchDeploy {
env_names: Vec<String>,
version_label: String,
},
BatchTag {
envs_with_arns: Vec<(String, String)>,
key: String,
value: Option<String>,
},
BatchSetOption {
env_names: Vec<String>,
namespace: String,
option_name: String,
value: String,
},
}
pub const UNDO_WINDOW: Duration = Duration::from_secs(5);
pub const LINT_INPUT_CACHE_TTL: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppsActionItem {
Drill,
BatchRebuild,
BatchRestart,
BatchDeploy,
OpenInConsole,
}
impl AppsActionItem {
pub fn label(self) -> &'static str {
match self {
Self::Drill => "Drill into envs",
Self::BatchRebuild => "Rebuild all envs in app",
Self::BatchRestart => "Restart all envs in app",
Self::BatchDeploy => "Deploy version label to all envs",
Self::OpenInConsole => "Open application in AWS console",
}
}
}
pub const APPS_ACTION_ITEMS: &[AppsActionItem] = &[
AppsActionItem::Drill,
AppsActionItem::BatchRebuild,
AppsActionItem::BatchRestart,
AppsActionItem::BatchDeploy,
AppsActionItem::OpenInConsole,
];
fn rewrite_assumed_role_arn(arn: &str) -> Option<String> {
let partition = crate::util::arn_partition(arn)?;
let rest = arn.strip_prefix(&format!("arn:{partition}:sts::"))?;
let (account, role_part) = rest.split_once(':')?;
let role_name = role_part.strip_prefix("assumed-role/")?.split('/').next()?;
Some(format!("arn:{partition}:iam::{account}:role/{role_name}"))
}
#[derive(Clone)]
pub(crate) struct RegionClient {
home: Arc<AwsClient>,
remote: Option<Remote>,
}
#[derive(Clone)]
pub(crate) enum Remote {
Profile(Option<String>, String),
Account(String, Box<crate::config::AccountSpec>),
}
impl RegionClient {
#[cfg(test)]
pub(crate) fn region_for_tests(&self) -> String {
match &self.remote {
Some(Remote::Profile(_, region)) => region.clone(),
Some(Remote::Account(_, spec)) => spec.region.clone().unwrap_or_default(),
None => self.home.context.region.clone(),
}
}
#[cfg(test)]
pub(crate) fn account_for_tests(&self) -> Option<String> {
match &self.remote {
Some(Remote::Account(name, _)) => Some(name.clone()),
_ => None,
}
}
#[cfg(test)]
pub(crate) fn is_home_for_tests(&self) -> bool {
self.remote.is_none()
}
pub(crate) async fn resolve(self) -> Result<Arc<AwsClient>, color_eyre::eyre::Report> {
match self.remote {
None => Ok(self.home),
Some(Remote::Profile(profile, region)) => {
crate::aws::cached_client(profile, region).await
}
Some(Remote::Account(name, spec)) => crate::aws::cached_role_client(&name, &spec).await,
}
}
}
impl App {
pub(crate) fn client_for_region(&self, region: &str) -> RegionClient {
let home = self.aws.clone();
if self.demo_mode || region == self.context.region || region.is_empty() {
return RegionClient { home, remote: None };
}
if let Some((name, mut spec)) = self.assumed_account() {
spec.region = Some(region.to_string());
return RegionClient {
home,
remote: Some(Remote::Account(name, Box::new(spec))),
};
}
let profile = self
.override_profile
.clone()
.or_else(|| self.context.profile.clone());
RegionClient {
home,
remote: Some(Remote::Profile(profile, region.to_string())),
}
}
pub(crate) fn should_refresh_home_client(&self) -> bool {
if self.demo_mode || self.aws_refresh_in_flight {
return false;
}
if self.assumed_account().is_some() {
return false;
}
self.aws_built_at.elapsed() >= crate::aws::CLIENT_CACHE_TTL
}
pub(crate) fn assumed_account(&self) -> Option<(String, crate::config::AccountSpec)> {
let name = self
.override_profile
.clone()
.or_else(|| self.context.profile.clone())?;
let spec = self.cfg.accounts.get(&name)?.clone();
Some((name, spec))
}
pub(crate) fn region_for_name(&self, env_name: &str) -> String {
self.environments
.iter()
.find(|e| e.name == env_name)
.or_else(|| {
self.detail
.as_ref()
.map(|d| &d.env_snapshot)
.filter(|e| e.name == env_name)
})
.map(|e| self.region_for(e))
.or_else(|| self.env_regions.get(env_name).cloned())
.unwrap_or_else(|| self.context.region.clone())
}
pub(crate) fn client_for_env(&self, env_name: &str) -> RegionClient {
self.client_for_region(&self.region_for_name(env_name))
}
pub(crate) fn region_for_app(&self, app_name: &str) -> String {
self.environments
.iter()
.find(|e| e.application == app_name)
.map(|e| self.region_for(e))
.unwrap_or_else(|| self.context.region.clone())
}
pub(crate) fn client_for_app(&self, app_name: &str) -> RegionClient {
self.client_for_region(&self.region_for_app(app_name))
}
pub(crate) fn current_env_client(&self) -> RegionClient {
match self
.detail
.as_ref()
.map(|d| d.env_name.clone())
.or_else(|| self.selected_env().map(|e| e.name.clone()))
{
Some(name) => self.client_for_env(&name),
None => self.client_for_region(&self.context.region),
}
}
pub(crate) fn why_red_client(&self) -> RegionClient {
let region = match self.current_overlay.as_ref() {
Some(Overlay::WhyRed { env_name, .. }) => self.region_for_name(env_name),
_ => self.context.region.clone(),
};
self.client_for_region(®ion)
}
pub(crate) fn dlq_client(&self) -> RegionClient {
let region = match self.dlq.as_ref() {
Some(d) => self.region_for_name(&d.env_name),
None => self.context.region.clone(),
};
self.client_for_region(®ion)
}
pub(crate) fn detail_client(&self) -> RegionClient {
let region = self
.detail
.as_ref()
.map(|d| self.region_for(&d.env_snapshot))
.unwrap_or_else(|| self.context.region.clone());
self.client_for_region(®ion)
}
}
pub(crate) fn principal_not_simulatable(arn: &str) -> Option<String> {
let partition = crate::util::arn_partition(arn)?;
if let Some(rest) = arn.strip_prefix(&format!("arn:{partition}:iam::")) {
let resource = rest.split_once(':').map(|(_, r)| r).unwrap_or(rest);
if resource.starts_with("user/")
|| resource.starts_with("group/")
|| resource.starts_with("role/")
{
return None;
}
if resource == "root" {
return Some(format!(
"{arn} is the account root — it has no attached policies to \
simulate. Pass the IAM role or user that made the call."
));
}
}
Some(format!(
"{arn} isn't an IAM user, group or role, so SimulatePrincipalPolicy \
can't evaluate it. Federated and service sessions aren't policy \
attachment points — pass the underlying role ARN \
(arn:{partition}:iam::ACCOUNT:role/NAME)."
))
}
pub(crate) fn parse_access_denied(msg: &str) -> Option<(String, String)> {
let user_prefix = "User: ";
let action_prefix = "is not authorized to perform:";
let user_start = msg.find(user_prefix)? + user_prefix.len();
let user_end = msg[user_start..]
.find(|c: char| c.is_whitespace())
.map(|i| user_start + i)?;
let principal_raw = &msg[user_start..user_end];
let action_start = msg.find(action_prefix)? + action_prefix.len();
let action_rest = msg[action_start..].trim_start();
let action_end = action_rest
.find(|c: char| c.is_whitespace() || c == ',')
.unwrap_or(action_rest.len());
let action = action_rest[..action_end].to_string();
let principal =
rewrite_assumed_role_arn(principal_raw).unwrap_or_else(|| principal_raw.to_string());
Some((principal, action))
}
fn console_url(region: &str, app_name: &str, env_name: &str) -> Option<String> {
let base = crate::util::console_base_url(region)?;
let app = urlencode(app_name);
let env = urlencode(env_name);
Some(format!(
"{base}/elasticbeanstalk/home?region={region}#/environment/dashboard?applicationName={app}&environmentName={env}"
))
}
fn open_url(url: &str) -> std::result::Result<(), String> {
#[cfg(target_os = "macos")]
let cmd = "open";
#[cfg(all(unix, not(target_os = "macos")))]
let cmd = "xdg-open";
#[cfg(target_os = "windows")]
let cmd = "explorer";
#[cfg(not(any(unix, target_os = "windows")))]
{
let _ = url;
return Err("don't know how to open a URL on this platform".into());
}
#[cfg(any(unix, target_os = "windows"))]
{
std::process::Command::new(cmd)
.arg(url)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.map(|_| ())
.map_err(|e| e.to_string())
}
}
use crate::util::json_escape;
#[cfg(test)]
mod tests;