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 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 cmd_action;
mod cmd_alarms;
mod cmd_config_template;
mod cmd_misc;
mod cmd_nav;
mod cmd_option;
mod cmd_overlay;
mod cmd_settings;
mod cmd_view;
mod cmd_write;
mod mode_keys;
mod msg;
pub use crate::mode_dlq::{DlqState, QueueView};
pub fn builtin_commands() -> Vec<&'static str> {
crate::commands::all_names()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Table,
Events,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewMode {
Default,
Compact,
Spacious,
}
impl ViewMode {
pub fn next(self) -> Self {
match self {
Self::Default => Self::Compact,
Self::Compact => Self::Spacious,
Self::Spacious => Self::Default,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Default => "default",
Self::Compact => "compact",
Self::Spacious => "spacious",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
Envs,
Apps,
}
impl Scope {
pub fn next(self) -> Self {
match self {
Self::Envs => Self::Apps,
Self::Apps => Self::Envs,
}
}
pub fn prev(self) -> Self {
self.next()
}
}
pub const HISTORY_CAP: usize = 20;
const MESSAGE_LOG_CAP: usize = 50;
const TOAST_CAP: usize = 4;
pub const LOADING_INDICATOR_THRESHOLD: Duration = Duration::from_millis(300);
pub const LOADING_INDICATOR_LINGER: Duration = Duration::from_millis(500);
#[derive(Debug, Clone)]
pub enum Overlay {
Describe(String),
Whatsnew(String),
History(String),
Alarms { env_name: String, body: String },
Diff(String),
SavedConfigs(String),
TextDump { title: String, body: String },
SavedConfigsInteractive {
items: Vec<(String, String)>,
cursor: usize,
confirm_delete: bool,
},
WhyRed {
env_name: String,
tier: String,
events: Option<Result<Vec<crate::aws::Event>, String>>,
alarms: Option<Result<Vec<crate::aws::CwAlarm>, String>>,
instances: Option<Result<Vec<crate::aws::Instance>, String>>,
deploys: Option<Result<Vec<crate::aws::AppVersion>, String>>,
queues: Option<Result<crate::aws::WorkerQueues, String>>,
dlq_messages: Option<Result<Vec<crate::aws::QueueMessage>, String>>,
session_id: u64,
cursor: usize,
},
ReportBug { body: String },
AppsActionMenu {
app_name: String,
env_names: Vec<String>,
cursor: usize,
},
LogTail {
log_group: String,
env_name: String,
events: std::collections::VecDeque<crate::aws::LogEvent>,
scroll: u16,
following: bool,
since_ms: i64,
filter_input: String,
filter_active: bool,
filter_pattern: Option<regex::Regex>,
last_err: Option<String>,
session_id: u64,
},
About(std::time::Instant),
}
pub const LOG_TAIL_MAX_LINES: usize = 2000;
#[derive(Debug, Clone)]
pub enum WhyItem {
Describe(String),
OpenDlq,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToastKind {
Info,
Success,
Error,
}
#[derive(Debug, Clone)]
pub struct Toast {
pub text: String,
pub kind: ToastKind,
pub shown_at: Instant,
}
impl Toast {
pub fn ttl(&self) -> Duration {
match self.kind {
ToastKind::Error => Duration::from_secs(8),
_ => Duration::from_secs(4),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MsgKind {
Info,
Error,
}
const WHATSNEW: &str = "\
ebman — what's new
==================
Recent additions:
• --version / --help / --read-only CLI flags
• README and GitHub Actions CI
• Themes: dark, light, high-contrast (set in config.toml)
• Detail auto-refresh (R in Detail mode)
• Open env in console (b)
• Describe overlay (D — raw env JSON)
• Breadcrumb top-line, FROZEN pill, quick-jump 1-9
• Pin / star envs (*), persisted across runs
• Local env aliases (:alias NAME LABEL)
• Exports: TSV (^Y), JSON (:json), Markdown (:report)
• Read-only mode (--read-only or :readonly on)
• Local audit log (~/.cache/ebman/audit.log)
• Notification bell (notify_bell = true in config.toml)
• Crash report writer
Press esc / q / w to close.";
const WELCOME_OVERLAY: &str = "\
Welcome to ebman
================
Looks like this is your first run — no AWS credentials or persisted ebman
state were found on this machine. Here's what you'll need:
1. AWS credentials. Either:
aws sso login --profile my-sso-profile (recommended)
or set up ~/.aws/credentials with an access key, then
export AWS_PROFILE=my-profile
2. The IAM identity needs at least these EB read permissions:
elasticbeanstalk:DescribeEnvironments
elasticbeanstalk:DescribeApplications
elasticbeanstalk:DescribeEvents
Destructive actions (rebuild / restart / swap / terminate) require their
matching write permission; you can stay safe with `--read-only` until then.
3. Optional: drop a config at ~/.config/ebman/config.toml. See README.md for
the full schema (theme, refresh_interval_secs, extra_regions, …).
Key bindings:
? this help screen
p / r switch profile / region
: command bar
Ctrl-K fuzzy command palette
Ctrl-X redact mode (good for screenshots / streaming)
Press esc / q / w to close.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortKey {
App,
Name,
Status,
Health,
Age,
Version,
}
impl SortKey {
pub fn next(self) -> Self {
match self {
Self::Name => Self::App,
Self::App => Self::Status,
Self::Status => Self::Health,
Self::Health => Self::Version,
Self::Version => Self::Age,
Self::Age => Self::Name,
}
}
pub fn label(self) -> &'static str {
match self {
Self::App => "app",
Self::Name => "name",
Self::Status => "status",
Self::Health => "health",
Self::Age => "age",
Self::Version => "version",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"app" => Some(Self::App),
"name" => Some(Self::Name),
"status" => Some(Self::Status),
"health" => Some(Self::Health),
"age" => Some(Self::Age),
"version" => Some(Self::Version),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EventTimeFormat {
#[default]
Utc,
Local,
Age,
}
impl EventTimeFormat {
pub fn next(self) -> Self {
match self {
Self::Utc => Self::Local,
Self::Local => Self::Age,
Self::Age => Self::Utc,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Utc => "utc",
Self::Local => "local",
Self::Age => "age",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"utc" => Some(Self::Utc),
"local" => Some(Self::Local),
"age" | "relative" => Some(Self::Age),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Normal,
Filter,
Help,
Picker,
Command,
Detail,
Action,
Dlq,
QuickJump,
Palette,
Shell,
Form,
}
#[derive(Debug, Clone)]
pub enum PaletteAction {
RunCommand(String),
PrefillCommand(String),
JumpEnv(String),
LoadView(String),
}
#[derive(Debug, Clone)]
pub struct PaletteItem {
pub label: String,
pub detail: String,
pub kind_tag: &'static str, pub action: PaletteAction,
}
#[derive(Debug, Clone)]
pub struct PendingAction {
pub label: String,
pub target: String,
pub started: Instant,
pub completed: Option<(Instant, Result<(), String>)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum HelpTopic {
Global,
Detail,
Dlq,
Action,
Shell,
SavedConfigs,
}
pub const PENDING_CAP: usize = 20;
pub const PENDING_COMPLETED_TTL: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
#[allow(dead_code)] pub(crate) struct DeploySnapshot {
pub env_name: String,
pub previous_version_label: String,
pub taken_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)] pub(crate) struct ArmedWatchdog {
pub env_name: String,
pub target_label: String,
pub armed_at: chrono::DateTime<chrono::Utc>,
pub deadline_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
pub(crate) struct WatchingDeploy {
pub env_name: String,
pub target_label: String,
pub armed_at: chrono::DateTime<chrono::Utc>,
pub deadline_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
pub(crate) struct UndoEntry {
pub env_name: String,
pub to_set: Vec<(String, String, String)>,
pub to_remove: Vec<(String, String)>,
pub original_summary: String,
pub captured_at: chrono::DateTime<chrono::Utc>,
}
pub(crate) const UNDO_HISTORY_CAP: usize = 10;
#[derive(Debug, Clone)]
pub(crate) struct DeployFreeze {
pub reason: String,
pub frozen_at: chrono::DateTime<chrono::Utc>,
}
impl DeploySnapshot {
pub fn to_persisted(&self) -> String {
format!(
"{}|{}",
self.previous_version_label,
self.taken_at.to_rfc3339()
)
}
pub fn parse_persisted(env_name: &str, raw: &str) -> Option<Self> {
let (label, ts_str) = raw.split_once('|')?;
let label = label.trim();
if label.is_empty() {
return None;
}
let taken_at = chrono::DateTime::parse_from_rfc3339(ts_str.trim())
.ok()?
.with_timezone(&chrono::Utc);
Some(Self {
env_name: env_name.to_string(),
previous_version_label: label.to_string(),
taken_at,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PickerKind {
Profile,
Region,
LogGroup,
SshInstance,
}
pub struct Picker {
pub kind: PickerKind,
pub items: Vec<String>,
pub filter: String,
pub list_state: ListState,
}
#[derive(Clone, Debug)]
pub struct MultiSelectOptions {
pub options: Vec<String>,
pub annotations: Vec<String>,
pub initial: Vec<String>,
}
impl Picker {
pub fn new(kind: PickerKind, items: Vec<String>, current: Option<&str>) -> Self {
let mut list_state = ListState::default();
let initial = current
.and_then(|c| items.iter().position(|i| i == c))
.unwrap_or(0);
if !items.is_empty() {
list_state.select(Some(initial));
}
Self {
kind,
items,
filter: String::new(),
list_state,
}
}
pub fn title(&self) -> &'static str {
match self.kind {
PickerKind::Profile => " select profile ",
PickerKind::Region => " select region ",
PickerKind::LogGroup => " select log group ",
PickerKind::SshInstance => " select instance for SSM session ",
}
}
pub fn filtered(&self) -> Vec<usize> {
if self.filter.is_empty() {
return (0..self.items.len()).collect();
}
let needle = self.filter.to_lowercase();
self.items
.iter()
.enumerate()
.filter(|(_, v)| v.to_lowercase().contains(&needle))
.map(|(i, _)| i)
.collect()
}
pub fn move_selection(&mut self, delta: i32) {
let filt = self.filtered();
if filt.is_empty() {
self.list_state.select(None);
return;
}
let cur_visible = self
.list_state
.selected()
.and_then(|s| filt.iter().position(|i| *i == s))
.unwrap_or(0) as i32;
let next = (cur_visible + delta).rem_euclid(filt.len() as i32) as usize;
self.list_state.select(Some(filt[next]));
}
pub fn selected_value(&self) -> Option<String> {
self.list_state
.selected()
.and_then(|i| self.items.get(i).cloned())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoadState {
Idle,
Loading,
Error,
}
#[derive(Default)]
pub struct CompletionState {
pub origin: Option<String>,
pub index: usize,
}
pub struct HelpState {
pub scroll: u16,
pub max_scroll: u16,
pub topic: HelpTopic,
pub pre_mode: Option<Mode>,
pub pre_overlay: Option<Overlay>,
}
pub struct EventPanel {
pub events: Vec<EbEvent>,
pub visible: bool,
pub time_format: EventTimeFormat,
pub for_env: Option<String>,
pub scroll: u16,
pub area: Option<ratatui::layout::Rect>,
pub drag_origin: Option<u16>,
pub cursor: Option<usize>,
pub height: u16,
}
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 filter: String,
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 redact: bool,
pub grouped: bool,
pub sort_key: SortKey,
pub sort_desc: bool,
pub command_input: String,
pub completion: CompletionState,
pub quickjump_input: String,
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 view_mode: ViewMode,
pub help: HelpState,
pub hover_row: Option<usize>,
pub alerts: usize, pub worker_dlq_depths: std::collections::HashMap<String, i64>,
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) tf_state: Option<crate::terraform::TfState>,
pub(crate) tf_managed_envs: std::collections::HashSet<String>,
pub(crate) undo_history: std::collections::VecDeque<UndoEntry>,
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 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: String,
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 hidden_cols: BTreeSet<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 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 notify_webhook: Option<String>,
pub command_aliases: std::collections::HashMap<String, String>,
pub lint_disable: Vec<String>,
pub required_tags: Vec<String>,
pub cfg_icons_raw: String,
pub profile_themes: std::collections::HashMap<String, String>,
pub runbooks: std::collections::HashMap<String, String>,
pub safety_envs: std::collections::HashMap<String, bool>,
pub safety_accounts: std::collections::HashMap<String, bool>,
pub accounts: std::collections::HashMap<String, crate::config::AccountSpec>,
pub base_theme_name: String,
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>,
cached_filtered: Vec<usize>,
cached_display: Vec<DisplayRow>,
pub cached_app_colors: HashMap<String, ratatui::style::Color>,
pub cached_stale_platforms: HashMap<String, String>,
pending_select: Option<String>,
aws: Arc<AwsClient>,
generation: u64,
msg_tx: mpsc::UnboundedSender<AppMsg>,
msg_rx: mpsc::UnboundedReceiver<AppMsg>,
quit: bool,
}
enum AppMsg {
Refresh {
gen: u64,
result: Result<Vec<Environment>, 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, i64)>,
},
EnvInstanceCountsCheck {
gen: u64,
results: Vec<(String, crate::aws::EnvInstanceCounts)>,
},
Rebuild(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<Vec<crate::aws::EnvCost>, 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>,
},
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>,
},
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,
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,
},
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> {
let _ = NOTIFY_WEBHOOK_URL.set(config.notify_webhook.clone());
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,
filter: persisted.filter.unwrap_or_default(),
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(),
redact,
grouped,
sort_key,
sort_desc,
command_input: String::new(),
completion: CompletionState::default(),
quickjump_input: String::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)
},
view_mode: ViewMode::Default,
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(),
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,
tf_state: crate::terraform::load_from_cwd(),
tf_managed_envs: std::collections::HashSet::new(),
undo_history: std::collections::VecDeque::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_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: String::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,
hidden_cols: persisted.hidden_cols,
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,
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,
notify_webhook: config.notify_webhook.clone(),
command_aliases: config.command_aliases.clone(),
lint_disable: config.lint_disable.clone(),
required_tags: config.required_tags,
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(),
cached_filtered: Vec::new(),
cached_display: Vec::new(),
cached_app_colors: HashMap::new(),
cached_stale_platforms: 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.error_message = Some(w);
}
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.filter = filter;
} else if let Some(app_name) = proj.application {
app.filter = app_name;
}
app.runbooks.extend(proj.runbooks);
}
if app.filter.is_empty() {
if let Some(eb) = eb_cli {
if let Some(app_name) = eb.application {
app.filter = app_name;
}
}
}
app.refresh_tf_managed_envs();
Ok(app)
}
pub fn new_demo(config: Config) -> Self {
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 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,
filter: String::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(),
redact: config.redact_default.unwrap_or(false),
grouped: config.grouped_default.unwrap_or(false),
sort_key: SortKey::App,
sort_desc: false,
command_input: String::new(),
completion: CompletionState::default(),
quickjump_input: String::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)
},
view_mode: ViewMode::Default,
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(),
deploy_snapshots: std::collections::HashMap::new(),
armed_watchdogs: std::collections::HashMap::new(),
watching_deploys: std::collections::HashMap::new(),
deploy_freeze: None,
tf_state: None,
tf_managed_envs: std::collections::HashSet::new(),
undo_history: std::collections::VecDeque::new(),
demo_mode: false,
env_instance_counts: std::collections::HashMap::new(),
cost_enabled: false,
costs: std::collections::HashMap::new(),
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: String::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(),
hidden_cols: BTreeSet::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,
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,
notify_webhook: config.notify_webhook.clone(),
command_aliases: config.command_aliases.clone(),
lint_disable: config.lint_disable.clone(),
required_tags: config.required_tags.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(),
cached_filtered: Vec::new(),
cached_display: Vec::new(),
cached_app_colors: HashMap::new(),
cached_stale_platforms: 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();
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(())
}
fn open_embedded_shell(&mut self, terminal: &mut Tui, instance_id: &str) -> Result<()> {
if self.demo_mode {
let size = terminal.size()?;
let rows = size.height.saturating_sub(2).max(4);
let cols = size.width.max(20);
let content = crate::demo_fixture::canned_ssm_session(instance_id);
let session =
crate::shell::ShellSession::demo(instance_id.to_string(), &content, rows, cols);
self.shell_return_mode = self.mode;
self.current_shell = Some(Box::new(session));
self.mode = Mode::Shell;
return Ok(());
}
let region = self.context.region.clone();
let profile = self
.override_profile
.clone()
.or_else(|| self.context.profile.clone());
write_audit_line(
self.context.account_id.as_deref(),
profile.as_deref(),
®ion,
&format!("stage=dispatched action=SsmSession target={instance_id}"),
);
let size = terminal.size()?;
let rows = size.height.saturating_sub(2).max(4);
let cols = size.width.max(20);
let mut args = vec![
"ssm",
"start-session",
"--target",
instance_id,
"--region",
®ion,
];
let prof = profile.clone();
if let Some(p) = prof.as_deref() {
args.push("--profile");
args.push(p);
}
match crate::shell::ShellSession::spawn(
"aws",
&args,
rows,
cols,
format!("ssm: {instance_id}"),
) {
Ok(session) => {
self.current_shell = Some(Box::new(session));
self.shell_return_mode = self.mode;
self.mode = Mode::Shell;
self.status_message = Some(format!(
"ssm session into {instance_id} — F12 detaches, ^D / exit closes"
));
}
Err(e) => {
self.error_message = Some(format!(
"could not start SSM session ({e}). Install the AWS CLI + session-manager-plugin and check ssm:StartSession IAM"
));
}
}
Ok(())
}
pub fn handle_shell_key(&mut self, key: KeyEvent) {
let is_demo_session = self
.current_shell
.as_ref()
.is_some_and(|s| s.writer.is_none());
let detach = matches!(key.code, KeyCode::F(12))
|| (is_demo_session && matches!(key.code, KeyCode::Esc));
if detach {
self.mode = self.shell_return_mode;
self.status_message = Some(
"detached from shell — F12 reattaches, or open shell again from Instances tab"
.into(),
);
return;
}
if let Some(shell) = self.current_shell.as_mut() {
if let Some(bytes) = crate::shell::key_event_to_bytes(&key) {
let _ = shell.send(&bytes);
}
}
}
pub fn close_shell_session(&mut self) {
if let Some(mut s) = self.current_shell.take() {
s.kill();
self.status_message = Some(format!("{} ended", s.label));
}
self.mode = self.shell_return_mode;
}
fn run_env_editor(
&mut self,
terminal: &mut Tui,
env_name: &str,
original: &[(String, String)],
) -> Result<()> {
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
},
};
let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "vi".to_string());
let now_ns = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let safe = env_name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect::<String>();
let path = std::env::temp_dir().join(format!("ebman-env-{safe}-{now_ns}.env"));
let body = build_env_edit_body(env_name, original);
std::fs::write(&path, body.as_bytes()).wrap_err("writing env-edit temp file")?;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
let status = std::process::Command::new(&editor).arg(&path).status();
enable_raw_mode()?;
execute!(
terminal.backend_mut(),
EnterAlternateScreen,
EnableMouseCapture
)?;
terminal.hide_cursor()?;
terminal.clear()?;
match status {
Ok(s) if !s.success() => {
self.error_message = Some(format!(
"$EDITOR ({editor}) exited {} — no changes dispatched",
s.code().unwrap_or(-1)
));
let _ = std::fs::remove_file(&path);
return Ok(());
}
Err(e) => {
self.error_message = Some(format!(
"couldn't launch editor ({editor}): {e} — set $EDITOR / $VISUAL"
));
let _ = std::fs::remove_file(&path);
return Ok(());
}
_ => {}
}
let edited = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
self.error_message = Some(format!(
"couldn't re-read temp file at {} — no changes dispatched ({e})",
path.display()
));
return Ok(());
}
};
let _ = std::fs::remove_file(&path);
let edited_map = parse_env_edit_body(&edited);
let original_map: std::collections::BTreeMap<String, String> = original
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let (to_set, to_remove) = diff_env_vars(
"aws:elasticbeanstalk:application:environment",
&original_map,
&edited_map,
);
if to_set.is_empty() && to_remove.is_empty() {
self.status_message = Some("env-edit: no changes — nothing dispatched".into());
return Ok(());
}
let label = format!(
"env-edit ({} set, {} removed)",
to_set.len(),
to_remove.len()
);
self.spawn_option_settings_update(label, to_set, to_remove);
Ok(())
}
#[allow(dead_code)]
fn run_inline_ssm(&mut self, terminal: &mut Tui, instance_id: &str) -> Result<()> {
use crossterm::{
event::{DisableMouseCapture, EnableMouseCapture},
execute,
terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
},
};
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
let region = self.context.region.clone();
let profile = self
.override_profile
.clone()
.or_else(|| self.context.profile.clone());
write_audit_line(
self.context.account_id.as_deref(),
profile.as_deref(),
®ion,
&format!("stage=dispatched action=SsmSession target={instance_id}"),
);
println!("→ aws ssm start-session --target {instance_id}");
println!(
" region={region}{}",
match &profile {
Some(p) => format!(" profile={p}"),
None => String::new(),
}
);
println!(" ^D or `exit` to return to ebman");
println!();
let mut cmd = std::process::Command::new("aws");
cmd.arg("ssm")
.arg("start-session")
.arg("--target")
.arg(instance_id)
.arg("--region")
.arg(®ion);
if let Some(p) = &profile {
cmd.arg("--profile").arg(p);
}
let status = cmd.status();
enable_raw_mode()?;
execute!(
terminal.backend_mut(),
EnterAlternateScreen,
EnableMouseCapture
)?;
terminal.hide_cursor()?;
terminal.clear()?;
match status {
Ok(s) if s.success() => {
self.status_message = Some(format!("ssm session to {instance_id} ended"));
}
Ok(s) => {
self.error_message = Some(format!(
"aws ssm start-session exited {} — check that the AWS CLI + session-manager-plugin are installed and you have ssm:StartSession",
s.code().unwrap_or(-1)
));
}
Err(e) => {
self.error_message = Some(format!(
"could not invoke `aws`: {e} — install the AWS CLI + session-manager-plugin"
));
}
}
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.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 handle_event(&mut self, event: Event) {
if self.first_run_hint && matches!(event, Event::Key(_) | Event::Mouse(_) | Event::Paste(_))
{
self.first_run_hint = false;
}
match event {
Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => {
self.handle_key(key)
}
Event::Mouse(m) => self.handle_mouse(m),
_ => {}
}
}
fn handle_mouse(&mut self, m: MouseEvent) {
if self.event_panel.visible {
if let Some(area) = self.event_panel.area {
let divider_row = area.y;
let in_drag = self.event_panel.drag_origin.is_some();
match m.kind {
MouseEventKind::Down(MouseButton::Left)
if (m.row as i32 - divider_row as i32).abs() <= 0 =>
{
self.event_panel.drag_origin = Some(self.event_panel.height);
return;
}
MouseEventKind::Drag(MouseButton::Left) if in_drag => {
let footer_bottom = area.y.saturating_add(area.height).saturating_add(2);
let new_height = footer_bottom.saturating_sub(m.row);
self.event_panel.height = new_height.clamp(4, 30);
return;
}
MouseEventKind::Up(MouseButton::Left) if in_drag => {
self.event_panel.drag_origin = None;
return;
}
_ => {}
}
}
}
if matches!(self.mode, Mode::Detail) {
if let Some(d) = self.detail.as_mut() {
if d.tab() == DetailTab::Metrics {
if let MouseEventKind::Moved = m.kind {
let in_body = d
.metrics_body_rect
.map(|r| {
m.column >= r.x
&& m.column < r.x.saturating_add(r.width)
&& m.row >= r.y
&& m.row < r.y.saturating_add(r.height)
})
.unwrap_or(false);
d.metrics_hover_col = if in_body { Some(m.column) } else { None };
}
}
}
return;
}
let mouse_active = matches!(self.mode, Mode::Normal)
&& self.scope == Scope::Envs
&& self.current_overlay.is_none();
if !mouse_active {
self.hover_row = None;
return;
}
match m.kind {
MouseEventKind::ScrollDown => self.move_selection(1),
MouseEventKind::ScrollUp => self.move_selection(-1),
MouseEventKind::Down(MouseButton::Left) => self.select_row_at(m.column, m.row),
MouseEventKind::Moved => self.update_hover(m.row),
_ => {}
}
}
fn update_hover(&mut self, row: u16) {
let area = self.table_area;
if area.width == 0 || area.height == 0 {
self.hover_row = None;
return;
}
let data_top = area.y.saturating_add(2);
let data_bottom = area.y.saturating_add(area.height).saturating_sub(1);
if row < data_top || row >= data_bottom {
self.hover_row = None;
return;
}
let offset = self.table_state.offset();
let target = offset + (row - data_top) as usize;
self.hover_row = Some(target);
}
fn select_row_at(&mut self, _col: u16, row: u16) {
let area = self.table_area;
if area.width == 0 || area.height == 0 {
return;
}
let data_top = area.y.saturating_add(2);
let data_bottom = area.y.saturating_add(area.height).saturating_sub(1);
if row < data_top || row >= data_bottom {
return;
}
let rows = self.display_rows();
if rows.is_empty() {
return;
}
let offset = self.table_state.offset();
let target = offset + (row - data_top) as usize;
if target < rows.len() && matches!(rows[target], DisplayRow::Env(_)) {
self.table_state.select(Some(target));
}
}
fn handle_key(&mut self, key: KeyEvent) {
if matches!(key.code, KeyCode::Char('c')) && key.modifiers.contains(KeyModifiers::CONTROL) {
self.quit = true;
return;
}
if !matches!(self.mode, Mode::Picker) {
if matches!(
self.current_overlay.as_ref(),
Some(Overlay::SavedConfigsInteractive { .. })
) {
self.handle_saved_configs_interactive_key(key);
return;
}
if matches!(self.current_overlay.as_ref(), Some(Overlay::LogTail { .. })) {
self.handle_log_tail_key(key);
return;
}
if matches!(
self.current_overlay.as_ref(),
Some(Overlay::AppsActionMenu { .. })
) {
self.handle_apps_action_menu_key(key);
return;
}
if matches!(
self.current_overlay.as_ref(),
Some(Overlay::ReportBug { .. })
) {
self.handle_report_bug_key(key);
return;
}
if let Some(Overlay::WhyRed { cursor, .. }) = self.current_overlay.as_mut() {
let item_count = self.why_items.len();
let moved = match key.code {
KeyCode::Char('j') | KeyCode::Down if item_count > 0 => {
*cursor = cursor.saturating_add(1).min(item_count - 1);
true
}
KeyCode::Char('k') | KeyCode::Up if *cursor > 0 => {
*cursor -= 1;
true
}
_ => false,
};
if moved {
return;
}
}
if matches!(key.code, KeyCode::Enter) {
let drill: Option<(WhyItem, String, Option<String>, Option<String>)> =
if let Some(Overlay::WhyRed {
cursor,
queues,
env_name,
..
}) = self.current_overlay.as_ref()
{
self.why_items.get(*cursor).cloned().map(|item| {
let qs = queues.as_ref().and_then(|r| r.as_ref().ok());
(
item,
env_name.clone(),
qs.and_then(|q| q.main_url.clone()),
qs.and_then(|q| q.dlq_url.clone()),
)
})
} else {
None
};
if let Some((item, env_name, main_url_opt, dlq_url_opt)) = drill {
match item {
WhyItem::Describe(text) => {
self.current_overlay = Some(Overlay::Describe(text));
}
WhyItem::OpenDlq => {
if let Some(dlq_url) = dlq_url_opt {
self.current_overlay = None;
self.open_dlq_from_why(
env_name,
main_url_opt.unwrap_or_default(),
dlq_url,
);
}
}
}
return;
}
}
if let Some(overlay) = self.current_overlay.as_ref() {
let drill_dlq: Option<(String, String, String)> = match overlay {
Overlay::WhyRed {
env_name,
tier,
queues,
..
} if matches!(key.code, KeyCode::Char('d'))
&& tier.eq_ignore_ascii_case("Worker") =>
{
queues
.as_ref()
.and_then(|r| r.as_ref().ok())
.and_then(|qs| {
qs.dlq_url.clone().map(|du| {
(
env_name.clone(),
qs.main_url.clone().unwrap_or_default(),
du,
)
})
})
}
_ => None,
};
if let Some((env_name, main_url, dlq_url)) = drill_dlq {
self.current_overlay = None;
self.open_dlq_from_why(env_name, main_url, dlq_url);
return;
}
let universal = matches!(key.code, KeyCode::Esc | KeyCode::Char('q'));
let variant_extra = match overlay {
Overlay::Describe(_) => {
matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D'))
}
Overlay::Whatsnew(_) => matches!(key.code, KeyCode::Char('w')),
_ => false,
};
if universal || variant_extra {
self.current_overlay = None;
}
return;
}
}
match self.mode {
Mode::Filter => self.handle_filter_key(key),
Mode::Help => self.handle_help_key(key),
Mode::Command => self.handle_command_key(key),
Mode::Shell => self.handle_shell_key(key),
Mode::Palette => self.handle_palette_key(key),
Mode::QuickJump => self.handle_quickjump_key(key),
Mode::Picker => self.handle_picker_key(key),
Mode::Detail => {
if self
.detail
.as_ref()
.is_some_and(|d| d.search_active || d.log_tail.search_active)
{
self.handle_detail_search_key(key);
return;
}
if self
.detail
.as_ref()
.is_some_and(|d| d.config_edit.is_some())
{
self.handle_config_edit_key(key);
return;
}
if let Some(idx) = self
.detail
.as_ref()
.and_then(|d| d.instance_terminate_confirm)
{
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
if let Some(d) = self.detail.as_mut() {
d.instance_terminate_confirm = None;
}
self.spawn_terminate_instance(idx);
}
_ => {
if let Some(d) = self.detail.as_mut() {
d.instance_terminate_confirm = None;
}
self.status_message = Some("terminate cancelled".into());
}
}
return;
}
if self
.detail
.as_ref()
.and_then(|d| d.config_delete_confirm)
.is_some()
{
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
self.commit_config_delete();
}
_ => {
if let Some(d) = self.detail.as_mut() {
d.config_delete_confirm = None;
}
self.status_message = Some("delete cancelled".into());
}
}
return;
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
self.detail = None;
self.mode = Mode::Normal;
}
KeyCode::Tab | KeyCode::Char('l') => self.detail_cycle_tab(1),
KeyCode::BackTab | KeyCode::Char('h') => self.detail_cycle_tab(-1),
KeyCode::Char('j') | KeyCode::Down => self.detail_scroll(1),
KeyCode::Char('k') | KeyCode::Up => self.detail_scroll(-1),
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.detail_refresh_active_tab();
}
KeyCode::Char('R') => {
if let Some(d) = self.detail.as_mut() {
d.auto_refresh = !d.auto_refresh;
let msg = if d.auto_refresh {
"detail auto-refresh ON"
} else {
"detail auto-refresh off"
};
self.status_message = Some(msg.into());
}
}
KeyCode::Char('T') => {
self.cmd_event_time(&[]);
}
KeyCode::Char('L')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Events)
) =>
{
if let Some(d) = self.detail.as_mut() {
d.events_level = d.events_level.next();
d.events_scroll = 0;
let label = d.events_level.label();
self.status_message = Some(format!("events: severity ≥ {label}"));
}
}
KeyCode::Char('w')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Events)
) =>
{
if let Some(d) = self.detail.as_mut() {
d.events_window = d.events_window.next();
d.events_scroll = 0;
let label = d.events_window.label();
self.status_message = Some(format!("events: window {label}"));
}
}
KeyCode::Char('?') => {
self.help.topic = HelpTopic::Detail;
self.help.pre_mode = Some(Mode::Detail);
self.mode = Mode::Help;
}
KeyCode::Char('a') => self.open_action_menu(),
KeyCode::Char('b')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Instances)
) =>
{
self.open_instance_in_console();
}
KeyCode::Char('b') => self.open_in_console(),
KeyCode::Char('*') => self.toggle_pin_selected(),
KeyCode::Enter
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Health)
) =>
{
self.drill_health_item();
}
KeyCode::Enter
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Queue)
) =>
{
let want_main = self
.detail
.as_ref()
.map(|d| d.queue_cursor == 0)
.unwrap_or(false);
if want_main {
self.open_queue_viewer(crate::app::QueueView::Main);
} else {
self.open_queue_viewer(crate::app::QueueView::Dlq);
}
}
KeyCode::Enter
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Instances)
) =>
{
self.open_instance_info_overlay();
}
KeyCode::Char('i')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Instances)
) =>
{
self.open_instance_info_overlay();
}
KeyCode::Enter
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Config)
) =>
{
self.start_config_edit();
}
KeyCode::Char('n')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Config)
) =>
{
self.start_config_add();
}
KeyCode::Char('x')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Config)
) =>
{
self.arm_config_delete();
}
KeyCode::Char('r')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Config)
) =>
{
self.start_config_rename();
}
KeyCode::Char('y')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Instances)
) =>
{
self.yank_instance_id();
}
KeyCode::Char('s')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Instances)
) =>
{
if let Some(d) = self.detail.as_ref() {
if let Some(inst) = d.instances.get(d.instances_cursor) {
self.pending_shell_target = Some(inst.id.clone());
}
}
}
KeyCode::Char('s')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Logs)
) =>
{
if let Some(d) = self.detail.as_ref() {
let env_name = d.env_name.clone();
self.spawn_logs_tail(env_name, None);
}
}
KeyCode::Char('x')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Instances)
) =>
{
if let Some(d) = self.detail.as_mut() {
if d.instances.get(d.instances_cursor).is_some() {
d.instance_terminate_confirm = Some(d.instances_cursor);
}
}
}
KeyCode::Char('d') => self.open_dlq(),
KeyCode::Char('D') => self.open_describe_overlay(),
KeyCode::Char(']')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Metrics)
) =>
{
self.cycle_metrics_range(1);
}
KeyCode::Char('[')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Metrics)
) =>
{
self.cycle_metrics_range(-1);
}
KeyCode::Char(']') if self.detail.is_none() && !self.saved_views.is_empty() => {
self.cycle_saved_view(1);
}
KeyCode::Char('[') if self.detail.is_none() && !self.saved_views.is_empty() => {
self.cycle_saved_view(-1);
}
KeyCode::Char('/')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Events)
) =>
{
if let Some(d) = self.detail.as_mut() {
d.search_active = true;
d.search_input.clear();
d.search_error = None;
}
}
KeyCode::Char('/')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Logs)
) =>
{
if let Some(d) = self.detail.as_mut() {
d.log_tail.search_active = true;
d.log_tail.search_input.clear();
d.log_tail.search_error = None;
}
}
KeyCode::Char('n')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Events)
) =>
{
self.detail_search_jump(1);
}
KeyCode::Char('N')
if matches!(
self.detail.as_ref().map(|d| d.tab()),
Some(DetailTab::Events)
) =>
{
self.detail_search_jump(-1);
}
_ => {}
}
}
Mode::Action => {
if key.code == KeyCode::Char('?') {
self.help.topic = HelpTopic::Action;
self.help.pre_mode = Some(Mode::Action);
self.mode = Mode::Help;
} else {
self.handle_action_key(key);
}
}
Mode::Dlq => {
if key.code == KeyCode::Char('?') {
self.help.topic = HelpTopic::Dlq;
self.help.pre_mode = Some(Mode::Dlq);
self.mode = Mode::Help;
} else {
self.handle_dlq_key(key);
}
}
Mode::Form => self.handle_form_key(key),
Mode::Normal => {
match key.code {
KeyCode::Char('q') => self.quit = true,
KeyCode::Char('U') if self.pending_dispatch.is_some() => {
self.cancel_pending_dispatch();
}
KeyCode::Esc if !self.multi_selected.is_empty() => {
let n = self.multi_selected.len();
self.multi_selected.clear();
self.status_message = Some(format!("multi-select cleared ({n} env(s))"));
}
KeyCode::Esc if !self.apps_selected.is_empty() => {
let n = self.apps_selected.len();
self.apps_selected.clear();
self.status_message =
Some(format!("apps multi-select cleared ({n} app(s))"));
}
KeyCode::Tab => self.set_scope(self.scope.next()),
KeyCode::BackTab => self.set_scope(self.scope.prev()),
KeyCode::Enter if self.scope == Scope::Apps => self.drill_into_app(),
KeyCode::Enter => self.open_detail(),
KeyCode::Char('a') if self.scope == Scope::Apps => {
self.open_apps_action_menu();
}
KeyCode::Char('a') if self.scope == Scope::Envs => self.open_action_menu(),
KeyCode::Char('b') if self.scope == Scope::Apps => {
self.open_app_in_console();
}
KeyCode::F(5) => self.manual_refresh(),
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.manual_refresh();
}
KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.redact = !self.redact;
self.status_message = Some(if self.redact {
"redact mode ON".into()
} else {
"redact mode off".into()
});
}
KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.grouped = !self.grouped;
self.rebuild_view();
self.status_message = Some(if self.grouped {
"grouped by application".into()
} else {
"ungrouped".into()
});
}
KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.event_panel.visible = !self.event_panel.visible;
if self.event_panel.visible {
self.event_panel.scroll = 0;
if self.event_panel.events.is_empty() {
self.spawn_events();
}
}
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.view_mode = self.view_mode.next();
self.status_message = Some(format!("view: {}", self.view_mode.label()));
}
KeyCode::Up
if key.modifiers.contains(KeyModifiers::CONTROL)
&& self.event_panel.visible =>
{
self.event_panel.height = (self.event_panel.height + 1).min(30);
}
KeyCode::Down
if key.modifiers.contains(KeyModifiers::CONTROL)
&& self.event_panel.visible =>
{
self.event_panel.height = self.event_panel.height.saturating_sub(1).max(4);
}
KeyCode::Char('s') => {
self.sort_key = self.sort_key.next();
self.resort_envs();
self.status_message = Some(format!(
"sort: {} ({})",
self.sort_key.label(),
if self.sort_desc { "desc" } else { "asc" }
));
}
KeyCode::Char('S') => {
self.sort_desc = !self.sort_desc;
self.resort_envs();
self.status_message = Some(format!(
"sort: {} ({})",
self.sort_key.label(),
if self.sort_desc { "desc" } else { "asc" }
));
}
KeyCode::Char('T') => {
self.cmd_event_time(&[]);
}
KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.export_tsv();
}
KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.yank_cli();
}
KeyCode::Char(']') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.focus = match self.focus {
Focus::Table => {
if self.event_panel.visible {
Focus::Events
} else {
Focus::Table
}
}
Focus::Events => Focus::Table,
};
if matches!(self.focus, Focus::Events) && self.event_panel.cursor.is_none()
{
self.event_panel.cursor = Some(0);
}
if matches!(self.focus, Focus::Table) {
self.event_panel.cursor = None;
}
self.status_message = Some(format!(
"focus: {}",
if matches!(self.focus, Focus::Table) {
"table"
} else {
"events"
}
));
}
KeyCode::Char('[') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.focus = match self.focus {
Focus::Events => Focus::Table,
Focus::Table => {
if self.event_panel.visible {
Focus::Events
} else {
Focus::Table
}
}
};
}
KeyCode::Char(' ') if self.scope == Scope::Envs => {
if let Some(env) = self.selected_env().cloned() {
if !self.multi_selected.remove(&env.name) {
self.multi_selected.insert(env.name);
}
let n = self.multi_selected.len();
self.status_message = if n == 0 {
Some("multi-select cleared".into())
} else {
Some(format!(
"{n} env(s) selected (a = batch action, esc = clear)"
))
};
}
}
KeyCode::Char(' ') if self.scope == Scope::Apps => {
if let Some(idx) = self.app_table_state.selected() {
if let Some(name) = self.applications.get(idx).map(|a| a.name.clone()) {
if !self.apps_selected.remove(&name) {
self.apps_selected.insert(name);
}
let n = self.apps_selected.len();
self.status_message = if n == 0 {
Some("apps multi-select cleared".into())
} else {
Some(format!("{n} app(s) selected (esc = clear)"))
};
}
}
}
KeyCode::Char('y') => {
if let Some(i) = self.event_panel.cursor {
self.yank_event_at(i);
} else {
self.yank_selected(YankKind::Cname);
}
}
KeyCode::Char('Y') => self.yank_selected(YankKind::Name),
KeyCode::Char('J')
if self.event_panel.visible && !self.event_panel.events.is_empty() =>
{
let next = self
.event_panel
.cursor
.map(|c| (c + 1).min(self.event_panel.events.len().saturating_sub(1)))
.unwrap_or(0);
self.event_panel.cursor = Some(next);
}
KeyCode::Char('K')
if self.event_panel.visible && !self.event_panel.events.is_empty() =>
{
self.event_panel.cursor =
self.event_panel.cursor.and_then(|c| c.checked_sub(1));
}
KeyCode::Char('b') if self.scope == Scope::Envs => self.open_in_console(),
KeyCode::Char('D') if self.scope == Scope::Envs => self.open_describe_overlay(),
KeyCode::Char('*') if self.scope == Scope::Envs => self.toggle_pin_selected(),
KeyCode::Char('*') if self.scope == Scope::Apps => {
self.toggle_pin_selected_app()
}
KeyCode::Char('!') if self.scope == Scope::Envs => {
if let Some(env) = self.selected_env() {
let env_name = env.name.clone();
let app_name = env.application.clone();
self.open_why_red(env_name, app_name);
} else {
self.error_message = Some("no env selected".into());
}
}
KeyCode::Char('f') if self.scope == Scope::Envs => {
self.frozen = !self.frozen;
self.status_message = Some(if self.frozen {
"frozen — auto-refresh paused".into()
} else {
"unfrozen".into()
});
}
KeyCode::Char(c @ '1'..='9') => self.quick_jump((c as u8 - b'0') as usize),
KeyCode::Char('?') => {
self.help.topic = HelpTopic::Global;
self.help.pre_mode = Some(Mode::Normal);
self.mode = Mode::Help;
}
KeyCode::Char(':') => {
self.command_input.clear();
self.mode = Mode::Command;
}
KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.open_palette();
}
KeyCode::Char('\'') => {
self.quickjump_input.clear();
self.mode = Mode::QuickJump;
}
KeyCode::Char('/') => {
self.filter.clear();
self.mode = Mode::Filter;
}
KeyCode::Char('p') => self.open_profile_picker(),
KeyCode::Char('r') => self.open_region_picker(),
KeyCode::Char('j') | KeyCode::Down => match self.focus {
Focus::Events if self.event_panel.visible => {
let next = self
.event_panel
.cursor
.map(|c| {
(c + 1).min(self.event_panel.events.len().saturating_sub(1))
})
.unwrap_or(0);
self.event_panel.cursor = Some(next);
}
_ => self.move_scope_selection(1),
},
KeyCode::Char('k') | KeyCode::Up => match self.focus {
Focus::Events if self.event_panel.visible => {
self.event_panel.cursor =
self.event_panel.cursor.and_then(|c| c.checked_sub(1));
}
_ => self.move_scope_selection(-1),
},
KeyCode::Char('g') | KeyCode::Home => self.scope_select_first(),
KeyCode::Char('G') | KeyCode::End => self.scope_select_last(),
_ => {}
}
}
}
}
fn handle_control_op(&mut self, op: crate::control::ControlOp, _terminal: &mut Tui) {
use crate::control::ControlOp;
match op {
ControlOp::Screen(reply) => {
let text = self
.last_rendered_buffer
.as_ref()
.map(crate::control::render_buffer_as_text)
.unwrap_or_else(|| "(no frame rendered yet)".to_string());
let _ = reply.send(text);
}
ControlOp::Key(ke) => {
self.handle_event(Event::Key(ke));
}
ControlOp::Command(text) => {
self.execute_command(&text);
}
ControlOp::Reload => {
self.reload_requested = true;
self.quit = true;
self.status_message = Some("reloading (exec self)…".into());
}
ControlOp::State(reply) => {
let selected = self
.selected_env()
.map(|e| e.name.clone())
.unwrap_or_default();
let env_count = self.environments.len();
let load = match self.load_state {
LoadState::Idle => "idle",
LoadState::Loading => "loading",
LoadState::Error => "error",
};
let esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
let json = format!(
"{{\"mode\":\"{:?}\",\"profile\":\"{}\",\"region\":\"{}\",\"account\":\"{}\",\"envs\":{},\"selected\":\"{}\",\"filter\":\"{}\",\"load\":\"{}\",\"sort\":\"{}\",\"grouped\":{},\"redact\":{},\"focus\":\"{:?}\"}}",
self.mode,
esc(self.context.profile.as_deref().unwrap_or("")),
esc(&self.context.region),
esc(self.context.account_id.as_deref().unwrap_or("")),
env_count,
esc(&selected),
esc(&self.filter),
load,
self.sort_key.label(),
self.grouped,
self.redact,
self.focus,
);
let _ = reply.send(json);
}
}
}
fn manual_refresh(&mut self) {
self.spawn_refresh();
self.status_message = Some("refresh requested".into());
}
pub(crate) fn cmd_cost(&mut self, rest: &[&str]) {
let next = match rest.first().copied() {
Some("on") | Some("true") | Some("enable") => true,
Some("off") | Some("false") | Some("disable") => false,
Some("status") | None => {
let pretty = match (self.cost_enabled, self.costs_fetched_at) {
(false, _) => "off".to_string(),
(true, None) => "on (no data yet)".into(),
(true, Some(t)) => {
let age = chrono::Utc::now()
.signed_duration_since(t)
.to_std()
.unwrap_or_default();
format!(
"on (refreshed {} ago, {} env(s) cached)",
humanize_short_age(age),
self.costs.len()
)
}
};
self.status_message = Some(format!("cost: {pretty}"));
return;
}
Some(other) => {
self.error_message =
Some(format!("usage: :cost on | off | status (got '{other}')"));
return;
}
};
if next == self.cost_enabled {
self.status_message =
Some(format!("cost: already {}", if next { "on" } else { "off" }));
return;
}
self.cost_enabled = next;
if next {
let account = self
.context
.account_id
.clone()
.unwrap_or_else(|| "unknown".into());
let cache = crate::cost_cache::load(&account, &self.context.region);
let now = chrono::Utc::now();
let stale = cache.is_stale(now);
self.costs = cache.costs;
self.costs_fetched_at = cache.fetched_at;
if stale {
self.spawn_cost_fetch();
self.status_message =
Some("cost: on — fetching latest from Cost Explorer (1-3s; cached 24h)".into());
} else {
let age = now
.signed_duration_since(cache.fetched_at.unwrap_or(now))
.to_std()
.unwrap_or_default();
self.status_message = Some(format!(
"cost: on — cached ({} ago; AWS refreshes ~24h)",
humanize_short_age(age)
));
}
} else {
self.costs.clear();
self.costs_fetched_at = None;
self.status_message = Some("cost: off — column hidden, cache preserved".into());
}
self.persist_state();
}
fn spawn_aws<T, Fut, Op, Build>(&self, op_name: &'static str, op: Op, into_msg: Build)
where
T: Send + 'static,
Fut: std::future::Future<Output = Result<T, color_eyre::eyre::Report>> + Send + 'static,
Op: FnOnce(Arc<AwsClient>) -> Fut + Send + 'static,
Build: FnOnce(u64, Result<T, String>) -> AppMsg + Send + 'static,
{
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
tokio::spawn(async move {
let result = op(aws).await.map_err(|e| flatten_err(op_name, e));
let _ = tx.send(into_msg(gen, result));
});
}
fn spawn_cost_fetch(&mut self) {
let account = self.context.account_id.clone();
let region = self.context.region.clone();
self.spawn_aws(
"fetch_env_costs",
move |aws| async move { aws.fetch_env_costs().await },
move |gen, result| AppMsg::CostsFetched {
gen,
account,
region,
result,
},
);
}
fn spawn_alarms_fetch(&mut self, env_name: String) {
self.current_overlay = Some(Overlay::Alarms {
env_name: env_name.clone(),
body: format!("fetching alarms for {env_name}…"),
});
let name_for_msg = env_name.clone();
self.spawn_aws(
"list_alarms_for_env",
move |aws| async move { aws.list_alarms_for_env(&env_name).await },
move |gen, result| AppMsg::Alarms {
gen,
env_name: name_for_msg,
result,
},
);
}
fn open_why_red(&mut self, env_name: String, app_name: String) {
self.why_red_session = self.why_red_session.wrapping_add(1);
let session_id = self.why_red_session;
let tier = self
.environments
.iter()
.find(|e| e.name == env_name)
.map(|e| e.tier.clone())
.unwrap_or_default();
let is_worker = tier.eq_ignore_ascii_case("Worker");
self.current_overlay = Some(Overlay::WhyRed {
env_name: env_name.clone(),
tier,
events: None,
alarms: None,
instances: None,
deploys: None,
queues: None,
dlq_messages: None,
session_id,
cursor: 0,
});
self.spawn_why_red_events(env_name.clone(), session_id);
self.spawn_why_red_alarms(env_name.clone(), session_id);
self.spawn_why_red_instances(env_name.clone(), session_id);
self.spawn_why_red_deploys(app_name.clone(), session_id);
if is_worker {
self.spawn_why_red_queues(app_name, env_name, session_id);
}
}
fn spawn_why_red_queues(&self, app_name: String, env_name: String, session_id: u64) {
if self.demo_mode {
let result = Ok(crate::demo_fixture::worker_queues_for_env(&env_name));
let gen = self.generation;
let _ = self.msg_tx.send(AppMsg::WhyRedQueues {
gen,
session_id,
result,
});
return;
}
self.spawn_aws(
"describe_worker_queues",
move |aws| async move { aws.describe_worker_queues(&app_name, &env_name).await },
move |gen, result| AppMsg::WhyRedQueues {
gen,
session_id,
result,
},
);
}
fn spawn_why_red_dlq_peek(&self, dlq_url: String, session_id: u64) {
self.spawn_aws(
"peek_messages",
move |aws| async move { aws.peek_messages(&dlq_url, 3).await },
move |gen, result| AppMsg::WhyRedDlqMessages {
gen,
session_id,
result,
},
);
}
fn spawn_why_red_events(&self, env_name: String, session_id: u64) {
if self.demo_mode {
let result = Ok(crate::demo_fixture::events_for_env(&env_name));
let gen = self.generation;
let _ = self.msg_tx.send(AppMsg::WhyRedEvents {
gen,
session_id,
result,
});
return;
}
self.spawn_aws(
"list_events_for_env",
move |aws| async move { aws.list_events_for_env(&env_name, 50).await },
move |gen, result| AppMsg::WhyRedEvents {
gen,
session_id,
result,
},
);
}
fn spawn_why_red_alarms(&self, env_name: String, session_id: u64) {
if self.demo_mode {
let result = Ok(crate::demo_fixture::alarms_for_env(&env_name));
let gen = self.generation;
let _ = self.msg_tx.send(AppMsg::WhyRedAlarms {
gen,
session_id,
result,
});
return;
}
self.spawn_aws(
"list_alarms_for_env",
move |aws| async move { aws.list_alarms_for_env(&env_name).await },
move |gen, result| AppMsg::WhyRedAlarms {
gen,
session_id,
result,
},
);
}
fn spawn_why_red_instances(&self, env_name: String, session_id: u64) {
if self.demo_mode {
let result = Ok(crate::demo_fixture::instances_for(&env_name));
let gen = self.generation;
let _ = self.msg_tx.send(AppMsg::WhyRedInstances {
gen,
session_id,
result,
});
return;
}
self.spawn_aws(
"list_instances",
move |aws| async move { aws.list_instances(&env_name).await },
move |gen, result| AppMsg::WhyRedInstances {
gen,
session_id,
result,
},
);
}
fn spawn_why_red_deploys(&self, app_name: String, session_id: u64) {
if self.demo_mode {
let result = Ok(crate::demo_fixture::deploys_for_app(&app_name));
let gen = self.generation;
let _ = self.msg_tx.send(AppMsg::WhyRedDeploys {
gen,
session_id,
result,
});
return;
}
self.spawn_aws(
"list_application_versions",
move |aws| async move { aws.list_application_versions(&app_name).await },
move |gen, result| AppMsg::WhyRedDeploys {
gen,
session_id,
result,
},
);
}
fn spawn_detail_alarms(&mut self, env_name: String) {
if let Some(d) = self.detail.as_mut() {
d.loading_cw_alarms = true;
}
if self.demo_mode {
let result = Ok(crate::demo_fixture::alarms_for_env(&env_name));
let gen = self.generation;
let _ = self.msg_tx.send(AppMsg::DetailAlarms {
gen,
env_name,
result,
});
return;
}
let env_for_msg = env_name.clone();
self.spawn_aws(
"list_alarms_for_env",
move |aws| async move { aws.list_alarms_for_env(&env_name).await },
move |gen, result| AppMsg::DetailAlarms {
gen,
env_name: env_for_msg,
result,
},
);
}
fn spawn_detail_recent_versions(&mut self, app_name: String, env_name: String) {
if let Some(d) = self.detail.as_mut() {
d.loading_recent_versions = true;
}
if self.demo_mode {
let result = Ok(crate::demo_fixture::deploys_for_app(&app_name));
let gen = self.generation;
let _ = self.msg_tx.send(AppMsg::DetailRecentVersions {
gen,
env_name,
result,
});
return;
}
self.spawn_aws(
"list_application_versions",
move |aws| async move { aws.list_application_versions(&app_name).await },
move |gen, result| AppMsg::DetailRecentVersions {
gen,
env_name,
result,
},
);
}
fn set_log_level(&mut self, level: &str) {
let directive = match level.to_lowercase().as_str() {
"trace" | "debug" | "info" | "warn" | "error" => {
format!("{level},aws=warn,hyper=warn")
}
other => other.to_string(),
};
let new_filter = match tracing_subscriber::EnvFilter::try_new(&directive) {
Ok(f) => f,
Err(e) => {
self.error_message = Some(format!("invalid log directive '{level}': {e}"));
return;
}
};
let Some(handle) = self.log_reload.as_ref() else {
self.error_message = Some("log reload handle missing".into());
return;
};
match handle.modify(|f| *f = new_filter) {
Ok(()) => {
self.log_directive = directive.clone();
self.status_message = Some(format!("log level → {directive}"));
}
Err(e) => self.error_message = Some(format!("log reload failed: {e}")),
}
}
fn open_whatsnew(&mut self) {
self.current_overlay = Some(Overlay::Whatsnew(WHATSNEW.into()));
}
pub(crate) fn open_report_bug_overlay(&mut self) {
let cnames: std::collections::BTreeSet<String> = self
.environments
.iter()
.filter(|e| !e.cname.is_empty())
.map(|e| e.cname.clone())
.collect();
let env_names: std::collections::BTreeSet<String> =
self.environments.iter().map(|e| e.name.clone()).collect();
let app_names: std::collections::BTreeSet<String> =
self.applications.iter().map(|a| a.name.clone()).collect();
let recent_messages: Vec<String> = self
.message_log
.iter()
.rev()
.take(10)
.map(|(ts, kind, text)| {
let sev = match kind {
MsgKind::Info => "[i]",
MsgKind::Error => "[!]",
};
let when = ts.format("%H:%M:%S");
format!("{when} {sev} {text}")
})
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let icons = format!("{:?}", self.theme.icons).to_lowercase();
let input = crate::report_bug::ReportInput {
ebman_version: env!("CARGO_PKG_VERSION"),
os: std::env::consts::OS,
os_release: std::env::consts::ARCH,
icons: &icons,
theme: self.theme.name,
refresh_interval_secs: self.refresh_interval.as_secs(),
recent_log_lines: crate::report_bug::tail_ebman_log(30),
recent_messages,
recent_crash: crate::report_bug::latest_crash_log(),
env_count: self.environments.len(),
app_count: self.applications.len(),
multi_regions_count: self.multi_regions.len(),
multi_account_enabled: !self.accounts.is_empty(),
};
let ctx = crate::report_bug::ScrubContext {
account_id: self.context.account_id.clone(),
profile: self.context.profile.clone(),
region: Some(self.context.region.clone()),
env_names,
app_names,
cnames,
};
let body = crate::report_bug::build_report(&input, &ctx);
self.current_overlay = Some(Overlay::ReportBug { body });
}
fn handle_report_bug_key(&mut self, key: KeyEvent) {
let body = match self.current_overlay.as_ref() {
Some(Overlay::ReportBug { body }) => body.clone(),
_ => return,
};
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
self.current_overlay = None;
}
KeyCode::Char('y') | KeyCode::Char('Y') => {
match yank(&body) {
Ok(()) => {
self.status_message = Some(format!(
"bug report copied to clipboard ({} chars) — paste at https://github.com/tombaldwin/ebman/issues/new",
body.chars().count()
));
}
Err(e) => {
self.error_message = Some(format!("clipboard error: {e}"));
}
}
self.current_overlay = None;
}
KeyCode::Char('b') | KeyCode::Char('B') => {
let url = crate::report_bug::github_issue_url(
"tombaldwin/ebman",
"Bug report from ebman",
&body,
);
match open_url(&url) {
Ok(()) => {
self.status_message = Some("opened GitHub issue draft in browser".into());
}
Err(e) => {
self.error_message = Some(format!("couldn't open browser: {e}"));
}
}
self.current_overlay = None;
}
_ => {}
}
}
fn command_completion_step(&mut self, delta: i32) {
if self.completion.origin.is_none() {
self.completion.origin = Some(self.command_input.clone());
self.completion.index = 0;
}
let origin = self.completion.origin.clone().unwrap_or_default();
let (prefix, rest): (String, String) = match origin.find(char::is_whitespace) {
Some(i) => (origin[..i].to_string(), origin[i..].to_string()),
None => (origin.clone(), String::new()),
};
let candidates = completion_candidates(&prefix);
if candidates.is_empty() {
self.command_input = origin;
self.status_message = Some(format!(
"no command matches '{prefix}' (Tab cycles command names)"
));
return;
}
let n = candidates.len() as i32;
let cur = self.completion.index as i32;
let next = (cur + delta).rem_euclid(n) as usize;
self.completion.index = next;
self.command_input = format!("{}{rest}", candidates[next]);
self.status_message = Some(format!(
"completion {}/{} — Tab cycles, Esc cancels",
next + 1,
n
));
}
pub(crate) fn cmd_secrets(&mut self, rest: &[&str]) {
let filter = rest.first().map(|s| s.to_string());
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let title_filter = filter.clone();
self.status_message = Some(match filter.as_deref() {
Some(f) => format!("listing secrets matching '{f}'…"),
None => "listing secrets…".into(),
});
tokio::spawn(async move {
let result = aws
.list_secrets(filter.as_deref())
.await
.map_err(|e| flatten_err("list_secrets", e));
let body = match result {
Ok(rows) => render_secrets_overlay(&rows, title_filter.as_deref()),
Err(e) => format!("secrets: {e}\n\nesc / q to close"),
};
let _ = tx.send(AppMsg::TextOverlay {
gen,
title: "secrets".into(),
body,
});
});
}
pub(crate) fn cmd_secret_view(&mut self, rest: &[&str]) {
let Some(name) = rest.first().map(|s| s.to_string()) else {
self.error_message =
Some("usage: :secret NAME (NAME or full ARN; see :secrets to list)".into());
return;
};
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let redact = self.redact;
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!("stage=dispatched action=GetSecretValue target={name}"),
);
let account = self.context.account_id.clone();
let profile = self.context.profile.clone();
let region = self.context.region.clone();
self.status_message = Some(format!("fetching secret '{name}'…"));
tokio::spawn(async move {
let result = aws
.fetch_secret_value(&name)
.await
.map_err(|e| flatten_err("fetch_secret_value", e));
let outcome = match &result {
Ok(_) => format!("stage=completed action=GetSecretValue target={name} ok"),
Err(e) => format!(
"stage=completed action=GetSecretValue target={name} err=\"{}\"",
e.replace('"', "'")
),
};
write_audit_line(account.as_deref(), profile.as_deref(), ®ion, &outcome);
let body = match result {
Ok(value) => render_secret_value_overlay(&name, &value, redact),
Err(e) => format!("secret: {e}\n\nesc / q to close"),
};
let _ = tx.send(AppMsg::TextOverlay {
gen,
title: format!("secret — {name}"),
body,
});
});
}
pub(crate) fn cmd_rollback(&mut self, rest: &[&str]) {
let Some(env) = self.selected_env().cloned() else {
self.error_message = Some("no env selected".into());
return;
};
if self.deny_write(&env.name, "rollback") {
return;
}
let auto_rollback_secs = parse_named_arg::<String>(rest, "--auto-rollback").and_then(|s| {
let ms = crate::aws::parse_window_ms(&s)?;
Some((ms / 1000) as u64)
});
if rest.contains(&"--auto-rollback") && auto_rollback_secs.is_none() {
self.error_message =
Some("--auto-rollback expects a duration like `5m` / `30m` / `1h`".into());
return;
}
if let Some(target) = parse_named_arg::<String>(rest, "--to") {
if target.is_empty() {
self.error_message = Some("--to expects a version label".into());
return;
}
if target == env.version_label {
self.error_message = Some(format!("{target} is already the deployed version"));
return;
}
self.open_parameterised_action(
Action::Deploy,
ParameterisedAction {
deploy_version: Some(target.clone()),
auto_rollback_secs,
..Default::default()
},
);
self.status_message = Some(format!("rollback target: {target} (operator-specified)"));
return;
}
let env_name = env.name.clone();
let current_version = env.version_label.clone();
if let Some(snapshot) = self.deploy_snapshots.get(&env_name).cloned() {
if snapshot.previous_version_label != current_version {
self.open_parameterised_action(
Action::Deploy,
ParameterisedAction {
deploy_version: Some(snapshot.previous_version_label.clone()),
auto_rollback_secs,
..Default::default()
},
);
let age = (chrono::Utc::now() - snapshot.taken_at).num_seconds();
self.status_message = Some(format!(
"rollback target: {} (from snapshot taken {}s ago)",
snapshot.previous_version_label, age
));
return;
}
}
if auto_rollback_secs.is_some() {
self.error_message = Some(format!(
"--auto-rollback needs an in-memory snapshot for {env_name} — none captured. \
Try `:rollback --to LABEL --auto-rollback Nm` to name the target explicitly."
));
return;
}
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
self.status_message = Some(format!("rollback: finding {env_name}'s previous version…"));
tokio::spawn(async move {
let result = aws
.list_events_for_env(&env_name, 100)
.await
.map_err(|e| flatten_err("list_events_for_env", e));
let _ = tx.send(AppMsg::RollbackTarget {
gen,
env_name,
current_version,
result,
});
});
}
pub(crate) fn cmd_ssm_run(&mut self, rest: &[&str]) {
if rest.is_empty() {
self.error_message = Some(
"usage: :ssm-run \"<shell-command>\" (fans the command out across the env's instances; quotes preserve whitespace)".into(),
);
return;
}
let command_str = rest.join(" ");
let trimmed = command_str
.trim_matches(|c: char| c == '"' || c == '\'')
.to_string();
if trimmed.is_empty() {
self.error_message = Some("empty command — nothing to run".into());
return;
}
let instances: Vec<String> = self
.detail
.as_ref()
.map(|d| d.instances.iter().map(|i| i.id.clone()).collect())
.unwrap_or_default();
if instances.is_empty() {
self.error_message = Some(
"no cached instances — open the env's Detail/Instances tab first so :ssm-run knows what to target".into(),
);
return;
}
let env_name = self
.detail
.as_ref()
.map(|d| d.env_name.clone())
.unwrap_or_default();
if self.deny_write(&env_name, "ssm-run") {
return;
}
let audit_cmd = trimmed.replace('"', "'");
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!(
"stage=dispatched action=SsmRunCommand target={env_name} instances={n} cmd=\"{cmd}\"",
n = instances.len(),
cmd = audit_cmd,
),
);
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let command_for_render = trimmed.clone();
let n = instances.len();
let audit_account = self.context.account_id.clone();
let audit_profile = self.context.profile.clone();
let audit_region = self.context.region.clone();
let audit_env = env_name.clone();
let audit_cmd_for_outcome = audit_cmd.clone();
self.status_message = Some(format!("running `{trimmed}` on {n} instance(s)…"));
tokio::spawn(async move {
let result = aws
.run_shell_command(&instances, &trimmed, 60)
.await
.map_err(|e| flatten_err("run_shell_command", e));
let outcome = match &result {
Ok(rows) => {
let oks = rows.iter().filter(|r| r.status == "Success").count();
format!(
"stage=completed action=SsmRunCommand target={audit_env} ok={oks}/{n} cmd=\"{audit_cmd_for_outcome}\""
)
}
Err(e) => format!(
"stage=completed action=SsmRunCommand target={audit_env} err=\"{}\" cmd=\"{audit_cmd_for_outcome}\"",
e.replace('"', "'")
),
};
write_audit_line(
audit_account.as_deref(),
audit_profile.as_deref(),
&audit_region,
&outcome,
);
let body = match result {
Ok(rows) => format_ssm_results(&command_for_render, &rows),
Err(e) => format!("ssm-run: {e}\n\nesc / q to close"),
};
let _ = tx.send(AppMsg::TextOverlay {
gen,
title: "ssm-run".into(),
body,
});
});
}
pub(crate) fn cmd_ssh(&mut self, rest: &[&str]) {
match rest.first().copied() {
Some(id) => {
if !id.starts_with("i-") {
self.error_message =
Some(format!("expected an EC2 instance ID (`i-…`), got '{id}'"));
return;
}
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!("stage=dispatched action=SsmSession target={id} via=cmd_ssh"),
);
self.pending_shell_target = Some(id.to_string());
self.status_message = Some(format!("opening SSM session to {id}…"));
}
None => {
let instances: Vec<String> = self
.detail
.as_ref()
.map(|d| d.instances.iter().map(|i| i.id.clone()).collect())
.unwrap_or_default();
if instances.is_empty() {
self.error_message = Some(
"no cached instances — open the env's Detail/Instances tab first, or pass an ID (`:ssh i-abc`)".into(),
);
return;
}
self.picker = Some(Picker::new(PickerKind::SshInstance, instances, None));
self.mode = Mode::Picker;
}
}
}
pub(crate) fn cmd_lineage(&mut self) {
let env_opt = if let Some(d) = self.detail.as_ref() {
Some(d.env_name.clone())
} else {
self.selected_env().map(|e| e.name.clone())
};
let Some(env_name) = env_opt else {
self.error_message = Some("no env selected".into());
return;
};
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
self.status_message = Some(format!("fetching deploy lineage for {env_name}…"));
tokio::spawn(async move {
let result = aws
.list_events_for_env(&env_name, 100)
.await
.map_err(|e| flatten_err("list_events_for_env", e));
let body = match result {
Ok(events) => format_lineage(&env_name, &events),
Err(e) => format!("lineage: {e}\n\nesc / q to close"),
};
let _ = tx.send(AppMsg::TextOverlay {
gen,
title: format!("lineage — {env_name}"),
body,
});
});
}
pub(crate) fn cmd_changes(&mut self) {
let env_opt = if let Some(d) = self.detail.as_ref() {
Some(d.env_name.clone())
} else {
self.selected_env().map(|e| e.name.clone())
};
let Some(env_name) = env_opt else {
self.error_message = Some("no env selected".into());
return;
};
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
self.status_message = Some(format!("fetching change history for {env_name}…"));
tokio::spawn(async move {
let result = aws
.list_events_for_env(&env_name, 100)
.await
.map_err(|e| flatten_err("list_events_for_env", e));
let body = match result {
Ok(events) => render_changes_overlay(&env_name, &events),
Err(e) => format!("changes: {e}\n\nesc / q to close"),
};
let _ = tx.send(AppMsg::TextOverlay {
gen,
title: format!("changes — {env_name}"),
body,
});
});
}
pub(crate) fn cmd_event_time(&mut self, rest: &[&str]) {
let next = match rest.first().copied() {
None => self.event_panel.time_format.next(),
Some(arg) => match EventTimeFormat::parse(arg) {
Some(f) => f,
None => {
self.error_message = Some(format!(
"unknown event-time format '{arg}' (use: utc | local | age)"
));
return;
}
},
};
self.event_panel.time_format = next;
self.persist_state();
self.status_message = Some(match next {
EventTimeFormat::Utc => "event timestamps: UTC (YYYY-MM-DD HH:MM:SSZ)".into(),
EventTimeFormat::Local => "event timestamps: local time".into(),
EventTimeFormat::Age => "event timestamps: relative age".into(),
});
}
pub(crate) fn cmd_env_edit(&mut self) {
let Some(env) = self.selected_env().cloned() else {
self.error_message = Some("no env selected".into());
return;
};
if self.deny_write(&env.name, ":env-edit") {
return;
}
if self.pending_env_edit.is_some() {
self.error_message =
Some("another :env-edit is mid-flight — wait for the editor to close".into());
return;
}
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let app_name = env.application.clone();
let env_name = env.name.clone();
let env_name_for_msg = env_name.clone();
self.status_message = Some(format!("fetching env vars for {env_name}…"));
tokio::spawn(async move {
let result = aws
.fetch_env_vars(&app_name, &env_name)
.await
.map_err(|e| flatten_err("fetch_env_vars", e));
let _ = tx.send(AppMsg::EnvVarsForEdit {
gen,
env_name: env_name_for_msg,
result,
});
});
}
pub(crate) fn cmd_explain(&mut self, rest: &[&str]) {
let (principal, actions): (String, Vec<String>) = match rest.first().copied() {
Some(arn) if arn.starts_with("arn:aws:") && rest.len() >= 2 => {
let actions: Vec<String> = rest[1..].iter().map(|s| s.to_string()).collect();
(arn.to_string(), actions)
}
Some(_) => {
self.error_message = Some(
"usage: :explain (no args, walks last error) | :explain ARN ACTION [ACTION ...]"
.into(),
);
return;
}
None => {
let latest = self.message_log.iter().rev().find(|(_, kind, text)| {
matches!(kind, MsgKind::Error) && text.contains("is not authorized to perform")
});
let Some((_, _, text)) = latest else {
self.error_message = Some(
"no recent AccessDenied to explain — :explain ARN ACTION to evaluate explicitly".into(),
);
return;
};
match parse_access_denied(text) {
Some((arn, action)) => (arn, vec![action]),
None => {
self.error_message = Some(format!(
"couldn't parse principal + action from last error: {text}"
));
return;
}
}
}
};
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let principal_for_title = principal.clone();
self.status_message = Some(format!(
"diagnosing IAM perms for {} action(s) on {principal}…",
actions.len()
));
tokio::spawn(async move {
let result = aws
.simulate_principal_policy(&principal, &actions, &[])
.await
.map_err(|e| flatten_err("simulate_principal_policy", e));
let body = match result {
Ok(rows) => render_explain_overlay(&principal, &rows),
Err(e) => format!(
"explain: {e}\n\n\
This usually means the caller lacks `iam:SimulatePrincipalPolicy`\n\
on the target role — common with assumed-role sessions that don't\n\
have IAM perms. Try from a profile with IAM access.\n\n\
esc / q to close"
),
};
let _ = tx.send(AppMsg::TextOverlay {
gen,
title: format!("explain — {principal_for_title}"),
body,
});
});
}
pub(crate) fn cmd_options(&mut self, rest: &[&str]) {
let Some(env) = self.selected_env().cloned() else {
self.error_message = Some("no env selected".into());
return;
};
let filter_ns = rest.first().map(|s| s.to_string());
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let app_name = env.application.clone();
let env_name = env.name.clone();
self.status_message = Some(format!(
"fetching config vocabulary for {env_name}… (this can take a few seconds)"
));
tokio::spawn(async move {
let result = aws
.fetch_env_configuration_options(&app_name, &env_name)
.await
.map_err(|e| flatten_err("fetch_env_configuration_options", e));
let body = match result {
Ok(rows) => render_options_overlay(&rows, filter_ns.as_deref(), &env_name),
Err(e) => format!("options: {e}\n\nesc / q to close"),
};
let _ = tx.send(AppMsg::TextOverlay {
gen,
title: format!("options — {env_name}"),
body,
});
});
}
pub(crate) fn cmd_config_diff_local(&mut self, rest: &[&str]) {
let Some(env) = self.selected_env().cloned() else {
self.error_message = Some("no env selected".into());
return;
};
let cwd = match std::env::current_dir() {
Ok(p) => p,
Err(e) => {
self.error_message = Some(format!("can't read cwd: {e}"));
return;
}
};
let path = match rest.first().copied() {
Some(name) => match crate::saved_config::resolve_saved_config(&cwd, name) {
Ok(p) => p,
Err(e) => {
self.error_message = Some(format!("config-diff-local: {e}"));
return;
}
},
None => {
let configs = match crate::saved_config::discover_saved_configs(&cwd) {
Ok(c) => c,
Err(e) => {
self.error_message = Some(format!("config-diff-local: {e}"));
return;
}
};
match configs.len() {
0 => {
self.error_message = Some(format!(
"no .elasticbeanstalk/saved_configs/*.cfg.yml under {}",
cwd.display()
));
return;
}
1 => configs.into_iter().next().unwrap(),
_ => {
let names: Vec<String> = configs
.iter()
.map(|p| crate::saved_config::saved_config_name(p))
.collect();
self.error_message = Some(format!(
"multiple saved configs — pick one: :config-diff-local <{}>",
names.join(" | ")
));
return;
}
}
}
};
let yaml = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
self.error_message = Some(format!("reading {}: {e}", path.display()));
return;
}
};
let local_opts = match crate::saved_config::parse_saved_config(&yaml) {
Ok(o) => o,
Err(e) => {
self.error_message = Some(format!("parsing {}: {e}", path.display()));
return;
}
};
let local_name = crate::saved_config::saved_config_name(&path);
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let (app_name, env_name) = (env.application.clone(), env.name.clone());
let env_name_for_title = env_name.clone();
let local_name_for_title = local_name.clone();
self.status_message = Some(format!(
"comparing {env_name} ↔ saved config '{local_name}'…"
));
tokio::spawn(async move {
let result = aws
.fetch_env_configuration_options(&app_name, &env_name)
.await
.map_err(|e| flatten_err("fetch_env_configuration_options", e));
let body = match result {
Ok(deployed) => {
let diffs = diff_config_options(&local_opts, &deployed);
let left_label = format!("local:{local_name}");
render_config_diff_overlay(&left_label, &env_name, &diffs)
}
Err(e) => format!("config-diff-local: {e}\n\nesc / q to close"),
};
let _ = tx.send(AppMsg::TextOverlay {
gen,
title: format!("config-diff-local — {env_name_for_title} ↔ {local_name_for_title}"),
body,
});
});
}
pub(crate) fn cmd_config_diff(&mut self, rest: &[&str]) {
let Some(target) = rest.first().map(|s| s.to_string()) else {
self.error_message = Some(
"usage: :config-diff ENV (compare the selected env's option-settings against ENV)"
.into(),
);
return;
};
let left = if let Some(d) = self.detail.as_ref() {
Some(d.env_snapshot.clone())
} else {
self.selected_env().cloned()
};
let Some(left) = left else {
self.error_message = Some("no env selected".into());
return;
};
let Some(right) = self.environments.iter().find(|e| e.name == target).cloned() else {
self.error_message = Some(format!("no env named '{target}' in the current view"));
return;
};
if left.name == right.name {
self.error_message = Some("pick a different env to compare against".into());
return;
}
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let (la, ln) = (left.application.clone(), left.name.clone());
let (ra, rn) = (right.application.clone(), right.name.clone());
self.status_message = Some(format!("comparing config: {ln} ↔ {rn}…"));
tokio::spawn(async move {
let body = match tokio::try_join!(
aws.fetch_env_configuration_options(&la, &ln),
aws.fetch_env_configuration_options(&ra, &rn),
) {
Ok((lopts, ropts)) => {
let diffs = diff_config_options(&lopts, &ropts);
render_config_diff_overlay(&ln, &rn, &diffs)
}
Err(e) => format!(
"config-diff: {}\n\nesc / q to close",
flatten_err("fetch_env_configuration_options", e)
),
};
let _ = tx.send(AppMsg::TextOverlay {
gen,
title: format!("config diff — {ln} ↔ {rn}"),
body,
});
});
}
pub(crate) fn cmd_rds(&mut self) {
let Some(env) = self.selected_env().cloned() else {
self.error_message = Some("no env selected".into());
return;
};
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let app_name = env.application.clone();
let env_name = env.name.clone();
self.status_message = Some(format!("fetching RDS config for {env_name}…"));
tokio::spawn(async move {
let result = aws
.fetch_env_rds_config(&app_name, &env_name)
.await
.map_err(|e| flatten_err("fetch_env_rds_config", e));
let body = match result {
Ok(rows) if rows.is_empty() => "No RDS instance attached to this env.\n\n\
EB-managed RDS is configured via `aws:rds:dbinstance.*`\n\
option settings. To attach a new one:\n\n \
:set-option aws:rds:dbinstance DBEngine postgres\n \
:set-option aws:rds:dbinstance DBInstanceClass db.t3.micro\n \
:set-option aws:rds:dbinstance DBPassword <secret>\n\n\
(See the EB docs — there are 10+ required fields. A\n\
dedicated `:rds-attach` form is a planned follow-up.)\n\n\
esc / q to close"
.to_string(),
Ok(rows) => {
let mut body = String::from("RDS dbinstance configuration:\n\n");
for (opt, value) in &rows {
let safe_value = if opt.eq_ignore_ascii_case("DBPassword") {
"(redacted)".to_string()
} else {
value.clone()
};
body.push_str(&format!(" {opt:<28} {safe_value}\n"));
}
body.push_str(
"\nUse `:set-option aws:rds:dbinstance <KEY> <VALUE>` to change a setting.\n\
Note: most RDS option changes trigger instance modification (downtime risk).\n\
esc / q to close",
);
body
}
Err(e) => format!("rds: {e}\n\nesc / q to close"),
};
let _ = tx.send(AppMsg::TextOverlay {
gen,
title: format!("rds — {env_name}"),
body,
});
});
}
pub(crate) fn cmd_listeners(&mut self) {
let Some(env) = self.selected_env().cloned() else {
self.error_message = Some("no env selected".into());
return;
};
if env.tier.eq_ignore_ascii_case("Worker") {
self.error_message = Some(format!(
"env '{}' is Worker tier — no ALB to configure",
env.name
));
return;
}
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let app_name = env.application.clone();
let env_name = env.name.clone();
self.status_message = Some(format!("fetching listeners for {env_name}…"));
tokio::spawn(async move {
let result = aws
.fetch_env_listeners(&app_name, &env_name)
.await
.map_err(|e| flatten_err("fetch_env_listeners", e));
let body = match result {
Ok(rows) if rows.is_empty() => "No listener config found.\n\n\
The env may use a Classic ELB instead of an ALB, or no\n\
listener overrides have been set (EB uses account defaults).\n\
`:set-option aws:elbv2:listener:443 SSLCertificateArns ARN`\n\
to configure a listener from scratch.\n\nesc / q to close"
.to_string(),
Ok(rows) => {
let mut body = String::from("Listener configuration:\n");
body.push_str("(one block per port; `default` = HTTP/80)\n\n");
let mut current_port: Option<String> = None;
for (port, opt, value) in &rows {
if current_port.as_deref() != Some(port.as_str()) {
if current_port.is_some() {
body.push('\n');
}
body.push_str(&format!("── aws:elbv2:listener:{port} ──\n"));
current_port = Some(port.clone());
}
body.push_str(&format!(" {opt:<32} {value}\n"));
}
body.push_str(
"\n`:set-option aws:elbv2:listener:<PORT> <KEY> <VALUE>` to change a setting.\n\
esc / q to close",
);
body
}
Err(e) => format!("listeners: {e}\n\nesc / q to close"),
};
let _ = tx.send(AppMsg::TextOverlay {
gen,
title: format!("listeners — {env_name}"),
body,
});
});
}
pub(crate) fn cmd_listener_edit(&mut self, rest: &[&str]) {
use crate::form::{Form, FormField, FormSubmit};
let Some(env) = self.selected_env().cloned() else {
self.error_message = Some("no env selected".into());
return;
};
if env.tier.eq_ignore_ascii_case("Worker") {
self.error_message = Some(format!(
"env '{}' is Worker tier — no ALB to configure",
env.name
));
return;
}
let Some(port) = rest.first().copied() else {
self.error_message = Some(
"usage: :listener-edit PORT (e.g. :listener-edit 443; `default` = HTTP/80)".into(),
);
return;
};
let port = port.to_string();
let ns = format!("aws:elbv2:listener:{port}");
let placeholder = FormField::multi_select(
"cert",
"SSL certificate(s)",
Vec::new(),
Vec::new(),
Some::<String>("space toggle · ↑↓ option cursor · loaded from ACM".into()),
);
let form = Form::loading(
format!("listener {port} — {}", env.name),
env.name.clone(),
format!("listener {port} cert update"),
vec![placeholder],
FormSubmit::OptionSettings {
mappings: vec![("cert".into(), ns, "SSLCertificateArns".into())],
},
);
self.form = Some(form);
self.mode = Mode::Form;
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let env_for_msg = env.name.clone();
let app_name = env.application.clone();
tokio::spawn(async move {
let result = load_listener_certs(aws, &app_name, &env_for_msg, &port).await;
let _ = tx.send(AppMsg::FormMultiSelectLoaded {
gen,
env_name: env_for_msg,
field_key: "cert".to_string(),
result,
});
});
}
pub(crate) fn open_apps_info_overlay(&mut self) {
let app_name_opt = match self.scope {
Scope::Apps => self
.app_table_state
.selected()
.and_then(|i| self.applications.get(i).map(|a| a.name.clone())),
Scope::Envs => self.selected_env().map(|e| e.application.clone()),
};
let Some(app_name) = app_name_opt else {
self.error_message = Some("no application selected".into());
return;
};
let Some(app) = self.applications.iter().find(|a| a.name == app_name) else {
self.error_message = Some(format!(
"application '{app_name}' not in cache yet — refresh and retry"
));
return;
};
let rollup = app_rollup(&self.environments, &app.name, &self.worker_dlq_depths);
let env_names: Vec<&str> = self
.environments
.iter()
.filter(|e| e.application == app.name)
.map(|e| e.name.as_str())
.collect();
let date_fmt = |dt: Option<chrono::DateTime<chrono::Utc>>| -> String {
dt.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
.unwrap_or_else(|| "—".into())
};
let templates_block = if app.templates.is_empty() {
" (none)".to_string()
} else {
app.templates
.iter()
.map(|t| format!(" ▸ {t}"))
.collect::<Vec<_>>()
.join("\n")
};
let envs_block = if env_names.is_empty() {
" (none)".to_string()
} else {
env_names
.iter()
.map(|n| format!(" ▸ {n}"))
.collect::<Vec<_>>()
.join("\n")
};
let description = if app.description.is_empty() {
"(no description)".to_string()
} else {
app.description.clone()
};
let latest_line = match (
app.latest_version_label.as_deref(),
app.latest_version_created,
) {
(Some(label), Some(created)) => format!("{label} ({})", date_fmt(Some(created))),
(Some(label), None) => label.to_string(),
_ => "—".into(),
};
let body = format!(
"Application: {}\n\
Description: {description}\n\n\
Created: {created}\n\
Updated: {updated}\n\n\
Versions: {version_count} registered · latest: {latest_line}\n\
Envs: {env_count} total · {red_count} alerting · {updating_count} updating\n\n\
Environments:\n{envs_block}\n\n\
Saved configuration templates:\n{templates_block}\n\n\
esc / q to close",
app.name,
created = date_fmt(app.date_created),
updated = date_fmt(app.date_updated),
version_count = app.version_count,
env_count = rollup.env_count,
red_count = rollup.red_count + rollup.worker_dlq_alerts,
updating_count = rollup.updating_count,
);
self.current_overlay = Some(Overlay::TextDump {
title: format!("info — {}", app.name),
body,
});
}
fn open_about_overlay(&mut self) {
self.current_overlay = Some(Overlay::About(Instant::now()));
}
fn toggle_pin_selected(&mut self) {
let name_opt = if let Some(d) = self.detail.as_ref() {
Some(d.env_name.clone())
} else {
self.selected_env().map(|e| e.name.clone())
};
let Some(name) = name_opt else {
self.status_message = Some("no env selected".into());
return;
};
if self.pinned.remove(&name) {
self.status_message = Some(format!("unpinned {name}"));
} else {
self.pinned.insert(name.clone());
self.status_message = Some(format!("pinned {name}"));
}
self.resort_envs();
self.persist_state();
}
fn toggle_pin_selected_app(&mut self) {
let Some(idx) = self.app_table_state.selected() else {
self.status_message = Some("no app selected".into());
return;
};
let Some(name) = self.applications.get(idx).map(|a| a.name.clone()) else {
return;
};
if self.pinned_apps.remove(&name) {
self.status_message = Some(format!("unpinned app {name}"));
} else {
self.pinned_apps.insert(name.clone());
self.status_message = Some(format!("pinned app {name}"));
}
self.resort_applications();
self.persist_state();
}
fn resort_applications(&mut self) {
let pinned = self.pinned_apps.clone();
self.applications.sort_by(|a, b| {
let a_pin = pinned.contains(&a.name);
let b_pin = pinned.contains(&b.name);
if a_pin != b_pin {
return if a_pin {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Greater
};
}
a.name.cmp(&b.name)
});
}
fn yank_cli(&mut self) {
let env_opt = if let Some(d) = self.detail.as_ref() {
Some(d.env_snapshot.clone())
} else {
self.selected_env().cloned()
};
let Some(env) = env_opt else {
self.status_message = Some("no env selected".into());
return;
};
let cmd = build_describe_cli(
&env.name,
&self.context.region,
self.override_profile
.as_deref()
.or(self.context.profile.as_deref()),
);
match yank(&cmd) {
Ok(()) => {
self.status_message = Some("equivalent AWS CLI command copied".into());
}
Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
}
}
fn export_json(&mut self) {
let count = self.cached_filtered.len();
let mut out = String::from("[\n");
for (idx, &i) in self.cached_filtered.iter().enumerate() {
let e = &self.environments[i];
let cname = if self.redact {
redact_block(&e.cname)
} else {
e.cname.clone()
};
let updated = e
.updated
.map(|u| format!("\"{}\"", u.to_rfc3339()))
.unwrap_or_else(|| "null".into());
out.push_str(&format!(
" {{\"name\":\"{}\",\"application\":\"{}\",\"tier\":\"{}\",\"status\":\"{}\",\"health\":\"{}\",\"platform\":\"{}\",\"version\":\"{}\",\"cname\":\"{}\",\"updated\":{}}}",
json_escape(&e.name),
json_escape(&e.application),
json_escape(&e.tier),
json_escape(&e.status),
json_escape(&e.health),
json_escape(&e.platform),
json_escape(&e.version_label),
json_escape(&cname),
updated,
));
if idx + 1 < count {
out.push(',');
}
out.push('\n');
}
out.push(']');
match yank(&out) {
Ok(()) => {
self.status_message = Some(format!("exported {count} rows (JSON) to clipboard"));
}
Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
}
}
fn export_markdown(&mut self) {
let count = self.cached_filtered.len();
let mut out = String::new();
out.push_str("| NAME | APPLICATION | TIER | STATUS | HEALTH | PLATFORM | VERSION | CNAME | UPDATED |\n");
out.push_str("| ---- | ----------- | ---- | ------ | ------ | -------- | ------- | ----- | ------- |\n");
for &i in &self.cached_filtered {
let e = &self.environments[i];
let cname = if self.redact {
redact_block(&e.cname)
} else {
e.cname.clone()
};
let updated = e.updated.map(|u| u.to_rfc3339()).unwrap_or_default();
out.push_str(&format!(
"| {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
md_escape(&e.name),
md_escape(&e.application),
e.tier,
e.status,
e.health,
md_escape(&e.platform),
md_escape(&e.version_label),
md_escape(&cname),
updated,
));
}
match yank(&out) {
Ok(()) => {
self.status_message =
Some(format!("exported {count} rows (Markdown) to clipboard"));
}
Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
}
}
fn open_describe_overlay(&mut self) {
let env = if let Some(d) = self.detail.as_ref() {
Some(d.env_snapshot.clone())
} else {
self.selected_env().cloned()
};
let Some(env) = env else {
self.status_message = Some("no env selected".into());
return;
};
self.current_overlay = Some(Overlay::Describe(describe_env(&env)));
}
fn open_in_console(&mut self) {
let env_opt = if let Some(d) = self.detail.as_ref() {
Some(d.env_snapshot.clone())
} else {
self.selected_env().cloned()
};
let Some(env) = env_opt else {
self.status_message = Some("no env selected".into());
return;
};
let url = console_url(&self.context.region, &env.application, &env.name);
match open_url(&url) {
Ok(()) => {
self.status_message = Some(format!("opened {} in browser", env.name));
}
Err(e) => {
self.error_message = Some(format!("couldn't open browser: {e}"));
}
}
}
fn open_palette(&mut self) {
self.palette_input.clear();
self.palette_items = build_palette_items(self);
self.palette_refilter();
self.mode = Mode::Palette;
}
fn palette_refilter(&mut self) {
let needle = self.palette_input.to_lowercase();
let mut scored: Vec<(usize, isize)> = self
.palette_items
.iter()
.enumerate()
.filter_map(|(i, it)| {
let s = palette_score(&needle, &it.label, &it.detail)?;
Some((i, s))
})
.collect();
scored.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
self.palette_filtered = scored.into_iter().map(|(i, _)| i).collect();
self.palette_state
.select(if self.palette_filtered.is_empty() {
None
} else {
Some(0)
});
}
fn palette_move(&mut self, delta: i32) {
let n = self.palette_filtered.len();
if n == 0 {
self.palette_state.select(None);
return;
}
let cur = self.palette_state.selected().unwrap_or(0) as i32;
let next = (cur + delta).rem_euclid(n as i32) as usize;
self.palette_state.select(Some(next));
}
fn palette_execute(&mut self) {
let Some(pos) = self.palette_state.selected() else {
return;
};
let Some(&idx) = self.palette_filtered.get(pos) else {
return;
};
let Some(item) = self.palette_items.get(idx).cloned() else {
return;
};
self.mode = Mode::Normal;
self.palette_input.clear();
match item.action {
PaletteAction::RunCommand(cmd) => self.execute_command(&cmd),
PaletteAction::PrefillCommand(prefix) => {
self.command_input = prefix;
self.mode = Mode::Command;
}
PaletteAction::JumpEnv(name) => {
if let Some(pos) = self.cached_display.iter().position(|r| match r {
DisplayRow::Env(i) => self.environments[*i].name == name,
DisplayRow::Separator => false,
}) {
self.table_state.select(Some(pos));
self.status_message = Some(format!("jumped to {name}"));
}
}
PaletteAction::LoadView(name) => {
self.execute_command(&format!("view {name}"));
}
}
}
fn quickjump_apply(&mut self) {
if self.quickjump_input.is_empty() {
return;
}
let needle = self.quickjump_input.to_lowercase();
for (pos, row) in self.cached_display.iter().enumerate() {
if let DisplayRow::Env(i) = row {
let e = &self.environments[*i];
let alias = self
.aliases
.get(&e.name)
.map(|a| a.to_lowercase())
.unwrap_or_default();
if e.name.to_lowercase().starts_with(&needle) || alias.starts_with(&needle) {
self.table_state.select(Some(pos));
return;
}
}
}
}
fn quick_jump(&mut self, n: usize) {
let Some(target_env) = self
.cached_display
.iter()
.filter(|r| matches!(r, DisplayRow::Env(_)))
.nth(n.saturating_sub(1))
else {
return;
};
if let Some(pos) = self
.cached_display
.iter()
.position(|r| std::ptr::eq(r, target_env))
{
self.table_state.select(Some(pos));
}
}
fn open_detail(&mut self) {
let Some(env) = self.selected_env().cloned() else {
self.status_message = Some("no env selected".into());
return;
};
let mut tabs = vec![
DetailTab::Health,
DetailTab::Events,
DetailTab::Instances,
DetailTab::Metrics,
];
if env.tier == "Worker" {
tabs.push(DetailTab::Queue);
}
tabs.push(DetailTab::Logs);
tabs.push(DetailTab::Config);
let detail = DetailState {
env_name: env.name.clone(),
env_snapshot: env,
tabs,
tab_idx: 0,
events: Vec::new(),
instances: Vec::new(),
queues: WorkerQueues::default(),
metrics: Vec::new(),
metrics_range_secs: 3600, auto_refresh: false,
search_input: String::new(),
search_active: false,
search_pattern: None,
search_error: None,
events_scroll: 0,
events_max_scroll: 0,
events_level: EventLevel::default(),
events_window: EventWindow::default(),
instances_scroll: 0,
tags: Vec::new(),
env_vars: Vec::new(),
cw_log_groups: None,
loading_events: false,
loading_instances: false,
loading_queues: false,
loading_metrics: false,
loading_tags: false,
loading_env_vars: false,
error: None,
log_tail: LogTail::default(),
queue_cursor: 0,
instances_cursor: 0,
instance_terminate_confirm: None,
health_cursor: 0,
metrics_hover_col: None,
metrics_body_rect: None,
cw_alarms: None,
loading_cw_alarms: false,
recent_versions: None,
loading_recent_versions: false,
config_cursor: 0,
config_edit: None,
config_scroll: 0,
config_delete_confirm: None,
};
self.detail = Some(detail);
self.mode = Mode::Detail;
self.detail_refresh_active_tab();
self.spawn_detail_tags();
self.spawn_detail_env_vars();
self.spawn_detail_log_groups();
if let Some(d) = self.detail.as_ref() {
let env_name = d.env_name.clone();
self.spawn_detail_instances(env_name);
}
}
fn spawn_detail_log_groups(&mut self) {
let Some(d) = self.detail.as_ref() else {
return;
};
let env_name = d.env_name.clone();
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
tokio::spawn(async move {
let groups = aws
.discover_env_log_groups(&env_name)
.await
.unwrap_or_default();
let _ = tx.send(AppMsg::DetailLogGroups {
gen,
env_name,
groups,
});
});
}
fn spawn_detail_env_vars(&mut self) {
let Some(d) = self.detail.as_ref() else {
return;
};
let app_name = d.env_snapshot.application.clone();
let env_name = d.env_name.clone();
if let Some(d) = self.detail.as_mut() {
d.loading_env_vars = true;
}
let env_for_msg = env_name.clone();
self.spawn_aws(
"fetch_env_vars",
move |aws| async move { aws.fetch_env_vars(&app_name, &env_name).await },
move |gen, result| AppMsg::DetailEnvVars {
gen,
env_name: env_for_msg,
result,
},
);
}
fn spawn_detail_tags(&mut self) {
let Some(d) = self.detail.as_ref() else {
return;
};
let Some(arn) = d.env_snapshot.arn.clone() else {
return;
};
let env_name = d.env_name.clone();
if let Some(d) = self.detail.as_mut() {
d.loading_tags = true;
}
self.spawn_aws(
"list_tags",
move |aws| async move { aws.list_tags(&arn).await },
move |gen, result| AppMsg::DetailTags {
gen,
env_name,
result,
},
);
}
fn drill_health_item(&mut self) {
let Some(detail) = self.detail.as_ref() else {
return;
};
let now = chrono::Utc::now();
let items = crate::app::health_items(detail, now);
let Some(item) = items.get(detail.health_cursor).copied() else {
return;
};
match item {
HealthItem::Event { event_idx } => {
let Some(ev) = detail.events.get(event_idx) else {
return;
};
let when = ev
.at
.map(|t| t.with_timezone(&chrono::Local).to_string())
.unwrap_or_else(|| "?".into());
let body = format!(
"{when}\n[{}] {}\n\n{}\n\nesc / q to close",
ev.severity, ev.env, ev.message
);
self.current_overlay = Some(Overlay::TextDump {
title: "event detail".into(),
body,
});
}
HealthItem::Instance { instance_idx } => {
let Some(d) = self.detail.as_mut() else {
return;
};
if let Some(pos) = d.tabs.iter().position(|t| *t == DetailTab::Instances) {
d.tab_idx = pos;
}
d.instances_cursor = instance_idx.min(d.instances.len().saturating_sub(1));
d.instances_scroll = (d.instances_cursor as u16).saturating_sub(3);
self.detail_refresh_active_tab();
}
HealthItem::MainQueue | HealthItem::Dlq => {
let Some(d) = self.detail.as_mut() else {
return;
};
if let Some(pos) = d.tabs.iter().position(|t| *t == DetailTab::Queue) {
d.tab_idx = pos;
}
d.queue_cursor = match item {
HealthItem::MainQueue => 0,
HealthItem::Dlq => 1,
_ => 0,
};
self.detail_refresh_active_tab();
}
}
}
fn detail_cycle_tab(&mut self, delta: i32) {
let Some(detail) = self.detail.as_mut() else {
return;
};
let n = detail.tabs.len() as i32;
let next = (detail.tab_idx as i32 + delta).rem_euclid(n) as usize;
detail.tab_idx = next;
self.detail_refresh_active_tab();
}
fn detail_scroll(&mut self, delta: i32) {
let Some(detail) = self.detail.as_mut() else {
return;
};
match detail.tab() {
DetailTab::Events => {
detail.events_scroll =
scroll_apply(detail.events_scroll, delta).min(detail.events_max_scroll);
}
DetailTab::Instances => {
let n = detail.instances.len();
if n == 0 {
return;
}
let cur = detail.instances_cursor as i32;
let next = (cur + delta).rem_euclid(n as i32) as usize;
detail.instances_cursor = next;
detail.instances_scroll = (next as u16).saturating_sub(3);
}
DetailTab::Logs => {
detail.log_tail.scroll = scroll_apply(detail.log_tail.scroll, delta);
}
DetailTab::Queue => {
let n: i32 = 2;
let cur = detail.queue_cursor as i32;
detail.queue_cursor = (cur + delta).rem_euclid(n) as usize;
}
DetailTab::Health => {
let now = chrono::Utc::now();
let n = crate::app::health_items(detail, now).len() as i32;
if n == 0 {
return;
}
let cur = detail.health_cursor as i32;
detail.health_cursor = (cur + delta).rem_euclid(n) as usize;
}
DetailTab::Config => {
let n = crate::app::config_editable_items(detail).len();
if n == 0 {
return;
}
let cur = detail.config_cursor as i32;
detail.config_cursor = (cur + delta).clamp(0, n as i32 - 1) as usize;
}
DetailTab::Metrics => {}
}
}
fn detail_refresh_active_tab(&mut self) {
let Some(detail) = self.detail.as_ref() else {
return;
};
let env_name = detail.env_name.clone();
let app_name = detail.env_snapshot.application.clone();
let is_worker = detail.env_snapshot.tier.eq_ignore_ascii_case("Worker");
let tab = detail.tab();
let _ = detail;
match tab {
DetailTab::Health => {
self.spawn_detail_events(env_name.clone());
self.spawn_detail_alarms(env_name.clone());
self.spawn_detail_recent_versions(app_name.clone(), env_name.clone());
if is_worker {
self.spawn_detail_queues(app_name, env_name);
}
}
DetailTab::Events => self.spawn_detail_events(env_name),
DetailTab::Instances => self.spawn_detail_instances(env_name),
DetailTab::Queue => self.spawn_detail_queues(app_name, env_name),
DetailTab::Metrics => self.spawn_detail_metrics(env_name),
DetailTab::Logs => self.spawn_detail_logs(env_name),
DetailTab::Config => {}
}
}
fn handle_detail_search_key(&mut self, key: KeyEvent) {
let Some(detail) = self.detail.as_mut() else {
return;
};
let on_logs = detail.log_tail.search_active;
match key.code {
KeyCode::Esc => {
if on_logs {
detail.log_tail.search_active = false;
detail.log_tail.search_input.clear();
detail.log_tail.search_error = None;
} else {
detail.search_active = false;
detail.search_input.clear();
detail.search_error = None;
}
}
KeyCode::Enter => {
if on_logs {
detail.log_tail.search_active = false;
if detail.log_tail.search_input.is_empty() {
detail.log_tail.search_pattern = None;
detail.log_tail.search_error = None;
return;
}
match regex::RegexBuilder::new(&detail.log_tail.search_input)
.case_insensitive(true)
.build()
{
Ok(r) => {
detail.log_tail.search_pattern = Some(r);
detail.log_tail.search_error = None;
}
Err(e) => {
detail.log_tail.search_pattern = None;
detail.log_tail.search_error = Some(format!("invalid regex: {e}"));
}
}
return;
}
detail.search_active = false;
if detail.search_input.is_empty() {
detail.search_pattern = None;
detail.search_error = None;
return;
}
match regex::RegexBuilder::new(&detail.search_input)
.case_insensitive(true)
.build()
{
Ok(r) => {
detail.search_pattern = Some(r);
detail.search_error = None;
}
Err(e) => {
detail.search_pattern = None;
detail.search_error = Some(format!("invalid regex: {e}"));
}
}
}
KeyCode::Backspace => {
if on_logs {
detail.log_tail.search_input.pop();
} else {
detail.search_input.pop();
}
}
KeyCode::Char(c) if is_text_input(&key) => {
if on_logs {
detail.log_tail.search_input.push(c);
} else {
detail.search_input.push(c);
}
}
_ => {}
}
}
fn start_config_edit(&mut self) {
let env_name = match self.detail.as_ref() {
Some(d) => d.env_name.clone(),
None => return,
};
if self.deny_write(&env_name, "config editing") {
return;
}
let Some(detail) = self.detail.as_mut() else {
return;
};
let items = crate::app::config_editable_items(detail);
let Some(item) = items.get(detail.config_cursor) else {
self.error_message = Some("no editable config rows".into());
return;
};
let key = item.key.clone();
let caret = item.value.chars().count();
detail.config_edit = Some(ConfigEdit {
kind: item.kind,
key: item.key.clone(),
original: item.value.clone(),
input: item.value.clone(),
caret,
mode: ConfigEditMode::Value,
});
self.status_message = Some(format!("editing {key} — enter saves, esc cancels"));
}
fn handle_config_edit_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
if let Some(d) = self.detail.as_mut() {
d.config_edit = None;
}
self.status_message = Some("config edit cancelled".into());
}
KeyCode::Enter => self.commit_config_edit(),
KeyCode::Backspace => {
if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
e.backspace();
}
}
KeyCode::Delete => {
if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
e.delete();
}
}
KeyCode::Left => {
if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
e.move_left();
}
}
KeyCode::Right => {
if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
e.move_right();
}
}
KeyCode::Home => {
if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
e.move_home();
}
}
KeyCode::End => {
if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
e.move_end();
}
}
KeyCode::Char(c) if is_text_input(&key) => {
if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
e.insert(c);
}
}
_ => {}
}
}
fn commit_config_edit(&mut self) {
let Some(edit) = self.detail.as_mut().and_then(|d| d.config_edit.take()) else {
return;
};
let ns = "aws:elasticbeanstalk:application:environment";
match edit.mode {
ConfigEditMode::Value => {
if edit.input == edit.original {
self.status_message = Some(format!("{} unchanged", edit.key));
return;
}
match edit.kind {
ConfigItemKind::EnvVar => self.spawn_option_settings_update(
format!("env set {}", edit.key),
vec![(ns.into(), edit.key.clone(), edit.input.clone())],
vec![],
),
ConfigItemKind::Tag => {
self.spawn_tag_update(vec![(edit.key.clone(), edit.input.clone())], vec![])
}
}
}
ConfigEditMode::NewRow => {
let Some((k, v)) = crate::mode_detail::parse_new_config_row(&edit.input) else {
self.error_message = Some("new row needs KEY=VALUE (non-empty key)".into());
return;
};
match edit.kind {
ConfigItemKind::EnvVar => self.spawn_option_settings_update(
format!("env set {k}"),
vec![(ns.into(), k, v)],
vec![],
),
ConfigItemKind::Tag => self.spawn_tag_update(vec![(k, v)], vec![]),
}
}
ConfigEditMode::RenameKey => {
let new_key = edit.input.trim().to_string();
if new_key.is_empty() {
self.error_message = Some("rename: the new key can't be empty".into());
return;
}
if new_key == edit.original {
self.status_message = Some(format!("{} unchanged", edit.key));
return;
}
let value = self.detail.as_ref().and_then(|d| {
config_editable_items(d)
.into_iter()
.find(|it| it.kind == edit.kind && it.key == edit.key)
.map(|it| it.value)
});
let Some(value) = value else {
self.error_message = Some("rename: the row no longer exists".into());
return;
};
let old = edit.key.clone();
match edit.kind {
ConfigItemKind::EnvVar => self.spawn_option_settings_update(
format!("env rename {old} -> {new_key}"),
vec![(ns.into(), new_key, value)],
vec![(ns.into(), old)],
),
ConfigItemKind::Tag => self.spawn_tag_update(vec![(new_key, value)], vec![old]),
}
}
}
}
fn start_config_add(&mut self) {
let env_name = match self.detail.as_ref() {
Some(d) => d.env_name.clone(),
None => return,
};
if self.deny_write(&env_name, "config editing") {
return;
}
let Some(detail) = self.detail.as_mut() else {
return;
};
let items = crate::app::config_editable_items(detail);
let kind = items
.get(detail.config_cursor)
.map(|i| i.kind)
.unwrap_or(ConfigItemKind::EnvVar);
detail.config_edit = Some(ConfigEdit {
kind,
key: String::new(),
original: String::new(),
input: String::new(),
caret: 0,
mode: ConfigEditMode::NewRow,
});
let what = match kind {
ConfigItemKind::EnvVar => "env var",
ConfigItemKind::Tag => "tag",
};
self.status_message = Some(format!(
"new {what} — type KEY=VALUE, enter saves, esc cancels"
));
}
fn start_config_rename(&mut self) {
let env_name = match self.detail.as_ref() {
Some(d) => d.env_name.clone(),
None => return,
};
if self.deny_write(&env_name, "config editing") {
return;
}
let Some(detail) = self.detail.as_mut() else {
return;
};
let items = crate::app::config_editable_items(detail);
let Some(item) = items.get(detail.config_cursor) else {
self.error_message = Some("no editable config rows".into());
return;
};
let key = item.key.clone();
let caret = key.chars().count();
detail.config_edit = Some(ConfigEdit {
kind: item.kind,
key: item.key.clone(),
original: item.key.clone(),
input: item.key.clone(),
caret,
mode: ConfigEditMode::RenameKey,
});
self.status_message = Some(format!(
"renaming {key} — type the new key, enter saves, esc cancels"
));
}
fn arm_config_delete(&mut self) {
let env_name = match self.detail.as_ref() {
Some(d) => d.env_name.clone(),
None => return,
};
if self.deny_write(&env_name, "config editing") {
return;
}
let Some(detail) = self.detail.as_mut() else {
return;
};
let items = crate::app::config_editable_items(detail);
let Some(item) = items.get(detail.config_cursor) else {
self.error_message = Some("no editable config rows".into());
return;
};
let key = item.key.clone();
detail.config_delete_confirm = Some(detail.config_cursor);
self.status_message = Some(format!("delete {key}? — y confirms, any other key cancels"));
}
fn commit_config_delete(&mut self) {
let Some(idx) = self
.detail
.as_mut()
.and_then(|d| d.config_delete_confirm.take())
else {
return;
};
let Some(detail) = self.detail.as_ref() else {
return;
};
let items = crate::app::config_editable_items(detail);
let Some(item) = items.get(idx) else {
self.error_message = Some("config row no longer exists".into());
return;
};
let kind = item.kind;
let key = item.key.clone();
match kind {
ConfigItemKind::EnvVar => {
let ns = "aws:elasticbeanstalk:application:environment";
self.spawn_option_settings_update(
format!("env unset {key}"),
vec![],
vec![(ns.into(), key)],
);
}
ConfigItemKind::Tag => {
self.spawn_tag_update(vec![], vec![key]);
}
}
}
fn detail_search_jump(&mut self, delta: i32) {
let Some(detail) = self.detail.as_mut() else {
return;
};
let Some(re) = detail.search_pattern.as_ref() else {
return;
};
let visible = crate::mode_detail::filter_event_indices(
&detail.events,
detail.events_level,
detail.events_window,
chrono::Utc::now(),
);
let n = visible.len();
if n == 0 {
return;
}
let cur = (detail.events_scroll as usize).min(n - 1);
let order: Vec<usize> = if delta >= 0 {
(1..=n).map(|off| (cur + off) % n).collect()
} else {
(1..=n).map(|off| (cur + n - off) % n).collect()
};
for pos in order {
if re.is_match(&detail.events[visible[pos]].message) {
detail.events_scroll = pos as u16;
return;
}
}
}
fn cycle_saved_view(&mut self, delta: i32) {
if self.saved_views.is_empty() {
return;
}
let names: Vec<String> = self.saved_views.keys().cloned().collect();
let cur_idx = if self.filter.is_empty() {
None
} else {
names.iter().position(|n| {
self.saved_views
.get(n)
.map(|encoded| view_filter_value(encoded) == self.filter)
.unwrap_or(false)
})
};
let next = match cur_idx {
Some(i) => (i as i32 + delta).rem_euclid(names.len() as i32) as usize,
None if delta >= 0 => 0,
None => names.len() - 1,
};
let chosen = names[next].clone();
if let Some(snap) = self.saved_views.get(&chosen).cloned() {
apply_view(self, &snap);
self.status_message = Some(format!("view: {chosen}"));
}
}
pub(crate) fn dispatch_auto_rollback(&mut self, env_name: String, health: String) {
self.watching_deploys.remove(&env_name);
let Some(snapshot) = self.deploy_snapshots.get(&env_name).cloned() else {
self.pin_error(format!(
"auto-rollback for {env_name}: no pre-deploy snapshot; manual rollback required"
));
self.armed_watchdogs.remove(&env_name);
return;
};
if self.deny_write(&env_name, "auto-rollback") {
self.armed_watchdogs.remove(&env_name);
return;
}
self.armed_watchdogs.remove(&env_name);
let label = snapshot.previous_version_label.clone();
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let account = self.context.account_id.clone();
let profile = self.context.profile.clone();
let region = self.context.region.clone();
write_audit_line(
account.as_deref(),
profile.as_deref(),
®ion,
&format!(
"stage=dispatched action=AutoRollback target={env_name} version={label} health={health}"
),
);
self.push_pending("Auto-rollback", env_name.clone());
self.pin_status(format!(
"auto-rollback for {env_name}: redeploying {label} (env was {health})"
));
let env_for_msg = env_name.clone();
tokio::spawn(async move {
let result = aws
.deploy_version(&env_name, &label)
.await
.map_err(|e| flatten_err("deploy_version", e));
let _ = tx.send(AppMsg::ActionResult {
gen,
action: Action::Deploy,
env_name: env_for_msg,
result,
});
});
}
fn cycle_metrics_range(&mut self, delta: i32) {
const RANGES: &[i64] = &[900, 3600, 21_600, 86_400]; let Some(d) = self.detail.as_mut() else {
return;
};
let cur = RANGES
.iter()
.position(|r| *r == d.metrics_range_secs)
.unwrap_or(1) as i32;
let next = (cur + delta).rem_euclid(RANGES.len() as i32) as usize;
d.metrics_range_secs = RANGES[next];
let env_name = d.env_name.clone();
self.spawn_detail_metrics(env_name);
}
fn spawn_detail_logs(&mut self, env_name: String) {
if let Some(d) = self.detail.as_mut() {
d.log_tail.stage = LogTailStage::Requesting;
d.log_tail.poll_attempt = 0;
d.log_tail.error = None;
}
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let env_for_msg = env_name.clone();
tokio::spawn(async move {
let result = collect_tail_logs(aws, env_name.clone(), tx.clone(), gen).await;
let _ = tx.send(AppMsg::DetailLogs {
gen,
env_name: env_for_msg,
result,
});
});
}
fn spawn_detail_metrics(&mut self, env_name: String) {
let range = self
.detail
.as_ref()
.map(|d| d.metrics_range_secs)
.unwrap_or(3600);
if let Some(d) = self.detail.as_mut() {
d.loading_metrics = true;
d.error = None;
}
let custom: Vec<crate::aws::CustomMetricQuery> = self
.custom_metrics
.iter()
.map(|(label, spec)| {
(
label.clone(),
spec.namespace.clone(),
spec.name.clone(),
spec.stat.clone(),
spec.dimensions.clone(),
)
})
.collect();
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let name = env_name.clone();
tokio::spawn(async move {
let (builtin, user) = tokio::join!(
aws.fetch_env_metrics(&name, range),
aws.fetch_custom_env_metrics(&name, range, &custom),
);
let result = match builtin {
Ok(mut series) => {
if let Ok(extra) = user {
series.extend(extra);
}
Ok(series)
}
Err(e) => Err(flatten_err("fetch_env_metrics", e)),
};
let _ = tx.send(AppMsg::DetailMetrics {
gen,
env_name,
result,
});
});
}
fn open_queue_viewer(&mut self, viewing: QueueView) {
let Some(detail) = self.detail.as_ref() else {
return;
};
if detail.tab() != DetailTab::Queue {
return;
}
let main_url = detail.queues.main_url.clone().unwrap_or_default();
let dlq_url = detail.queues.dlq_url.clone().unwrap_or_default();
let target_url = match viewing {
QueueView::Main => main_url.clone(),
QueueView::Dlq => dlq_url.clone(),
};
if target_url.is_empty() {
self.status_message = Some(match viewing {
QueueView::Main => "no main queue URL known".into(),
QueueView::Dlq => "no DLQ for this env".into(),
});
return;
}
let dlq = DlqState {
env_name: detail.env_name.clone(),
main_queue_url: main_url,
dlq_url,
messages: Vec::new(),
list_state: ListState::default(),
loading: false,
error: None,
confirm_purge: false,
purge_typed: String::new(),
viewing,
confirm_delete_idx: None,
replay_input: None,
};
self.dlq = Some(dlq);
self.mode = Mode::Dlq;
self.spawn_dlq_fetch();
}
fn open_dlq(&mut self) {
let Some(detail) = self.detail.as_ref() else {
return;
};
if detail.tab() != DetailTab::Queue {
return;
}
let Some(dlq_url) = detail.queues.dlq_url.clone() else {
self.status_message = Some("no DLQ for this env".into());
return;
};
let main_url = detail.queues.main_url.clone().unwrap_or_default();
let dlq = DlqState {
env_name: detail.env_name.clone(),
main_queue_url: main_url,
dlq_url,
messages: Vec::new(),
list_state: ListState::default(),
loading: false,
error: None,
confirm_purge: false,
purge_typed: String::new(),
viewing: QueueView::Dlq,
confirm_delete_idx: None,
replay_input: None,
};
self.dlq = Some(dlq);
self.mode = Mode::Dlq;
self.spawn_dlq_fetch();
}
fn open_dlq_from_why(&mut self, env_name: String, main_queue_url: String, dlq_url: String) {
let dlq = DlqState {
env_name,
main_queue_url,
dlq_url,
messages: Vec::new(),
list_state: ListState::default(),
loading: false,
error: None,
confirm_purge: false,
purge_typed: String::new(),
viewing: QueueView::Dlq,
confirm_delete_idx: None,
replay_input: None,
};
self.dlq = Some(dlq);
self.mode = Mode::Dlq;
self.spawn_dlq_fetch();
}
fn close_dlq(&mut self) {
self.dlq = None;
self.mode = if self.detail.is_some() {
Mode::Detail
} else {
Mode::Normal
};
}
fn spawn_dlq_fetch(&mut self) {
let Some(dlq) = self.dlq.as_mut() else { return };
dlq.loading = true;
dlq.error = None;
let env_name = dlq.env_name.clone();
let queue_url = match dlq.viewing {
QueueView::Dlq => dlq.dlq_url.clone(),
QueueView::Main => dlq.main_queue_url.clone(),
};
self.spawn_aws(
"peek_messages",
move |aws| async move { aws.peek_messages(&queue_url, 50).await },
move |gen, result| AppMsg::DlqMessages {
gen,
env_name,
result,
},
);
}
fn spawn_dlq_delete_one(&mut self, idx: usize) {
let Some(dlq) = self.dlq.as_mut() else { return };
let Some(msg) = dlq.messages.get(idx).cloned() else {
return;
};
let queue_url = match dlq.viewing {
QueueView::Dlq => dlq.dlq_url.clone(),
QueueView::Main => dlq.main_queue_url.clone(),
};
if queue_url.is_empty() {
self.error_message = Some("queue URL missing — cannot delete".into());
return;
}
let env_name = dlq.env_name.clone();
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!(
"sqs-delete env={env_name} queue={} msg_id={}",
if matches!(dlq.viewing, QueueView::Main) {
"MAIN"
} else {
"DLQ"
},
msg.id
),
);
tokio::spawn(async move {
let result = aws
.delete_message(&queue_url, &msg.receipt_handle)
.await
.map(|_| DlqOp::Resent {
message_id: msg.id.clone(),
})
.map_err(|e| flatten_err("delete_message", e));
let _ = tx.send(AppMsg::DlqActionResult {
gen,
env_name,
result,
});
});
}
fn handle_dlq_key(&mut self, key: KeyEvent) {
let Some(dlq) = self.dlq.as_mut() else { return };
if let Some(idx) = dlq.confirm_delete_idx {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
dlq.confirm_delete_idx = None;
self.spawn_dlq_delete_one(idx);
}
_ => {
dlq.confirm_delete_idx = None;
}
}
return;
}
if dlq.confirm_purge {
match key.code {
KeyCode::Esc => {
dlq.confirm_purge = false;
dlq.purge_typed.clear();
}
KeyCode::Enter if dlq.purge_typed == dlq.env_name => {
let dlq_url = dlq.dlq_url.clone();
let env_name = dlq.env_name.clone();
dlq.confirm_purge = false;
dlq.purge_typed.clear();
self.spawn_dlq_purge(env_name, dlq_url);
}
KeyCode::Backspace => {
dlq.purge_typed.pop();
}
KeyCode::Char(c) if is_text_input(&key) => dlq.purge_typed.push(c),
_ => {}
}
return;
}
if let Some(input) = dlq.replay_input.as_mut() {
match key.code {
KeyCode::Esc => dlq.replay_input = None,
KeyCode::Enter => match crate::mode_dlq::parse_replay_spec(input) {
None => {
dlq.error = Some(
"replay: type `all`, a count (e.g. 20), or a window (1h / 24h / 7d)"
.into(),
);
}
Some(spec) => {
let idxs = crate::mode_dlq::select_replay_indices(
&dlq.messages,
&spec,
chrono::Utc::now(),
);
let msgs: Vec<_> = idxs
.iter()
.filter_map(|&i| dlq.messages.get(i).cloned())
.collect();
dlq.replay_input = None;
if msgs.is_empty() {
self.error_message = Some("replay: no messages match".into());
} else {
self.spawn_dlq_replay_batch(msgs);
}
}
},
KeyCode::Backspace => {
input.pop();
}
KeyCode::Char(c) if is_text_input(&key) => input.push(c),
_ => {}
}
return;
}
match key.code {
KeyCode::Esc | KeyCode::Char('q') => self.close_dlq(),
KeyCode::Enter => {
let Some(idx) = dlq.list_state.selected() else {
return;
};
let Some(msg) = dlq.messages.get(idx).cloned() else {
return;
};
let when = msg
.sent_at
.map(|t| {
t.with_timezone(&chrono::Local)
.format("%Y-%m-%d %H:%M:%S %Z")
.to_string()
})
.unwrap_or_else(|| "—".into());
let view_label = match dlq.viewing {
QueueView::Main => "Main queue",
QueueView::Dlq => "DLQ",
};
let body = format!(
"{view_label} message\n\
─────────────────────────────\n\
id: {}\n\
receive-count:{}\n\
sent: {when}\n\
bytes: {}\n\n\
─ body ─\n{}\n\nesc / q to close",
msg.id,
msg.receive_count,
msg.body.len(),
msg.body
);
self.current_overlay = Some(Overlay::Describe(body));
}
KeyCode::Char('j') | KeyCode::Down => {
let n = dlq.messages.len();
if n == 0 {
return;
}
let cur = dlq.list_state.selected().unwrap_or(0);
dlq.list_state.select(Some((cur + 1) % n));
}
KeyCode::Char('k') | KeyCode::Up => {
let n = dlq.messages.len();
if n == 0 {
return;
}
let cur = dlq.list_state.selected().unwrap_or(0);
dlq.list_state.select(Some((cur + n - 1) % n));
}
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.spawn_dlq_fetch();
}
KeyCode::Char('r') => {
if matches!(dlq.viewing, QueueView::Main) {
self.error_message = Some("resend is only available in DLQ view".into());
} else {
self.spawn_dlq_resend_selected();
}
}
KeyCode::Char('R') => {
if matches!(dlq.viewing, QueueView::Main) {
self.error_message = Some("replay is only available in DLQ view".into());
} else if dlq.messages.is_empty() {
self.error_message = Some("replay: DLQ is empty".into());
} else {
dlq.replay_input = Some(String::new());
dlq.error = None;
}
}
KeyCode::Char('m') => {
if dlq.main_queue_url.is_empty() {
self.error_message = Some("no main queue URL known".into());
} else {
dlq.viewing = match dlq.viewing {
QueueView::Dlq => QueueView::Main,
QueueView::Main => QueueView::Dlq,
};
dlq.messages.clear();
dlq.list_state.select(None);
self.spawn_dlq_fetch();
}
}
KeyCode::Char('x') => {
if let Some(idx) = dlq.list_state.selected() {
if dlq.messages.get(idx).is_some() {
dlq.confirm_delete_idx = Some(idx);
}
}
}
KeyCode::Char('p') => {
if let Some(dlq) = self.dlq.as_mut() {
dlq.confirm_purge = true;
dlq.purge_typed.clear();
}
}
_ => {}
}
}
fn spawn_dlq_resend_selected(&mut self) {
let env_name = match self.dlq.as_ref() {
Some(d) => d.env_name.clone(),
None => return,
};
if self.deny_write(&env_name, "resend") {
return;
}
let Some(dlq) = self.dlq.as_mut() else { return };
let Some(idx) = dlq.list_state.selected() else {
return;
};
let Some(msg) = dlq.messages.get(idx).cloned() else {
return;
};
if dlq.main_queue_url.is_empty() {
dlq.error = Some("main queue URL unknown — cannot resend".into());
return;
}
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let env_name = dlq.env_name.clone();
let main_url = dlq.main_queue_url.clone();
let dlq_url = dlq.dlq_url.clone();
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!("dlq-resend env={env_name} msg_id={}", msg.id),
);
tokio::spawn(async move {
let result = match aws.send_message(&main_url, &msg.body).await {
Ok(()) => match aws.delete_message(&dlq_url, &msg.receipt_handle).await {
Ok(()) => Ok(DlqOp::Resent {
message_id: msg.id.clone(),
}),
Err(e) => {
tracing::error!(target: "ebman::aws", op = "dlq_delete_after_send", error = ?e, "aws call failed");
Err(format!("sent to main queue, but DLQ delete failed: {e}"))
}
},
Err(e) => {
tracing::error!(target: "ebman::aws", op = "dlq_send", error = ?e, "aws call failed");
Err(format!("send to main queue failed: {e}"))
}
};
let _ = tx.send(AppMsg::DlqActionResult {
gen,
env_name,
result,
});
});
}
fn spawn_dlq_purge(&mut self, env_name: String, dlq_url: String) {
if self.deny_write(&env_name, "purge") {
return;
}
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!("dlq-purge env={env_name}"),
);
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
tokio::spawn(async move {
let result = aws
.purge_queue(&dlq_url)
.await
.map(|_| DlqOp::Purged)
.map_err(|e| flatten_err("purge_queue", e));
let _ = tx.send(AppMsg::DlqActionResult {
gen,
env_name,
result,
});
});
}
fn spawn_dlq_replay_batch(&mut self, messages: Vec<crate::aws::QueueMessage>) {
let env_name = match self.dlq.as_ref() {
Some(d) => d.env_name.clone(),
None => return,
};
if self.deny_write(&env_name, "replay") {
return;
}
let Some(dlq) = self.dlq.as_ref() else { return };
if matches!(dlq.viewing, QueueView::Main) {
self.error_message = Some("replay is only available in DLQ view".into());
return;
}
if dlq.main_queue_url.is_empty() {
self.error_message = Some("main queue URL unknown — cannot replay".into());
return;
}
let main_url = dlq.main_queue_url.clone();
let dlq_url = dlq.dlq_url.clone();
let env_name = dlq.env_name.clone();
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let count = messages.len();
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!("dlq-replay env={env_name} count={count}"),
);
self.status_message = Some(format!("replaying {count} message(s) to the main queue…"));
tokio::spawn(async move {
let mut failures = 0usize;
for msg in &messages {
match aws.send_message(&main_url, &msg.body).await {
Ok(()) => {
if let Err(e) = aws.delete_message(&dlq_url, &msg.receipt_handle).await {
tracing::error!(target: "ebman::aws", op = "dlq_replay_delete", error = ?e, msg_id = %msg.id, "DLQ delete after send failed");
failures += 1;
}
}
Err(e) => {
tracing::error!(target: "ebman::aws", op = "dlq_replay_send", error = ?e, msg_id = %msg.id, "send to main queue failed");
failures += 1;
}
}
}
let result = Ok(DlqOp::Replayed {
count: count - failures,
failures,
});
let _ = tx.send(AppMsg::DlqActionResult {
gen,
env_name,
result,
});
});
}
fn spawn_detail_queues(&mut self, application_name: String, env_name: String) {
if let Some(d) = self.detail.as_mut() {
d.loading_queues = true;
d.error = None;
}
if self.demo_mode {
let result = Ok(crate::demo_fixture::worker_queues_for_env(&env_name));
let gen = self.generation;
let _ = self.msg_tx.send(AppMsg::DetailQueues {
gen,
env_name,
result,
});
return;
}
let env_for_msg = env_name.clone();
self.spawn_aws(
"describe_worker_queues",
move |aws| async move {
aws.describe_worker_queues(&application_name, &env_name)
.await
},
move |gen, result| AppMsg::DetailQueues {
gen,
env_name: env_for_msg,
result,
},
);
}
fn spawn_detail_events(&mut self, env_name: String) {
if let Some(d) = self.detail.as_mut() {
d.loading_events = true;
d.error = None;
}
if self.demo_mode {
let result = Ok(crate::demo_fixture::events_for_env(&env_name));
let gen = self.generation;
let _ = self.msg_tx.send(AppMsg::DetailEvents {
gen,
env_name,
result,
});
return;
}
let env_for_msg = env_name.clone();
self.spawn_aws(
"list_events_for_env",
move |aws| async move { aws.list_events_for_env(&env_name, 50).await },
move |gen, result| AppMsg::DetailEvents {
gen,
env_name: env_for_msg,
result,
},
);
}
fn target_env_for_action(&self) -> Option<Environment> {
if let Some(d) = self.detail.as_ref() {
return Some(d.env_snapshot.clone());
}
self.selected_env().cloned()
}
fn open_action_menu(&mut self) {
let Some(target) = self.target_env_for_action() else {
self.status_message = Some("no env selected".into());
return;
};
if self.deny_write(&target.name, "action menu") {
return;
}
let mut list_state = ListState::default();
list_state.select(Some(0));
self.action_flow = Some(ActionFlow::Menu { list_state });
self.mode = Mode::Action;
}
fn close_action_flow(&mut self) {
self.action_flow = None;
if self.detail.is_some() {
self.mode = Mode::Detail;
} else {
self.mode = Mode::Normal;
}
}
fn open_form(&mut self, mut form: crate::form::Form) {
if matches!(form.submit, crate::form::FormSubmit::LocalConfig) {
form.state = crate::form::FormState::Ready;
self.form = Some(form);
self.mode = Mode::Form;
return;
}
let env_name = form.env_name.clone();
let app_name = match self.environments.iter().find(|e| e.name == env_name) {
Some(e) => e.application.clone(),
None => {
self.error_message = Some(format!("env '{env_name}' not in current list"));
return;
}
};
self.form = Some(form);
self.mode = Mode::Form;
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let env_for_msg = env_name.clone();
tokio::spawn(async move {
let settings = aws
.fetch_env_option_settings(&app_name, &env_for_msg)
.await
.map_err(|e| flatten_err("fetch_env_option_settings", e));
let _ = tx.send(AppMsg::FormPrefilled {
gen,
env_name: env_for_msg,
settings,
});
});
}
fn handle_form_key(&mut self, key: KeyEvent) {
use crate::form::{FieldKind, FormState};
let state = self.form.as_ref().map(|f| f.state.clone());
let cursor_kind = self
.form
.as_ref()
.and_then(|f| f.current_field().map(|fld| fld.kind.clone()));
match state {
None => return,
Some(FormState::Loading) | Some(FormState::Submitting) => {
if matches!(key.code, KeyCode::Esc) {
self.form = None;
self.mode = Mode::Normal;
}
return;
}
Some(FormState::Ready) => {}
}
if matches!(key.code, KeyCode::Char('s')) && key.modifiers.contains(KeyModifiers::CONTROL) {
self.submit_form();
return;
}
if matches!(key.code, KeyCode::Esc) {
self.form = None;
self.mode = Mode::Normal;
return;
}
let is_multi = matches!(cursor_kind.as_ref(), Some(FieldKind::MultiSelect { .. }));
let between_fields = match key.code {
KeyCode::Tab => Some(1),
KeyCode::BackTab => Some(-1),
KeyCode::Up | KeyCode::Down if !is_multi => {
if matches!(key.code, KeyCode::Up) {
Some(-1)
} else {
Some(1)
}
}
_ => None,
};
if let Some(delta) = between_fields {
if let Some(form) = self.form.as_mut() {
form.move_cursor(delta);
}
return;
}
if is_multi
&& matches!(
key.code,
KeyCode::Up | KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('k')
)
{
if let Some(form) = self.form.as_mut() {
if let Some(field) = form.current_field_mut() {
if let FieldKind::MultiSelect { options } = &field.kind {
let n = options.len();
if n > 0 {
let delta: isize =
matches!(key.code, KeyCode::Down | KeyCode::Char('j')) as isize * 2
- 1;
let cur = field.option_cursor as isize;
let next = ((cur + delta) % n as isize + n as isize) % n as isize;
field.option_cursor = next as usize;
}
}
}
}
return;
}
let Some(form) = self.form.as_mut() else {
return;
};
let Some(field) = form.current_field_mut() else {
return;
};
match (cursor_kind.unwrap_or(FieldKind::Text), key.code) {
(FieldKind::Text, KeyCode::Backspace) => {
field.value.pop();
}
(FieldKind::Text, KeyCode::Char(c)) if is_text_input(&key) => {
field.value.push(c);
}
(FieldKind::Integer { .. }, KeyCode::Backspace) => {
field.value.pop();
}
(FieldKind::Integer { .. }, KeyCode::Char(c))
if c.is_ascii_digit() || (c == '-' && field.value.is_empty()) =>
{
field.value.push(c);
}
(FieldKind::Boolean, KeyCode::Char(' ')) => {
field.value = if field.value == "true" {
"false".into()
} else {
"true".into()
};
}
(FieldKind::Boolean, KeyCode::Char('t')) => {
field.value = "true".into();
}
(FieldKind::Boolean, KeyCode::Char('f')) => {
field.value = "false".into();
}
(FieldKind::Select { options }, KeyCode::Left)
| (FieldKind::Select { options }, KeyCode::Char('h')) => {
let i = options.iter().position(|o| o == &field.value).unwrap_or(0);
let next = (i + options.len() - 1) % options.len();
field.value = options[next].clone();
}
(FieldKind::Select { options }, KeyCode::Right)
| (FieldKind::Select { options }, KeyCode::Char('l')) => {
let i = options.iter().position(|o| o == &field.value).unwrap_or(0);
let next = (i + 1) % options.len();
field.value = options[next].clone();
}
(FieldKind::MultiSelect { options }, KeyCode::Char(' ')) => {
if let Some(opt) = options.get(field.option_cursor) {
field.value = crate::form::toggle_multi(&field.value, opt);
}
}
_ => {}
}
let _ = crate::form::validate_field(&field.value, &field.kind).map(|_| field.error = None);
}
fn submit_form(&mut self) {
let Some(form) = self.form.as_mut() else {
return;
};
if let Err(failing) = form.validate() {
form.cursor = failing[0];
return;
}
if matches!(form.submit, crate::form::FormSubmit::LocalConfig) {
self.submit_local_config();
return;
}
let env_name = form.env_name.clone();
let summary = form.summary.clone();
let (to_set, to_remove) = form.to_option_settings();
form.state = crate::form::FormState::Submitting;
if self.deny_write(&env_name, "form submit") {
self.form = None;
self.mode = Mode::Normal;
return;
}
if to_set.is_empty() && to_remove.is_empty() {
self.status_message = Some("no changes to apply".into());
self.form = None;
self.mode = Mode::Normal;
return;
}
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!(
"stage=dispatched action=UpdateOptionSettings target={env_name} summary=\"{summary}\""
),
);
self.push_pending(summary.clone(), env_name.clone());
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let env_for_msg = env_name.clone();
let summary_for_msg = summary.clone();
let account = self.context.account_id.clone();
let profile = self.context.profile.clone();
let region = self.context.region.clone();
let app_for_undo = self
.environments
.iter()
.find(|e| e.name == env_name)
.map(|e| e.application.clone());
let env_for_undo = env_name.clone();
let summary_for_undo = summary.clone();
let to_set_for_undo = to_set.clone();
let to_remove_for_undo = to_remove.clone();
tokio::spawn(async move {
let undo_entry = if let Some(app_name) = app_for_undo {
match aws
.fetch_env_option_settings(&app_name, &env_for_undo)
.await
{
Ok(opts) => Some(build_undo_entry(
&env_for_undo,
&summary_for_undo,
&to_set_for_undo,
&to_remove_for_undo,
&opts,
)),
Err(_) => None,
}
} else {
None
};
let result = aws
.update_env_option_settings(&env_for_msg, &to_set, &to_remove)
.await
.map_err(|e| flatten_err("update_env_option_settings", e));
let outcome = match &result {
Ok(()) => format!(
"stage=completed action=UpdateOptionSettings target={env_for_msg} summary=\"{summary_for_msg}\" ok"
),
Err(e) => format!(
"stage=completed action=UpdateOptionSettings target={env_for_msg} summary=\"{summary_for_msg}\" err=\"{}\"",
e.replace('"', "'")
),
};
write_audit_line(account.as_deref(), profile.as_deref(), ®ion, &outcome);
if result.is_ok() {
if let Some(entry) = undo_entry {
let _ = tx.send(AppMsg::UndoCaptured { gen, entry });
}
}
let _ = tx.send(AppMsg::OptionSettingsUpdate {
gen,
env_name: env_for_msg,
summary: summary_for_msg,
result,
});
});
self.form = None;
self.mode = Mode::Normal;
}
fn submit_local_config(&mut self) {
let Some(form) = self.form.as_ref() else {
return;
};
let snapshot = self.current_config_snapshot();
let updated = form.apply_to_config(&snapshot);
match crate::config::save(&updated) {
Ok(()) => {
let path = crate::config::config_path();
self.apply_config_live(&updated);
self.pin_status(format!("settings saved → {}", path.display()));
}
Err(e) => {
self.error_message = Some(format!("settings save failed: {e}"));
}
}
self.form = None;
self.mode = Mode::Normal;
}
fn open_subnets_form(&mut self) {
self.open_multi_select_form(MultiSelectFlavour::Subnets);
}
fn open_elb_subnets_form(&mut self) {
self.open_multi_select_form(MultiSelectFlavour::ElbSubnets);
}
fn open_security_groups_form(&mut self) {
self.open_multi_select_form(MultiSelectFlavour::SecurityGroups);
}
fn open_multi_select_form(&mut self, flavour: MultiSelectFlavour) {
use crate::form::{Form, FormField, FormSubmit};
let Some(env) = self.selected_env().cloned() else {
self.error_message = Some("no env selected".into());
return;
};
let (title_prefix, summary, field_key, label, ns, opt_name) = match flavour {
MultiSelectFlavour::Subnets => (
"subnets",
"subnets update",
"subnets",
"Subnets",
"aws:ec2:vpc",
"Subnets",
),
MultiSelectFlavour::ElbSubnets => (
"elb-subnets",
"elb-subnets update",
"elb_subnets",
"ELB subnets",
"aws:ec2:vpc",
"ELBSubnets",
),
MultiSelectFlavour::SecurityGroups => (
"security-groups",
"security-groups update",
"security_groups",
"Security groups",
"aws:autoscaling:launchconfiguration",
"SecurityGroups",
),
};
let placeholder = FormField::multi_select(
field_key,
label,
Vec::new(),
Vec::new(),
Some::<String>("space toggle · ↑↓ option cursor · tab field".into()),
);
let form = Form::loading(
format!("{title_prefix} — {}", env.name),
env.name.clone(),
summary.to_string(),
vec![placeholder],
FormSubmit::OptionSettings {
mappings: vec![(field_key.into(), ns.into(), opt_name.into())],
},
);
self.form = Some(form);
self.mode = Mode::Form;
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let env_for_msg = env.name.clone();
let app_name = env.application.clone();
let field_key_for_msg = field_key.to_string();
tokio::spawn(async move {
let result = load_multi_select(aws, &app_name, &env_for_msg, flavour).await;
let _ = tx.send(AppMsg::FormMultiSelectLoaded {
gen,
env_name: env_for_msg,
field_key: field_key_for_msg,
result,
});
});
}
fn open_settings_form(&mut self) {
use crate::form::{Form, FormField, FormSubmit};
let snapshot = self.current_config_snapshot();
let bool_select = vec!["true".to_string(), "false".to_string()];
let triple_select = vec!["auto".to_string(), "true".to_string(), "false".to_string()];
let mut fields: Vec<FormField> = Vec::new();
let theme_options = vec![
"dark".to_string(),
"light".to_string(),
"high-contrast".to_string(),
];
let mut theme_field = FormField::select(
"theme",
"Theme",
theme_options.clone(),
Some::<String>("dark / light / high-contrast".into()),
);
theme_field.value = if theme_options.iter().any(|o| o == &snapshot.theme) {
snapshot.theme.clone()
} else {
theme_options[0].clone()
};
fields.push(theme_field);
let icons_options = vec![
"unicode".to_string(),
"ascii".to_string(),
"powerline".to_string(),
"auto".to_string(),
];
let mut icons_field = FormField::select(
"icons",
"Icons",
icons_options.clone(),
Some::<String>("auto = probe the terminal at startup".into()),
);
icons_field.value = if icons_options
.iter()
.any(|o| o.eq_ignore_ascii_case(&snapshot.icons))
{
snapshot.icons.to_ascii_lowercase()
} else {
"unicode".to_string()
};
fields.push(icons_field);
let mut refresh_field = FormField::integer(
"refresh_interval_secs",
"Refresh interval (s)",
Some("How often the env list reloads from AWS"),
Some(5),
Some(600),
false,
);
refresh_field.value = snapshot.refresh_interval.as_secs().to_string();
fields.push(refresh_field);
let mut redact_field = FormField::select(
"redact_default",
"Redact by default",
triple_select.clone(),
Some::<String>("auto leaves the toggle to per-session state".into()),
);
redact_field.value = match snapshot.redact_default {
None => "auto".into(),
Some(true) => "true".into(),
Some(false) => "false".into(),
};
fields.push(redact_field);
let mut grouped_field = FormField::select(
"grouped_default",
"Group by app by default",
triple_select,
Some::<String>("auto leaves the toggle to per-session state".into()),
);
grouped_field.value = match snapshot.grouped_default {
None => "auto".into(),
Some(true) => "true".into(),
Some(false) => "false".into(),
};
fields.push(grouped_field);
let mut notify_field = FormField::select(
"notify_bell",
"Bell on new Red",
bool_select,
Some::<String>("ring BEL when an env transitions into Red".into()),
);
notify_field.value = if snapshot.notify_bell {
"true".into()
} else {
"false".into()
};
fields.push(notify_field);
let mut tags_field = FormField::text(
"required_tags",
"Required tags",
Some::<String>("comma-separated; surfaced in :report".into()),
);
tags_field.value = snapshot.required_tags.join(",");
fields.push(tags_field);
let mut regions_field = FormField::text(
"extra_regions",
"Extra regions",
Some::<String>("comma-separated; appended to :region picker".into()),
);
regions_field.value = snapshot.extra_regions.join(",");
fields.push(regions_field);
let form = Form::loading(
"settings",
String::new(),
"settings".to_string(),
fields,
FormSubmit::LocalConfig,
);
self.open_form(form);
}
fn current_config_snapshot(&self) -> Config {
Config {
refresh_interval: self.refresh_interval,
extra_regions: self.extra_regions.clone(),
redact_default: Some(self.redact),
grouped_default: Some(self.grouped),
theme: self.base_theme_name.clone(),
icons: self.cfg_icons_raw.clone(),
notify_bell: self.notify_bell,
required_tags: self.required_tags.clone(),
profile_themes: self.profile_themes.clone(),
accounts: self.accounts.clone(),
runbooks: self.runbooks.clone(),
safety_envs: self.safety_envs.clone(),
safety_accounts: self.safety_accounts.clone(),
notify_webhook: self.notify_webhook.clone(),
command_aliases: self.command_aliases.clone(),
lint_disable: self.lint_disable.clone(),
}
}
pub fn is_read_only_for(&self, env_name: &str) -> bool {
if self.read_only {
return true;
}
if self.deploy_freeze.is_some() {
return true;
}
if self.safety_envs.get(env_name).copied().unwrap_or(false) {
return true;
}
if let Some(profile) = self.context.profile.as_deref() {
if self.safety_accounts.get(profile).copied().unwrap_or(false) {
return true;
}
}
false
}
pub fn deny_write(&mut self, env_name: &str, verb: &str) -> bool {
if !self.is_read_only_for(env_name) {
return false;
}
let reason = self
.read_only_reason(env_name)
.unwrap_or_else(|| "read-only mode".into());
self.error_message = Some(format!("{reason} — {verb} disabled"));
true
}
pub fn read_only_reason(&self, env_name: &str) -> Option<String> {
if self.read_only {
return Some("read-only mode (global toggle)".into());
}
if let Some(freeze) = self.deploy_freeze.as_ref() {
let age = (chrono::Utc::now() - freeze.frozen_at).num_seconds().max(0);
let age = crate::app::humanize_short_age(std::time::Duration::from_secs(age as u64));
return Some(if freeze.reason.is_empty() {
format!("deploys frozen ({age} ago) — :thaw-deploys to unfreeze")
} else {
format!(
"deploys frozen ({age} ago): {} — :thaw-deploys to unfreeze",
freeze.reason
)
});
}
if self.safety_envs.get(env_name).copied().unwrap_or(false) {
return Some(format!(
"read-only mode (env pinned via safety.envs.{env_name})"
));
}
if let Some(profile) = self.context.profile.as_deref() {
if self.safety_accounts.get(profile).copied().unwrap_or(false) {
return Some(format!(
"read-only mode (account pinned via safety.accounts.{profile})"
));
}
}
None
}
fn maybe_apply_profile_theme(&mut self) {
let profile = self.context.profile.as_deref().unwrap_or("default");
let target_name = self
.profile_themes
.get(profile)
.cloned()
.unwrap_or_else(|| self.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.cached_app_colors.clear();
}
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_icons_raw = icons_raw;
self.refresh_interval = cfg.refresh_interval;
self.extra_regions = cfg.extra_regions.clone();
self.notify_bell = cfg.notify_bell;
self.required_tags = cfg.required_tags.clone();
self.rebuild_view();
}
fn handle_action_key(&mut self, key: KeyEvent) {
let Some(flow) = self.action_flow.as_mut() else {
self.mode = Mode::Normal;
return;
};
match flow {
ActionFlow::Menu { list_state } => match key.code {
KeyCode::Esc | KeyCode::Char('q') => self.close_action_flow(),
KeyCode::Char('j') | KeyCode::Down => {
let cur = list_state.selected().unwrap_or(0);
let next = (cur + 1) % ACTIONS.len();
list_state.select(Some(next));
}
KeyCode::Char('k') | KeyCode::Up => {
let cur = list_state.selected().unwrap_or(0);
let next = (cur + ACTIONS.len() - 1) % ACTIONS.len();
list_state.select(Some(next));
}
KeyCode::Enter => {
let Some(idx) = list_state.selected() else {
return;
};
let action = ACTIONS[idx];
self.advance_action_flow(action);
}
_ => {}
},
ActionFlow::SwapTarget { picker, .. } => match key.code {
KeyCode::Esc => self.close_action_flow(),
KeyCode::Down | KeyCode::Char('j')
if !key.modifiers.contains(KeyModifiers::CONTROL) =>
{
picker.move_selection(1);
}
KeyCode::Up | KeyCode::Char('k')
if !key.modifiers.contains(KeyModifiers::CONTROL) =>
{
picker.move_selection(-1);
}
KeyCode::Backspace => {
picker.filter.pop();
}
KeyCode::Enter => {
let Some(target) = picker.selected_value() else {
return;
};
let source = match flow {
ActionFlow::SwapTarget { source, .. } => source.clone(),
_ => return,
};
let warning = self
.environments
.iter()
.find(|e| e.name == source)
.map(compute_traffic_warning)
.unwrap_or(None);
self.action_flow = Some(ActionFlow::Confirm(ConfirmModal {
action: Action::SwapCnames,
target_env: source,
swap_with: Some(target),
typed: String::new(),
kind: ConfirmKind::YesNo,
dryrun: None,
loading_dryrun: false,
recent_events: None,
loading_events: false,
traffic_warning: warning,
deploy_version: None,
upgrade_platform_arn: None,
upgrade_platform_label: None,
clone_target: None,
scale_min: None,
scale_max: None,
auto_rollback_secs: None,
wait_for_green_secs: None,
version_preview: None,
loading_version_preview: false,
health_check_probe: None,
loading_health_check: false,
unavailability_line: None,
loading_unavailability: false,
lint_issues: None,
loading_lint: false,
}));
}
KeyCode::Char(c) if is_text_input(&key) => {
picker.filter.push(c);
let filt = picker.filtered();
if !filt
.iter()
.any(|i| Some(*i) == picker.list_state.selected())
{
picker.list_state.select(filt.first().copied());
}
}
_ => {}
},
ActionFlow::Confirm(modal) => match (key.code, modal.kind) {
(KeyCode::Esc, _) => self.close_action_flow(),
(KeyCode::Char('q'), ConfirmKind::YesNo) => self.close_action_flow(),
(KeyCode::Char('y'), ConfirmKind::YesNo) | (KeyCode::Enter, ConfirmKind::YesNo) => {
let m = modal.clone();
self.close_action_flow();
self.queue_action_dispatch(m);
}
(KeyCode::Char('n'), ConfirmKind::YesNo) => self.close_action_flow(),
(KeyCode::Enter, ConfirmKind::TypeName) if modal.typed == modal.target_env => {
let m = modal.clone();
self.close_action_flow();
self.queue_action_dispatch(m);
}
(KeyCode::Backspace, ConfirmKind::TypeName) => {
modal.typed.pop();
}
(KeyCode::Char(c), ConfirmKind::TypeName) if is_text_input(&key) => {
modal.typed.push(c);
}
_ => {}
},
}
}
fn advance_action_flow(&mut self, action: Action) {
let Some(env) = self.target_env_for_action() else {
self.close_action_flow();
return;
};
match action {
Action::SwapCnames => {
let candidates: Vec<String> = self
.environments
.iter()
.filter(|e| e.application == env.application && e.name != env.name)
.map(|e| e.name.clone())
.collect();
if candidates.is_empty() {
self.action_flow = None;
self.mode = if self.detail.is_some() {
Mode::Detail
} else {
Mode::Normal
};
self.error_message = Some(format!(
"no swap candidates: app '{}' has only one env",
env.application
));
return;
}
let picker = Picker::new(PickerKind::Region, candidates, None); self.action_flow = Some(ActionFlow::SwapTarget {
source: env.name.clone(),
picker,
});
}
Action::Terminate => {
let wants_preflight = action.wants_preflight();
self.action_flow = Some(ActionFlow::Confirm(ConfirmModal {
action,
target_env: env.name.clone(),
swap_with: None,
typed: String::new(),
kind: ConfirmKind::TypeName,
dryrun: None,
loading_dryrun: wants_preflight,
recent_events: None,
loading_events: wants_preflight,
traffic_warning: compute_traffic_warning(&env),
deploy_version: None,
upgrade_platform_arn: None,
upgrade_platform_label: None,
clone_target: None,
scale_min: None,
scale_max: None,
auto_rollback_secs: None,
wait_for_green_secs: None,
version_preview: None,
loading_version_preview: false,
health_check_probe: None,
loading_health_check: false,
unavailability_line: None,
loading_unavailability: false,
lint_issues: None,
loading_lint: false,
}));
if wants_preflight {
self.spawn_dry_run(env.name.clone());
self.spawn_preflight_events(env.name.clone());
}
}
Action::Rebuild => {
self.open_parameterised_action(action, ParameterisedAction::default());
}
Action::Deploy => {
self.close_action_flow();
self.mode = Mode::Command;
self.command_input = "deploy ".into();
self.status_message = Some("type a version label and press enter".into());
}
Action::UpgradePlatform => {
self.close_action_flow();
self.spawn_list_compatible_platforms(env.name.clone());
self.mode = Mode::Command;
self.command_input = "upgrade ".into();
self.status_message =
Some("listing platforms in overlay; paste an ARN and press enter".into());
}
Action::Clone => {
self.close_action_flow();
self.mode = Mode::Command;
self.command_input = "clone ".into();
self.status_message = Some("type a new env name and press enter".into());
}
Action::Scale => {
self.close_action_flow();
self.mode = Mode::Command;
self.command_input = "scale ".into();
self.status_message = Some(
"scale N (instances), or `scale min N` / `scale max N`; enter to apply".into(),
);
}
Action::Capacity => {
self.close_action_flow();
self.cmd_capacity();
}
Action::AbortUpdate => {
self.action_flow = Some(ActionFlow::Confirm(ConfirmModal {
action,
target_env: env.name.clone(),
swap_with: None,
typed: String::new(),
kind: ConfirmKind::YesNo,
dryrun: None,
loading_dryrun: false,
recent_events: None,
loading_events: false,
traffic_warning: compute_traffic_warning(&env),
deploy_version: None,
upgrade_platform_arn: None,
upgrade_platform_label: None,
clone_target: None,
scale_min: None,
scale_max: None,
auto_rollback_secs: None,
wait_for_green_secs: None,
version_preview: None,
loading_version_preview: false,
health_check_probe: None,
loading_health_check: false,
unavailability_line: None,
loading_unavailability: false,
lint_issues: None,
loading_lint: false,
}));
}
_ => {
self.action_flow = Some(ActionFlow::Confirm(ConfirmModal {
action,
target_env: env.name.clone(),
swap_with: None,
typed: String::new(),
kind: ConfirmKind::YesNo,
dryrun: None,
loading_dryrun: false,
recent_events: None,
loading_events: false,
traffic_warning: compute_traffic_warning(&env),
deploy_version: None,
upgrade_platform_arn: None,
upgrade_platform_label: None,
clone_target: None,
scale_min: None,
scale_max: None,
auto_rollback_secs: None,
wait_for_green_secs: None,
version_preview: None,
loading_version_preview: false,
health_check_probe: None,
loading_health_check: false,
unavailability_line: None,
loading_unavailability: false,
lint_issues: None,
loading_lint: false,
}));
}
}
}
fn handle_log_tail_key(&mut self, key: KeyEvent) {
if matches!(key.code, KeyCode::Tab)
&& !matches!(
self.current_overlay.as_ref(),
Some(Overlay::LogTail {
filter_active: true,
..
})
)
{
self.open_log_group_picker();
return;
}
{
let Some(Overlay::LogTail {
filter_active,
filter_input,
filter_pattern,
..
}) = self.current_overlay.as_mut()
else {
return;
};
if *filter_active {
match key.code {
KeyCode::Esc => {
*filter_active = false;
filter_input.clear();
*filter_pattern = None;
return;
}
KeyCode::Enter => {
*filter_active = false;
if filter_input.is_empty() {
*filter_pattern = None;
} else {
match regex::RegexBuilder::new(filter_input)
.case_insensitive(true)
.build()
{
Ok(re) => *filter_pattern = Some(re),
Err(_) => *filter_pattern = None,
}
}
return;
}
KeyCode::Backspace => {
filter_input.pop();
return;
}
KeyCode::Char(c) if is_text_input(&key) => {
filter_input.push(c);
return;
}
_ => return,
}
}
}
let Some(Overlay::LogTail {
scroll,
following,
filter_active,
filter_input,
filter_pattern,
..
}) = self.current_overlay.as_mut()
else {
return;
};
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
if let Some(handle) = self.log_tail_task.take() {
handle.abort();
}
self.log_tail_session = self.log_tail_session.wrapping_add(1);
self.current_overlay = None;
}
KeyCode::Char('j') | KeyCode::Down => {
if *scroll > 0 {
*scroll -= 1;
}
if *scroll == 0 {
*following = true;
}
}
KeyCode::Char('k') | KeyCode::Up => {
*scroll = scroll.saturating_add(1);
*following = false;
}
KeyCode::Char('G') | KeyCode::End => {
*scroll = 0;
*following = true;
}
KeyCode::Char('g') | KeyCode::Home => {
*scroll = u16::MAX;
*following = false;
}
KeyCode::Char('/') => {
*filter_active = true;
filter_input.clear();
*filter_pattern = None;
}
KeyCode::Char('n') => {
filter_input.clear();
*filter_pattern = None;
}
_ => {}
}
}
fn open_log_group_picker(&mut self) {
let Some(Overlay::LogTail { log_group, .. }) = self.current_overlay.as_ref() else {
return;
};
let current_group = log_group.clone();
let groups: Vec<String> = self
.detail
.as_ref()
.and_then(|d| d.cw_log_groups.clone())
.unwrap_or_default();
if groups.is_empty() {
self.status_message = Some(
"no CW log groups discovered for this env — try `:logs-tail <full-group-name>`"
.into(),
);
return;
}
self.picker = Some(Picker::new(
PickerKind::LogGroup,
groups,
Some(current_group.as_str()),
));
self.mode = Mode::Picker;
}
fn spawn_config_apply_template(&mut self, env_name: String, template: String) {
if self.deny_write(&env_name, "config-apply") {
return;
}
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!("stage=dispatched action=ConfigApply target={env_name} template={template}"),
);
self.push_pending(Action::ConfigApply.label(), env_name.clone());
let env_for_msg = env_name.clone();
tokio::spawn(async move {
let result = aws
.apply_config_template(&env_for_msg, &template)
.await
.map_err(|e| flatten_err("apply_config_template", e));
let _ = tx.send(AppMsg::ActionResult {
gen,
action: Action::ConfigApply,
env_name: env_for_msg,
result,
});
});
}
fn spawn_config_delete_template(&mut self, app_name: String, template: String) {
if self.deny_write("", "config-delete") {
return;
}
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let target = format!("{app_name}/{template}");
self.status_message = Some(format!(
"deleting template '{template}' from app '{app_name}'…"
));
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!("stage=dispatched action=ConfigDelete target={target}"),
);
self.push_pending(Action::ConfigDelete.label(), target.clone());
let template_for_msg = template.clone();
tokio::spawn(async move {
let result = aws
.delete_config_template(&app_name, &template)
.await
.map_err(|e| flatten_err("delete_config_template", e))
.map_err(|e| format!("config-delete '{template_for_msg}': {e}"));
let _ = tx.send(AppMsg::ActionResult {
gen,
action: Action::ConfigDelete,
env_name: target,
result,
});
});
}
fn spawn_config_inspect_template(&mut self, app_name: String, template: String) {
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let title = format!("template — {app_name}/{template}");
tokio::spawn(async move {
let body = match aws.describe_template_settings(&app_name, &template).await {
Ok(settings) if settings.is_empty() => {
"(template has no option settings)".to_string()
}
Ok(settings) => format_template_settings(&settings),
Err(e) => format!("error: {}", flatten_err("describe_template_settings", e)),
};
let _ = tx.send(AppMsg::TextOverlay { gen, title, body });
});
}
fn spawn_logs_tail(&mut self, env_name: String, explicit_group: Option<String>) {
if let Some(handle) = self.log_tail_task.take() {
handle.abort();
}
self.log_tail_session = self.log_tail_session.wrapping_add(1);
let session_id = self.log_tail_session;
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let env_for_msg = env_name.clone();
let handle = tokio::spawn(async move {
let group = match explicit_group {
Some(g) => g,
None => match aws.discover_env_log_groups(&env_for_msg).await {
Ok(groups) => match pick_default_log_group(&groups) {
Some(g) => g,
None => {
let _ = tx.send(AppMsg::LogTailEvents {
gen,
session_id,
next_since_ms: 0,
result: Err(format!(
"no CW log groups under /aws/elasticbeanstalk/{env_for_msg}/ — enable streaming with `:logs-stream on`"
)),
});
return;
}
},
Err(e) => {
let _ = tx.send(AppMsg::LogTailEvents {
gen,
session_id,
next_since_ms: 0,
result: Err(format!("discover log groups: {e}")),
});
return;
}
},
};
let mut since_ms = chrono::Utc::now().timestamp_millis() - 5 * 60 * 1000;
let _ = tx.send(AppMsg::LogTailOpened {
gen,
session_id,
env_name: env_for_msg.clone(),
log_group: group.clone(),
since_ms,
});
loop {
match aws.fetch_recent_log_events(&group, since_ms, 1000).await {
Ok((events, next_since)) => {
let next_since_ms = next_since;
let _ = tx.send(AppMsg::LogTailEvents {
gen,
session_id,
next_since_ms,
result: Ok(events),
});
since_ms = next_since;
}
Err(e) => {
let _ = tx.send(AppMsg::LogTailEvents {
gen,
session_id,
next_since_ms: since_ms,
result: Err(format!("{e}")),
});
}
}
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
}
});
self.log_tail_task = Some(handle);
}
pub(crate) fn spawn_option_settings_update(
&mut self,
summary: String,
to_set: Vec<(String, String, String)>,
to_remove: Vec<(String, String)>,
) {
let Some(env) = self.selected_env().cloned() else {
self.error_message = Some("no env selected".into());
return;
};
if self.deny_write(&env.name, &summary) {
return;
}
if to_set.is_empty() && to_remove.is_empty() {
self.error_message = Some(format!(
"{summary}: nothing to do (no options to set or remove)"
));
return;
}
let env_name = env.name.clone();
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!(
"stage=dispatched action=UpdateOptionSettings target={env_name} summary=\"{summary}\""
),
);
self.push_pending(summary.clone(), env_name.clone());
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let env_for_msg = env_name.clone();
let summary_for_msg = summary.clone();
let account = self.context.account_id.clone();
let profile = self.context.profile.clone();
let region = self.context.region.clone();
let app_for_undo = env.application.clone();
let env_for_undo = env_name.clone();
let summary_for_undo = summary.clone();
let to_set_for_undo = to_set.clone();
let to_remove_for_undo = to_remove.clone();
tokio::spawn(async move {
let undo_entry = match aws
.fetch_env_option_settings(&app_for_undo, &env_for_undo)
.await
{
Ok(opts) => Some(build_undo_entry(
&env_for_undo,
&summary_for_undo,
&to_set_for_undo,
&to_remove_for_undo,
&opts,
)),
Err(_) => None,
};
let result = aws
.update_env_option_settings(&env_for_msg, &to_set, &to_remove)
.await
.map_err(|e| flatten_err("update_env_option_settings", e));
let outcome = match &result {
Ok(()) => format!(
"stage=completed action=UpdateOptionSettings target={env_for_msg} summary=\"{summary_for_msg}\" ok"
),
Err(e) => format!(
"stage=completed action=UpdateOptionSettings target={env_for_msg} summary=\"{summary_for_msg}\" err=\"{}\"",
e.replace('"', "'")
),
};
write_audit_line(account.as_deref(), profile.as_deref(), ®ion, &outcome);
if result.is_ok() {
if let Some(entry) = undo_entry {
let _ = tx.send(AppMsg::UndoCaptured { gen, entry });
}
}
let _ = tx.send(AppMsg::OptionSettingsUpdate {
gen,
env_name: env_for_msg,
summary: summary_for_msg,
result,
});
});
}
fn spawn_deploy_from_s3(
&mut self,
bucket: String,
key: String,
explicit_label: Option<String>,
description: Option<String>,
and_deploy: bool,
) {
let Some(env) = self.selected_env().cloned() else {
self.error_message = Some("no env selected".into());
return;
};
if self.deny_write(&env.name, "deploy-from-s3") {
return;
}
let label = explicit_label
.unwrap_or_else(|| derive_version_label(&key, chrono::Utc::now().timestamp()));
let env_name = env.name.clone();
let app_name = env.application.clone();
let summary = if and_deploy {
format!("deploy-from-s3 {label}")
} else {
format!("create-version-from-s3 {label}")
};
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!(
"stage=dispatched action=DeployFromS3 target={env_name} label={label} source=s3://{bucket}/{key} and_deploy={and_deploy}"
),
);
self.push_pending(summary.clone(), env_name.clone());
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let env_for_msg = env_name.clone();
let label_for_msg = label.clone();
let summary_for_msg = summary.clone();
let account = self.context.account_id.clone();
let profile = self.context.profile.clone();
let region = self.context.region.clone();
let description_owned = description;
tokio::spawn(async move {
if let Err(e) = aws
.create_app_version(
&app_name,
&label_for_msg,
description_owned.as_deref(),
&bucket,
&key,
)
.await
{
let err = format!("create-version: {}", flatten_err("create_app_version", e));
finish_deploy_from_local(
&tx,
gen,
env_for_msg,
label_for_msg,
summary_for_msg,
account.as_deref(),
profile.as_deref(),
®ion,
Err(err),
);
return;
}
if and_deploy {
if let Err(e) = aws.deploy_version(&env_for_msg, &label_for_msg).await {
let err = format!("deploy: {}", flatten_err("deploy_version", e));
finish_deploy_from_local(
&tx,
gen,
env_for_msg,
label_for_msg,
summary_for_msg,
account.as_deref(),
profile.as_deref(),
®ion,
Err(err),
);
return;
}
}
finish_deploy_from_local(
&tx,
gen,
env_for_msg,
label_for_msg,
summary_for_msg,
account.as_deref(),
profile.as_deref(),
®ion,
Ok(()),
);
});
}
fn spawn_deploy_preview(&self, env: crate::aws::Environment, label: String) {
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let app_name = env.application.clone();
let env_name = env.name.clone();
let current_label = env.version_label.clone();
tokio::spawn(async move {
let body = match aws.list_application_versions(&app_name).await {
Ok(versions) => format_deploy_preview(&env_name, ¤t_label, &label, &versions),
Err(e) => format!(
"deploy preview — failed to fetch application versions:\n {}\n",
flatten_err_to_string(&e)
),
};
let _ = tx.send(AppMsg::TextOverlay {
gen,
title: format!("deploy preview — {env_name} ← {label}"),
body,
});
});
}
fn spawn_deploy_from_local(
&mut self,
path: String,
explicit_label: Option<String>,
description: Option<String>,
and_deploy: bool,
) {
let Some(env) = self.selected_env().cloned() else {
self.error_message = Some("no env selected".into());
return;
};
if self.is_read_only_for(&env.name) {
let reason = self
.read_only_reason(&env.name)
.unwrap_or_else(|| "read-only mode".into());
self.error_message = Some(format!("{reason} — deploy-from-local disabled"));
return;
}
let resolved = expand_tilde(&path);
let resolved_path = std::path::PathBuf::from(&resolved);
let size = match std::fs::metadata(&resolved_path) {
Ok(m) => m.len(),
Err(e) => {
self.error_message = Some(format!("can't read {resolved}: {e}"));
return;
}
};
if size == 0 {
self.error_message = Some(format!("{resolved} is empty"));
return;
}
let label = explicit_label
.unwrap_or_else(|| derive_version_label(&resolved, chrono::Utc::now().timestamp()));
let env_name = env.name.clone();
let app_name = env.application.clone();
let summary = if and_deploy {
format!("deploy-from-local {label}")
} else {
format!("upload-version {label}")
};
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!(
"stage=dispatched action=DeployFromLocal target={env_name} label={label} bytes={size} and_deploy={and_deploy}"
),
);
self.push_pending(summary.clone(), env_name.clone());
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let env_for_msg = env_name.clone();
let label_for_msg = label.clone();
let summary_for_msg = summary.clone();
let account = self.context.account_id.clone();
let profile = self.context.profile.clone();
let region = self.context.region.clone();
let description_owned = description;
tokio::spawn(async move {
let bucket = match aws.create_storage_location().await {
Ok(b) => b,
Err(e) => {
let err = format!(
"storage-location: {}",
flatten_err("create_storage_location", e)
);
finish_deploy_from_local(
&tx,
gen,
env_for_msg,
label_for_msg,
summary_for_msg,
account.as_deref(),
profile.as_deref(),
®ion,
Err(err),
);
return;
}
};
let key = format!("applications/{app_name}/{label_for_msg}");
if let Err(e) = aws.upload_bundle(&bucket, &key, &resolved_path).await {
let err = format!("s3-put: {}", flatten_err("upload_bundle", e));
finish_deploy_from_local(
&tx,
gen,
env_for_msg,
label_for_msg,
summary_for_msg,
account.as_deref(),
profile.as_deref(),
®ion,
Err(err),
);
return;
}
if let Err(e) = aws
.create_app_version(
&app_name,
&label_for_msg,
description_owned.as_deref(),
&bucket,
&key,
)
.await
{
let err = format!("create-version: {}", flatten_err("create_app_version", e));
finish_deploy_from_local(
&tx,
gen,
env_for_msg,
label_for_msg,
summary_for_msg,
account.as_deref(),
profile.as_deref(),
®ion,
Err(err),
);
return;
}
if and_deploy {
if let Err(e) = aws.deploy_version(&env_for_msg, &label_for_msg).await {
let err = format!("deploy: {}", flatten_err("deploy_version", e));
finish_deploy_from_local(
&tx,
gen,
env_for_msg,
label_for_msg,
summary_for_msg,
account.as_deref(),
profile.as_deref(),
®ion,
Err(err),
);
return;
}
}
finish_deploy_from_local(
&tx,
gen,
env_for_msg,
label_for_msg,
summary_for_msg,
account.as_deref(),
profile.as_deref(),
®ion,
Ok(()),
);
});
}
fn spawn_delete_app_version(&mut self, label: String, force: bool) {
let Some(env) = self.selected_env().cloned() else {
self.error_message = Some("no env selected".into());
return;
};
if self.deny_write(&env.name, "delete-version") {
return;
}
let application = env.application.clone();
let force_str = if force { " (+source bundle)" } else { "" };
let detail = format!(
"stage=dispatched action=DeleteAppVersion target={application}/{label}{force_str}"
);
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&detail,
);
let _ = force_str;
let pending_label = if force {
"Delete app version (+source)"
} else {
"Delete app version"
};
let pending_target = format!("{application}/{label}");
self.push_pending(pending_label, pending_target);
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let account = self.context.account_id.clone();
let profile = self.context.profile.clone();
let region = self.context.region.clone();
let app_for_msg = application.clone();
let label_for_msg = label.clone();
tokio::spawn(async move {
let result = aws
.delete_application_version(&application, &label, force)
.await
.map_err(|e| flatten_err("delete_application_version", e));
let outcome = match &result {
Ok(()) => format!(
"stage=completed action=DeleteAppVersion target={application}/{label}{force_str} ok"
),
Err(e) => format!(
"stage=completed action=DeleteAppVersion target={application}/{label}{force_str} err=\"{}\"",
e.replace('"', "'")
),
};
write_audit_line(account.as_deref(), profile.as_deref(), ®ion, &outcome);
let _ = tx.send(AppMsg::DeleteAppVersion {
gen,
application: app_for_msg,
label: label_for_msg,
force,
result,
});
});
}
fn handle_saved_configs_interactive_key(&mut self, key: KeyEvent) {
{
let Some(Overlay::SavedConfigsInteractive {
items,
cursor,
confirm_delete,
}) = self.current_overlay.as_mut()
else {
return;
};
if items.is_empty() {
self.current_overlay = None;
return;
}
let len = items.len();
if *confirm_delete {
match key.code {
KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
*confirm_delete = false;
return;
}
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
}
_ => return,
}
} else {
match key.code {
KeyCode::Char('j') | KeyCode::Down => {
*cursor = (*cursor + 1).min(len.saturating_sub(1));
return;
}
KeyCode::Char('k') | KeyCode::Up => {
*cursor = cursor.saturating_sub(1);
return;
}
KeyCode::Char('g') | KeyCode::Home => {
*cursor = 0;
return;
}
KeyCode::Char('G') | KeyCode::End => {
*cursor = len.saturating_sub(1);
return;
}
KeyCode::Char('x') => {
*confirm_delete = true;
return;
}
_ => {}
}
}
}
let Some(Overlay::SavedConfigsInteractive {
items,
cursor,
confirm_delete,
}) = self.current_overlay.as_ref()
else {
return;
};
let cursor = *cursor;
let confirm_delete = *confirm_delete;
let selected = items.get(cursor).cloned();
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
self.current_overlay = None;
}
KeyCode::Char('a') | KeyCode::Enter if !confirm_delete => {
if let Some((_app, template)) = selected {
self.current_overlay = None;
let Some(env) = self.selected_env().cloned() else {
self.error_message = Some("no env selected".into());
return;
};
self.spawn_config_apply_template(env.name, template);
}
}
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter if confirm_delete => {
if let Some((app_name, template)) = selected {
self.current_overlay = None;
self.spawn_config_delete_template(app_name, template);
}
}
KeyCode::Char('c') => {
self.current_overlay = None;
self.command_input = "config-save ".into();
self.mode = Mode::Command;
}
KeyCode::Char('i') => {
if let Some((app_name, template)) = selected {
self.current_overlay = None;
self.spawn_config_inspect_template(app_name, template);
}
}
KeyCode::Char('?') => {
self.help.pre_overlay = self.current_overlay.take();
self.help.pre_mode = Some(self.mode);
self.help.topic = HelpTopic::SavedConfigs;
self.mode = Mode::Help;
}
_ => {}
}
}
fn spawn_tag_update(&mut self, to_add: Vec<(String, String)>, to_remove: Vec<String>) {
let Some(env) = self.selected_env().cloned() else {
self.error_message = Some("no env selected".into());
return;
};
if self.deny_write(&env.name, "tag edits") {
return;
}
let Some(arn) = env.arn.clone() else {
self.error_message = Some(format!("env {} has no ARN — re-fetch and retry", env.name));
return;
};
if to_add.is_empty() && to_remove.is_empty() {
self.error_message =
Some("nothing to do — provide tags to add or keys to remove".into());
return;
}
let summary = if !to_add.is_empty() {
let keys: Vec<String> = to_add.iter().map(|(k, _)| k.clone()).collect();
format!("tag {}", keys.join(","))
} else {
format!("untag {}", to_remove.join(","))
};
let detail = format!(
"stage=dispatched action=UpdateTags target={} {}",
env.name, summary
);
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&detail,
);
self.push_pending(summary.clone(), env.name.clone());
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let env_name = env.name.clone();
let summary_for_msg = summary.clone();
let account = self.context.account_id.clone();
let profile = self.context.profile.clone();
let region = self.context.region.clone();
tokio::spawn(async move {
let result = aws
.update_tags(&arn, &to_add, &to_remove)
.await
.map_err(|e| flatten_err("update_tags", e));
let outcome_detail = match &result {
Ok(()) => {
format!("stage=completed action=UpdateTags target={env_name} {summary} ok")
}
Err(e) => format!(
"stage=completed action=UpdateTags target={env_name} {summary} err=\"{}\"",
e.replace('"', "'"),
),
};
write_audit_line(
account.as_deref(),
profile.as_deref(),
®ion,
&outcome_detail,
);
let _ = tx.send(AppMsg::TagUpdate {
gen,
env_name,
summary: summary_for_msg,
result,
});
});
}
fn spawn_preflight_events(&mut self, env_name: String) {
let env_for_msg = env_name.clone();
self.spawn_aws(
"preflight_events",
move |aws| async move { aws.list_events_for_env(&env_name, 3).await },
move |gen, result| AppMsg::PreflightEvents {
gen,
env_name: env_for_msg,
result,
},
);
}
fn spawn_dry_run(&mut self, env_name: String) {
let env_for_msg = env_name.clone();
self.spawn_aws(
"dry_run_list_instances",
move |aws| async move { aws.list_instances(&env_name).await },
move |gen, result| AppMsg::DryRunResult {
gen,
env_name: env_for_msg,
result,
},
);
}
fn spawn_version_preview(
&mut self,
app_name: String,
env_name: String,
current_label: String,
candidate_label: String,
) {
let env_for_msg = env_name.clone();
let env_for_render = env_name.clone();
let candidate_for_render = candidate_label.clone();
self.spawn_aws(
"version_preview",
move |aws| async move { aws.list_application_versions(&app_name).await },
move |gen, result| {
let body = match result {
Ok(versions) => Ok(format_deploy_preview(
&env_for_render,
¤t_label,
&candidate_for_render,
&versions,
)),
Err(e) => Err(e),
};
AppMsg::VersionPreview {
gen,
env_name: env_for_msg,
result: body,
}
},
);
}
fn spawn_health_check_probe(&mut self, app_name: String, env_name: String, cname: String) {
let env_for_msg = env_name.clone();
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
tokio::spawn(async move {
let path = match aws.fetch_env_option_settings(&app_name, &env_name).await {
Ok(opts) => opts
.into_iter()
.find(|(ns, name, _)| {
ns == "aws:elasticbeanstalk:application"
&& name == "Application Healthcheck URL"
})
.map(|(_, _, v)| v)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "/".into()),
Err(_) => "/".into(),
};
let url = build_health_check_probe_url(&cname, &path);
let result = run_health_check_probe(&url).await;
let _ = tx.send(AppMsg::HealthCheckProbe {
gen,
env_name: env_for_msg,
result,
});
});
}
fn spawn_unavailability_estimate(&mut self, app_name: String, env_name: String) {
let env_for_msg = env_name.clone();
self.spawn_aws(
"unavailability_estimate",
move |aws| async move { aws.fetch_env_option_settings(&app_name, &env_name).await },
move |gen, result| {
let line = result.ok().map(|opts| {
let (policy, batch, btype, asg_max) = extract_unavailability_inputs(&opts);
let count = compute_unavailability_count(&policy, batch, &btype, asg_max);
format_unavailability_line(&policy, count, asg_max)
});
AppMsg::UnavailabilityEstimate {
gen,
env_name: env_for_msg,
line,
}
},
);
}
fn spawn_confirm_lint(&mut self, env: crate::aws::Environment) {
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let mut disabled = self.lint_disable.clone();
disabled.extend(crate::project::load_lint_disables_from_cwd());
let env_for_msg = env.name.clone();
let app_name = env.application.clone();
let env_name = env.name.clone();
tokio::spawn(async move {
let issues = match aws.fetch_env_option_settings(&app_name, &env_name).await {
Ok(opts) => {
let ctx = crate::lint::LintContext {
env: &env,
options: &opts,
events: &[],
cost_usd_per_month: None,
latest_stack_version: None,
};
let rules = crate::lint::default_rules(&disabled);
crate::lint::run_rules(&rules, &ctx)
}
Err(_) => Vec::new(),
};
let _ = tx.send(AppMsg::ConfirmModalLint {
gen,
env_name: env_for_msg,
issues,
});
});
}
fn spawn_batch_action(&mut self, action: Action, env: String) {
write_audit_entry(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
action,
&env,
None,
);
self.push_pending(action.label(), env.clone());
let env_for_msg = env.clone();
self.spawn_aws(
"batch_action",
move |aws| async move {
match action {
Action::Rebuild => aws.rebuild_env(&env).await,
Action::RestartAppServer => aws.restart_app_server(&env).await,
_ => Err(color_eyre::eyre::eyre!(
"batch-mode only supports Rebuild / Restart"
)),
}
},
move |gen, result| AppMsg::ActionResult {
gen,
action,
env_name: env_for_msg,
result,
},
);
}
fn spawn_batch_deploy(&mut self, env: String, label: String) {
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!("stage=dispatched action=Deploy target={env} version={label}"),
);
self.push_pending(Action::Deploy.label(), env.clone());
let env_for_msg = env.clone();
tokio::spawn(async move {
let result = aws
.deploy_version(&env, &label)
.await
.map_err(|e| flatten_err("deploy_version", e));
let _ = tx.send(AppMsg::ActionResult {
gen,
action: Action::Deploy,
env_name: env_for_msg,
result,
});
});
}
fn spawn_batch_tag(&mut self, env: String, arn: String, key: String, value: Option<String>) {
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let is_add = value.is_some();
let op_label = if is_add { "tag" } else { "untag" };
let detail = match &value {
Some(v) => {
format!("stage=dispatched action=Tag target={env} key={key} value={v}")
}
None => format!("stage=dispatched action=Untag target={env} key={key}"),
};
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&detail,
);
let pending_label = format!("{op_label} {key}");
self.push_pending(pending_label.clone(), env.clone());
let env_for_msg = env.clone();
tokio::spawn(async move {
let to_add: Vec<(String, String)> = match &value {
Some(v) => vec![(key.clone(), v.clone())],
None => Vec::new(),
};
let to_remove: Vec<String> = if value.is_none() {
vec![key.clone()]
} else {
Vec::new()
};
let result = aws
.update_tags(&arn, &to_add, &to_remove)
.await
.map_err(|e| flatten_err("update_tags", e));
let _ = tx.send(AppMsg::TagUpdate {
gen,
env_name: env_for_msg,
summary: pending_label,
result,
});
});
}
fn spawn_batch_set_option(
&mut self,
env: String,
namespace: String,
name: String,
value: String,
) {
let Some(app_name) = self
.environments
.iter()
.find(|e| e.name == env)
.map(|e| e.application.clone())
else {
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!(
"stage=skipped action=SetOption target={env} reason=\"env not in current view\""
),
);
return;
};
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let detail = format!(
"stage=dispatched action=SetOption target={env} ns={namespace} name={name} value={value}"
);
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&detail,
);
let pending_label = format!("set-option {namespace}.{name}");
self.push_pending(pending_label.clone(), env.clone());
let env_for_msg = env.clone();
let env_for_undo = env.clone();
let pending_label_for_undo = pending_label.clone();
let to_set_for_undo: Vec<(String, String, String)> =
vec![(namespace.clone(), name.clone(), value.clone())];
tokio::spawn(async move {
let undo_entry = match aws
.fetch_env_option_settings(&app_name, &env_for_undo)
.await
{
Ok(opts) => Some(build_undo_entry(
&env_for_undo,
&pending_label_for_undo,
&to_set_for_undo,
&[],
&opts,
)),
Err(_) => None,
};
let settings = vec![(namespace, name, value)];
let result = aws
.update_env_option_settings(&env, &settings, &[])
.await
.map_err(|e| flatten_err("update_env_option_settings", e));
if result.is_ok() {
if let Some(entry) = undo_entry {
let _ = tx.send(AppMsg::UndoCaptured { gen, entry });
}
}
let _ = tx.send(AppMsg::OptionSettingsUpdate {
gen,
env_name: env_for_msg,
summary: pending_label,
result,
});
});
}
fn open_instance_info_overlay(&mut self) {
let Some(d) = self.detail.as_ref() else {
return;
};
let Some(inst) = d.instances.get(d.instances_cursor) else {
self.status_message = Some("no instance selected".into());
return;
};
let mut body = String::new();
body.push_str(&format!("Instance ID {}\n", inst.id));
body.push_str(&format!("Type {}\n", inst.instance_type));
body.push_str(&format!("Availability zone {}\n", inst.availability_zone));
body.push_str(&format!(
"Health {} ({})\n",
inst.health, inst.color
));
if let Some(t) = inst.launched_at {
let age = chrono::Utc::now().signed_duration_since(t);
body.push_str(&format!(
"Launched {} (up {})\n",
t.format("%Y-%m-%d %H:%M UTC"),
humanize_short_age(age.to_std().unwrap_or_default())
));
}
if !inst.causes.is_empty() {
body.push_str("\nCauses:\n");
for c in &inst.causes {
body.push_str(&format!(" • {c}\n"));
}
}
body.push_str(
"\nKeys: b → open in EC2 console · s → SSM shell · y → yank id · x → terminate",
);
self.current_overlay = Some(Overlay::TextDump {
title: format!("instance — {}", inst.id),
body,
});
}
fn open_instance_in_console(&mut self) {
let Some(d) = self.detail.as_ref() else {
return;
};
let Some(inst) = d.instances.get(d.instances_cursor) else {
return;
};
let region = self.context.region.clone();
let id = inst.id.clone();
let url = format!(
"https://{region}.console.aws.amazon.com/ec2/home?region={region}#InstanceDetails:instanceId={id}"
);
let display = id.clone();
let result = std::process::Command::new(if cfg!(target_os = "macos") {
"open"
} else {
"xdg-open"
})
.arg(&url)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn();
match result {
Ok(_) => {
self.status_message = Some(format!("opened {display} in EC2 console"));
}
Err(e) => {
self.error_message = Some(format!("could not open browser: {e}"));
}
}
}
fn yank_instance_id(&mut self) {
let Some(d) = self.detail.as_ref() else {
return;
};
let Some(inst) = d.instances.get(d.instances_cursor) else {
return;
};
let id = inst.id.clone();
match yank(&id) {
Ok(()) => self.status_message = Some(format!("yanked instance id: {id}")),
Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
}
}
fn spawn_terminate_instance(&mut self, idx: usize) {
let Some(d) = self.detail.as_ref() else {
return;
};
let Some(inst) = d.instances.get(idx).cloned() else {
return;
};
let env_name = d.env_name.clone();
if self.is_read_only_for(&env_name) {
let reason = self
.read_only_reason(&env_name)
.unwrap_or_else(|| "read-only mode".into());
self.error_message = Some(format!("{reason} — terminate-instance disabled"));
return;
}
let id = inst.id.clone();
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!("stage=dispatched action=TerminateInstance target={env_name} instance={id}"),
);
let target = format!("{env_name}/{id}");
self.push_pending(Action::TerminateInstance.label(), target.clone());
let _ = id;
tokio::spawn(async move {
let result = aws
.terminate_instance(&id)
.await
.map_err(|e| flatten_err("terminate_instance", e));
let _ = tx.send(AppMsg::ActionResult {
gen,
action: Action::TerminateInstance,
env_name: target,
result,
});
});
}
pub fn push_pending(&mut self, label: impl Into<String>, target: impl Into<String>) {
if self.pending_actions.len() >= PENDING_CAP {
self.pending_actions.pop_front();
}
self.pending_actions.push_back(PendingAction {
label: label.into(),
target: target.into(),
started: Instant::now(),
completed: None,
});
}
pub fn complete_pending(&mut self, label: &str, target: &str, result: Result<(), String>) {
if let Some(entry) = self
.pending_actions
.iter_mut()
.find(|e| e.completed.is_none() && e.label == label && e.target == target)
{
entry.completed = Some((Instant::now(), result));
}
}
pub fn expire_pending(&mut self) {
let now = Instant::now();
self.pending_actions.retain(|e| match e.completed {
Some((c, _)) => now.duration_since(c) < PENDING_COMPLETED_TTL,
None => true,
});
}
fn open_parameterised_action(&mut self, action: Action, params: ParameterisedAction) {
let Some(env) = self.selected_env().cloned() else {
self.error_message = Some("no env selected".into());
return;
};
self.open_parameterised_action_on(env, action, params);
}
pub(crate) fn open_parameterised_action_on(
&mut self,
env: crate::aws::Environment,
action: Action,
params: ParameterisedAction,
) {
if self.deny_write(&env.name, action.label()) {
return;
}
let wants_preflight = action.wants_preflight();
let wants_version_preview = action == Action::Deploy && params.deploy_version.is_some();
let modal = ConfirmModal {
action,
target_env: env.name.clone(),
swap_with: params.swap_with,
typed: String::new(),
kind: ConfirmKind::YesNo,
dryrun: None,
loading_dryrun: wants_preflight,
recent_events: None,
loading_events: wants_preflight,
traffic_warning: compute_traffic_warning(&env),
deploy_version: params.deploy_version.clone(),
upgrade_platform_arn: params.upgrade_platform_arn,
upgrade_platform_label: params.upgrade_platform_label,
clone_target: params.clone_target,
scale_min: params.scale_min,
scale_max: params.scale_max,
auto_rollback_secs: params.auto_rollback_secs,
wait_for_green_secs: params.wait_for_green_secs,
version_preview: None,
loading_version_preview: wants_version_preview,
health_check_probe: None,
loading_health_check: wants_version_preview && !env.cname.is_empty() && !self.demo_mode,
unavailability_line: None,
loading_unavailability: wants_version_preview && !self.demo_mode,
lint_issues: None,
loading_lint: !self.demo_mode,
};
let needs_health_check_probe = modal.loading_health_check;
let needs_unavailability = modal.loading_unavailability;
let needs_lint = modal.loading_lint;
self.action_flow = Some(ActionFlow::Confirm(modal));
self.mode = Mode::Action;
if wants_preflight {
self.spawn_dry_run(env.name.clone());
self.spawn_preflight_events(env.name.clone());
}
if wants_version_preview {
if let Some(label) = params.deploy_version {
self.spawn_version_preview(
env.application.clone(),
env.name.clone(),
env.version_label.clone(),
label,
);
}
}
if needs_health_check_probe {
self.spawn_health_check_probe(
env.application.clone(),
env.name.clone(),
env.cname.clone(),
);
}
if needs_unavailability {
self.spawn_unavailability_estimate(env.application.clone(), env.name.clone());
}
if needs_lint {
self.spawn_confirm_lint(env.clone());
}
}
fn spawn_list_compatible_platforms(&mut self, env_name: String) {
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
self.status_message = Some(format!(
"fetching compatible platform versions for {env_name}…"
));
let env_for_msg = env_name.clone();
tokio::spawn(async move {
let result = aws
.list_compatible_platforms(&env_name)
.await
.map_err(|e| flatten_err("list_compatible_platforms", e));
let body = match result {
Ok(p) if p.is_empty() => {
format!("No compatible platform versions found for {env_for_msg}.\n\nesc / q to close")
}
Ok(platforms) => {
let mut lines: Vec<String> = vec![
format!("Compatible platform versions for {env_for_msg}"),
"─────────────────────────────────────────────".into(),
String::new(),
];
for p in platforms.iter().take(20) {
lines.push(format!(
" v{} {} ({}, {})",
p.version, p.branch, p.status, p.lifecycle
));
lines.push(format!(" {}", p.arn));
}
lines.push(String::new());
lines.push(
"Copy an ARN and run `:upgrade <ARN>` to migrate. esc / q to close".into(),
);
lines.join("\n")
}
Err(e) => format!("upgrade list failed: {e}\n\nesc / q to close"),
};
let _ = tx.send(AppMsg::TextOverlay {
gen,
title: format!("compatible platforms — {env_for_msg}"),
body,
});
});
}
fn queue_action_dispatch(&mut self, modal: ConfirmModal) {
if self.pending_dispatch.is_some() {
self.error_message = Some(
"another action is mid-dispatch — wait for it to land or press U to undo".into(),
);
return;
}
let label = modal.action.label().to_string();
let target = modal.target_env.clone();
let deadline = Instant::now() + UNDO_WINDOW;
self.pending_dispatch = Some(PendingDispatch {
deadline,
label: label.clone(),
target: target.clone(),
kind: PendingDispatchKind::Single { modal },
});
self.status_message = Some(format!(
"{} → {} dispatches in {}s — press U to undo",
label,
target,
UNDO_WINDOW.as_secs()
));
}
pub(crate) fn queue_batch_dispatch(
&mut self,
label: String,
target: String,
kind: PendingDispatchKind,
) {
if self.pending_dispatch.is_some() {
self.error_message = Some(
"another dispatch is mid-window — wait for it to land or press U to undo".into(),
);
return;
}
let deadline = Instant::now() + UNDO_WINDOW;
let status = format!(
"{} → {} dispatches in {}s — press U to undo",
label,
target,
UNDO_WINDOW.as_secs()
);
self.pending_dispatch = Some(PendingDispatch {
deadline,
label,
target,
kind,
});
self.status_message = Some(status);
}
fn tick_pending_dispatch(&mut self) {
let now = Instant::now();
let Some(pd) = self.pending_dispatch.as_ref() else {
return;
};
if now < pd.deadline {
return;
}
let kind = pd.kind.clone();
self.pending_dispatch = None;
match kind {
PendingDispatchKind::Single { modal } => self.spawn_action(modal),
PendingDispatchKind::BatchAction { action, env_names } => {
for env in env_names {
self.spawn_batch_action(action, env);
}
}
PendingDispatchKind::BatchDeploy {
env_names,
version_label,
} => {
for env in env_names {
self.spawn_batch_deploy(env, version_label.clone());
}
}
PendingDispatchKind::BatchTag {
envs_with_arns,
key,
value,
} => {
for (env, arn) in envs_with_arns {
self.spawn_batch_tag(env, arn, key.clone(), value.clone());
}
}
PendingDispatchKind::BatchSetOption {
env_names,
namespace,
option_name,
value,
} => {
for env in env_names {
self.spawn_batch_set_option(
env,
namespace.clone(),
option_name.clone(),
value.clone(),
);
}
}
}
}
fn cancel_pending_dispatch(&mut self) {
let Some(pd) = self.pending_dispatch.take() else {
return;
};
let msg = format!("undone — {} → {} not dispatched", pd.label, pd.target);
let action_for_audit = match &pd.kind {
PendingDispatchKind::Single { modal } => format!("{:?}", modal.action),
PendingDispatchKind::BatchAction { action, .. } => format!("Batch{action:?}"),
PendingDispatchKind::BatchDeploy { .. } => "BatchDeploy".into(),
PendingDispatchKind::BatchTag { value, .. } => {
if value.is_some() {
"BatchTag".into()
} else {
"BatchUntag".into()
}
}
PendingDispatchKind::BatchSetOption { .. } => "BatchSetOption".into(),
};
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!(
"stage=undone action={action_for_audit} target={}",
pd.target
),
);
self.status_message = Some(msg);
}
fn spawn_action(&mut self, modal: ConfirmModal) {
if self.is_read_only_for(&modal.target_env) {
let reason = self
.read_only_reason(&modal.target_env)
.unwrap_or_else(|| "read-only mode".into());
self.error_message = Some(format!("{reason} — {} disabled", modal.action.label()));
return;
}
if modal.action == Action::Deploy {
if let Some(env) = self
.environments
.iter()
.find(|e| e.name == modal.target_env)
{
if !env.version_label.is_empty() {
self.deploy_snapshots.insert(
env.name.clone(),
DeploySnapshot {
env_name: env.name.clone(),
previous_version_label: env.version_label.clone(),
taken_at: chrono::Utc::now(),
},
);
}
}
if let Some(secs) = modal.auto_rollback_secs {
let tx = self.msg_tx.clone();
let env_name = modal.target_env.clone();
let gen = self.generation;
let target_label = self
.deploy_snapshots
.get(&modal.target_env)
.map(|s| s.previous_version_label.clone())
.unwrap_or_default();
let armed_at = chrono::Utc::now();
let deadline_at = armed_at + chrono::Duration::seconds(secs as i64);
self.armed_watchdogs.insert(
modal.target_env.clone(),
ArmedWatchdog {
env_name: modal.target_env.clone(),
target_label,
armed_at,
deadline_at,
},
);
self.status_message = Some(format!(
"auto-rollback armed: {secs}s to reach Green or revert"
));
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
let _ = tx.send(AppMsg::AutoRollbackCheck { gen, env_name });
});
}
if let Some(secs) = modal.wait_for_green_secs {
let target_label = modal.deploy_version.clone().unwrap_or_default();
let armed_at = chrono::Utc::now();
let deadline_at = armed_at + chrono::Duration::seconds(secs as i64);
self.watching_deploys.insert(
modal.target_env.clone(),
WatchingDeploy {
env_name: modal.target_env.clone(),
target_label,
armed_at,
deadline_at,
},
);
if let Some(existing) = self.status_message.as_mut() {
existing.push_str(&format!("; watching for Green ({secs}s)"));
} else {
self.status_message = Some(format!(
"watching deploy: {secs}s to reach Green or report timeout"
));
}
}
}
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let action = modal.action;
let env = modal.target_env.clone();
let swap_with = modal.swap_with.clone();
let deploy_version = modal.deploy_version.clone();
let upgrade_arn = modal.upgrade_platform_arn.clone();
let clone_target = modal.clone_target.clone();
let scale_min = modal.scale_min;
let scale_max = modal.scale_max;
write_audit_entry(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
action,
&env,
swap_with.as_deref(),
);
self.push_pending(action.label(), env.clone());
tokio::spawn(async move {
let result = match action {
Action::Rebuild => aws.rebuild_env(&env).await,
Action::RestartAppServer => aws.restart_app_server(&env).await,
Action::Terminate => aws.terminate_env(&env).await,
Action::SwapCnames => match swap_with {
Some(dest) => aws.swap_cnames(&env, &dest).await,
None => Err(color_eyre::eyre::eyre!("swap target missing")),
},
Action::Deploy => match deploy_version {
Some(ver) => aws.deploy_version(&env, &ver).await,
None => Err(color_eyre::eyre::eyre!("deploy version missing")),
},
Action::UpgradePlatform => match upgrade_arn {
Some(arn) => aws.upgrade_platform(&env, &arn).await,
None => Err(color_eyre::eyre::eyre!("upgrade platform ARN missing")),
},
Action::Clone => match clone_target {
Some(target) => aws.clone_env(&env, &target).await,
None => Err(color_eyre::eyre::eyre!("clone target name missing")),
},
Action::Scale => match (scale_min, scale_max) {
(Some(mn), Some(mx)) => aws.scale_env(&env, mn, mx).await,
_ => Err(color_eyre::eyre::eyre!("scale min/max missing")),
},
Action::AbortUpdate => aws.abort_environment_update(&env).await,
Action::Capacity
| Action::ConfigSave
| Action::ConfigDelete
| Action::ConfigApply
| Action::TerminateInstance => Err(color_eyre::eyre::eyre!(
"internal: {} dispatched through spawn_action path",
action.label()
)),
}
.map_err(|e| flatten_err("action", e));
let _ = tx.send(AppMsg::ActionResult {
gen,
action,
env_name: env,
result,
});
});
}
fn spawn_detail_instances(&mut self, env_name: String) {
if let Some(d) = self.detail.as_mut() {
d.loading_instances = true;
d.error = None;
}
if self.demo_mode {
let result = Ok(crate::demo_fixture::instances_for(&env_name));
let gen = self.generation;
let _ = self.msg_tx.send(AppMsg::DetailInstances {
gen,
env_name,
result,
});
return;
}
let env_for_msg = env_name.clone();
self.spawn_aws(
"list_instances",
move |aws| async move { aws.list_instances(&env_name).await },
move |gen, result| AppMsg::DetailInstances {
gen,
env_name: env_for_msg,
result,
},
);
}
fn execute_command(&mut self, raw: &str) {
let line = raw.trim();
if line.is_empty() {
return;
}
let expanded = expand_command_alias(line, &self.command_aliases);
let line = expanded.as_str();
let mut parts = line.split_whitespace();
let Some(cmd) = parts.next() else { return };
let rest: Vec<&str> = parts.collect();
match cmd {
"q" | "quit" => self.quit = true,
"refresh" => self.manual_refresh(),
"help" | "?" => {
self.help.topic = if self.detail.is_some() {
HelpTopic::Detail
} else if self.action_flow.is_some() {
HelpTopic::Action
} else if self.dlq.is_some() {
HelpTopic::Dlq
} else if matches!(
self.current_overlay,
Some(Overlay::SavedConfigsInteractive { .. })
) {
HelpTopic::SavedConfigs
} else {
HelpTopic::Global
};
self.help.pre_mode = Some(self.mode);
self.mode = Mode::Help;
}
"region" | "r" => self.cmd_region(&rest),
"custom-platforms" | "platforms" => self.cmd_custom_platforms(),
"accounts" => self.cmd_accounts(),
"org-health" => self.cmd_org_health(),
"find-env" => match rest.first().copied() {
None => {
self.error_message = Some(
"usage: :find-env <name-substring> (scans every AWS profile + AssumeRole account)"
.into(),
);
}
Some(needle) => self.cmd_find_env(needle),
},
"envs-by-version" => match rest.first().copied() {
None => {
self.error_message = Some(
"usage: :envs-by-version <label> (scans every AWS profile + AssumeRole account for envs running that exact version label)"
.into(),
);
}
Some(label) => self.cmd_envs_by_version(label),
},
"logs-insights" => {
let args = rest.join(" ");
self.cmd_logs_insights(&args);
}
"account" => self.cmd_account(&rest),
"profile" | "p" => self.cmd_profile(&rest),
"sort" => self.cmd_sort(&rest),
"group" => self.cmd_group(&rest),
"redact" => self.cmd_redact(&rest),
"events" => {
self.event_panel.visible =
parse_toggle(rest.first().copied(), self.event_panel.visible);
if self.event_panel.visible && self.event_panel.events.is_empty() {
self.spawn_events();
}
self.status_message = Some(if self.event_panel.visible {
"events panel ON".into()
} else {
"events panel off".into()
});
}
"event-time" => self.cmd_event_time(&rest),
"export" => self.export_tsv(),
"json" => self.export_json(),
"report" | "markdown" => self.export_markdown(),
"readonly" => {
self.read_only = parse_toggle(rest.first().copied(), self.read_only);
self.status_message = Some(if self.read_only {
"read-only ON — destructive actions disabled".into()
} else {
"read-only off".into()
});
}
"pin" => self.toggle_pin_selected(),
"alias" => match rest.first().copied() {
Some(name) => {
let label = rest[1..].join(" ");
if label.is_empty() {
self.error_message = Some(
"usage: :alias <env-name> <label> (label cannot be empty)".to_string(),
);
} else {
self.aliases.insert(name.to_string(), label.clone());
self.status_message = Some(format!("alias '{name}' → \"{label}\""));
self.persist_state();
}
}
None => {
if self.aliases.is_empty() {
self.status_message = Some("no aliases set".into());
} else {
let list: Vec<String> = self
.aliases
.iter()
.map(|(k, v)| format!("{k} → \"{v}\""))
.collect();
self.status_message = Some(format!("aliases: {}", list.join(" ")));
}
}
},
"alias-drop" | "alias-rm" => match rest.first() {
Some(name) => {
if self.aliases.remove(*name).is_some() {
self.status_message = Some(format!("alias '{name}' removed"));
self.persist_state();
} else {
self.error_message = Some(format!("no alias for '{name}'"));
}
}
None => self.error_message = Some("usage: :alias-drop <env-name>".into()),
},
"whatsnew" => self.open_whatsnew(),
"about" | "credits" => self.open_about_overlay(),
"apps-info" => self.open_apps_info_overlay(),
"cost" => self.cmd_cost(&rest),
"listeners" => self.cmd_listeners(),
"listener-edit" => self.cmd_listener_edit(&rest),
"rds" => self.cmd_rds(),
"rds-attach" => self.cmd_rds_attach(),
"rds-detach" => self.cmd_rds_detach(&rest),
"options" => self.cmd_options(&rest),
"config-diff" => self.cmd_config_diff(&rest),
"config-diff-local" => self.cmd_config_diff_local(&rest),
"explain" => self.cmd_explain(&rest),
"env-edit" => self.cmd_env_edit(),
"secrets" => self.cmd_secrets(&rest),
"secret" => self.cmd_secret_view(&rest),
"report-bug" => self.open_report_bug_overlay(),
"settings" => {
self.open_settings_form();
}
"capacity" => self.cmd_capacity(),
"scaling-triggers" => self.cmd_scaling_triggers(),
"subnets" => self.open_subnets_form(),
"elb-subnets" => self.open_elb_subnets_form(),
"security-groups" => self.open_security_groups_form(),
"update" => {
let channel = crate::update_check::detect_install_channel();
let cmd = channel.upgrade_command();
let current = env!("CARGO_PKG_VERSION");
let msg = match self.update_available.as_ref() {
Some(release) => format!(
"update available: {current} → {}. run: {cmd}",
release.version
),
None => {
format!("already on the latest ({current}). to force-reinstall: {cmd}")
}
};
if let Ok(mut cb) = arboard::Clipboard::new() {
let _ = cb.set_text(cmd.to_string());
}
self.pin_status(msg);
}
"history" => {
self.current_overlay = Some(Overlay::History(self.format_message_log()));
}
"saved-configs" | "configs" => {
let items = collect_saved_configs(&self.applications);
if items.is_empty() {
self.current_overlay = Some(Overlay::SavedConfigs(format_saved_configs(
&self.applications,
)));
} else {
self.current_overlay = Some(Overlay::SavedConfigsInteractive {
items,
cursor: 0,
confirm_delete: false,
});
}
}
"plugins" => {
if self.plugins.is_empty() {
self.status_message =
Some("no plugins — add ~/.config/ebman/commands.toml".into());
} else {
let names: Vec<&str> = self.plugins.keys().map(String::as_str).collect();
self.status_message = Some(format!(":<plugin> {}", names.join(", ")));
}
}
"diff" => match (rest.first(), rest.get(1)) {
(None, _) => {
self.error_message = Some(
"usage: :diff ENV (selected ↔ ENV) | :diff ENV-A ENV-B (name both)"
.into(),
);
}
(Some(a), Some(b)) => {
if a == b {
self.error_message = Some("pick two different envs to compare".into());
return;
}
let Some(left) = self.environments.iter().find(|e| e.name == **a).cloned()
else {
self.error_message = Some(format!("no env named '{a}' in current view"));
return;
};
let Some(right) = self.environments.iter().find(|e| e.name == **b).cloned()
else {
self.error_message = Some(format!("no env named '{b}' in current view"));
return;
};
self.current_overlay =
Some(Overlay::Diff(diff_envs(&left, &right, self.redact)));
}
(Some(target), None) => {
let left_opt = if let Some(d) = self.detail.as_ref() {
Some(d.env_snapshot.clone())
} else {
self.selected_env().cloned()
};
let Some(left) = left_opt else {
self.error_message = Some("no env selected".into());
return;
};
if left.name == **target {
self.error_message = Some("pick a different env to compare against".into());
return;
}
let right = self
.environments
.iter()
.find(|e| e.name == **target)
.cloned();
match right {
None => {
self.error_message =
Some(format!("no env named '{target}' in current view"));
}
Some(right) => {
self.current_overlay =
Some(Overlay::Diff(diff_envs(&left, &right, self.redact)));
}
}
}
},
"alarms" => {
let env_opt = if let Some(d) = self.detail.as_ref() {
Some(d.env_name.clone())
} else {
self.selected_env().map(|e| e.name.clone())
};
match env_opt {
Some(env_name) => self.spawn_alarms_fetch(env_name),
None => self.error_message = Some("no env selected".into()),
}
}
"why" | "diagnose" => {
let env_opt = if let Some(d) = self.detail.as_ref() {
Some((d.env_name.clone(), d.env_snapshot.application.clone()))
} else {
self.selected_env()
.map(|e| (e.name.clone(), e.application.clone()))
};
match env_opt {
Some((env_name, app_name)) => self.open_why_red(env_name, app_name),
None => self.error_message = Some("no env selected".into()),
}
}
"loglevel" => match rest.first() {
None => {
self.status_message =
Some(format!("current log directive: {}", self.log_directive));
}
Some(level) => {
self.set_log_level(level);
}
},
"cols" => self.cmd_cols(&rest),
"save-view" => self.cmd_save_view(&rest),
"view" => self.cmd_view(&rest),
"views" => self.cmd_views(),
"view-drop" => self.cmd_view_drop(&rest),
"filter" | "f" => self.cmd_filter_load(&rest),
"save" => self.cmd_save_filter(&rest),
"drop" => self.cmd_drop_filter(&rest),
"filters" => self.cmd_filters(),
"batch-rebuild" => self.cmd_batch_action(Action::Rebuild),
"batch-restart" => self.cmd_batch_action(Action::RestartAppServer),
"batch-deploy" => self.cmd_batch_deploy(&rest),
"batch-tag" => self.cmd_batch_tag_or_untag(true, &rest),
"batch-untag" => self.cmd_batch_tag_or_untag(false, &rest),
"batch-set-option" => self.cmd_batch_set_option(&rest),
"versions" => self.cmd_versions(),
"deploy" => self.cmd_deploy(&rest),
"rollback" => self.cmd_rollback(&rest),
"changes" => self.cmd_changes(),
"lineage" => self.cmd_lineage(),
"ssh" => self.cmd_ssh(&rest),
"ssm-run" => self.cmd_ssm_run(&rest),
"delete-version" => self.cmd_delete_version(&rest),
"upgrade" => self.cmd_upgrade(&rest),
"clone" => self.cmd_clone(&rest),
"promote-env" => self.cmd_promote_env(&rest),
"scale" => self.cmd_scale(&rest),
"stop" => self.cmd_stop(),
"start" => self.cmd_start(),
"abort" => self.cmd_abort(),
"pending" | "in-flight" | "inflight" => self.cmd_pending(),
"rollbacks-armed" | "rb-armed" => self.cmd_rollbacks_armed(),
"abort-rollback" => self.cmd_abort_rollback(&rest),
"freeze-deploys" => self.cmd_freeze_deploys(&rest),
"thaw-deploys" => self.cmd_thaw_deploys(),
"undo" => self.cmd_undo(),
"lint" => self.cmd_lint(&rest),
"drift" => self.cmd_drift(&rest),
"tag" => self.cmd_tag(&rest),
"untag" => self.cmd_untag(&rest),
"resources" | "res" => self.cmd_resources(),
"rebuild" => self.cmd_rebuild(),
"restart" => self.cmd_restart(),
"terminate" => self.cmd_terminate(),
"swap" => self.cmd_swap(&rest),
"config-save" => self.cmd_config_save(&rest),
"config-delete" => self.cmd_config_delete(&rest),
"config-apply" => self.cmd_config_apply(&rest),
"deployment-policy" => self.cmd_deployment_policy(&rest),
"rolling-update" => self.cmd_rolling_update(&rest),
"health-check-url" => self.cmd_health_check_url(&rest),
"keypair" => self.cmd_keypair(&rest),
"service-role" => self.cmd_service_role(&rest),
"instance-profile" => self.cmd_instance_profile(&rest),
"public-ip" => self.cmd_public_ip(&rest),
"elb-scheme" => self.cmd_elb_scheme(&rest),
"set-option" => self.cmd_set_option(&rest),
"unset-option" => self.cmd_unset_option(&rest),
"instance-type" => self.cmd_instance_type(&rest),
"custom-platform-delete" => self.cmd_custom_platform_delete(&rest),
"env" => self.cmd_env(&rest),
"metric" => self.cmd_metric(&rest),
"logs-tail" => {
let Some(env) = self.selected_env().cloned() else {
self.error_message = Some("no env selected".into());
return;
};
let explicit_group = rest.first().map(|s| s.to_string());
self.spawn_logs_tail(env.name.clone(), explicit_group);
}
"logs-stream" => self.cmd_logs_stream(&rest),
"notify" => self.cmd_notify(&rest),
"managed-window" => self.cmd_managed_window(&rest),
"alarm-create" => self.cmd_alarm_create(&rest),
"alarm-delete" => self.cmd_alarm_delete(&rest),
"alarm-history" => self.cmd_alarm_history(&rest),
"config-inspect" => self.cmd_config_inspect(&rest),
"deselect" | "select-clear" => {
let n = self.multi_selected.len();
self.multi_selected.clear();
self.status_message = Some(format!("cleared {n} env selection(s)"));
}
other => {
if let Some(plugin) = self.plugins.get(other).cloned() {
self.run_plugin_command(other, &plugin);
return;
}
let suggestion = suggest_command(other);
let msg = match suggestion {
Some(name) => {
format!("unknown command: :{other} — did you mean :{name}? (try :help)")
}
None => format!("unknown command: :{other} (try :help)"),
};
self.error_message = Some(msg);
}
}
}
fn run_plugin_command(&mut self, name: &str, plugin: &crate::plugins::Plugin) {
let env_opt = if let Some(d) = self.detail.as_ref() {
Some(d.env_snapshot.clone())
} else {
self.selected_env().cloned()
};
let Some(env) = env_opt else {
self.error_message = Some(format!(":{name} — no env selected"));
return;
};
let rendered = crate::plugins::render(
&plugin.template,
&env.name,
&env.cname,
&env.application,
&env.tier,
&self.context.region,
self.override_profile
.as_deref()
.or(self.context.profile.as_deref()),
);
match yank(&rendered) {
Ok(()) => {
self.status_message = Some(format!(
"plugin :{name} → clipboard ({} chars)",
rendered.chars().count()
));
}
Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
}
}
fn open_profile_picker(&mut self) {
let items = profiles::load_profiles();
let current = self.context.profile.as_deref();
self.picker = Some(Picker::new(PickerKind::Profile, items, current));
self.mode = Mode::Picker;
}
fn open_region_picker(&mut self) {
let mut items: Vec<String> = profiles::REGIONS.iter().map(|s| (*s).to_string()).collect();
for r in &self.extra_regions {
if !items.iter().any(|i| i == r) {
items.push(r.clone());
}
}
let current = Some(self.context.region.as_str());
self.picker = Some(Picker::new(PickerKind::Region, items, current));
self.mode = Mode::Picker;
}
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.filter.is_empty() {
None
} else {
Some(self.filter.clone())
},
sort: Some(format!(
"{}:{}",
self.sort_key.label(),
if self.sort_desc { "desc" } else { "asc" }
)),
grouped: Some(self.grouped),
redact: Some(self.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.hidden_cols.clone(),
custom_metrics: self.custom_metrics.clone(),
});
}
fn resort_envs(&mut self) {
let key = self.sort_key;
let desc = self.sort_desc;
let pinned = self.pinned.clone();
self.environments.sort_by(|a, b| {
let a_pin = pinned.contains(&a.name);
let b_pin = pinned.contains(&b.name);
if a_pin != b_pin {
return if a_pin {
std::cmp::Ordering::Less
} else {
std::cmp::Ordering::Greater
};
}
let ord = match key {
SortKey::App => a
.application
.to_lowercase()
.cmp(&b.application.to_lowercase())
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase())),
SortKey::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
SortKey::Status => a
.status
.to_lowercase()
.cmp(&b.status.to_lowercase())
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase())),
SortKey::Health => health_rank(&a.health)
.cmp(&health_rank(&b.health))
.then(a.name.to_lowercase().cmp(&b.name.to_lowercase())),
SortKey::Age => a.updated.cmp(&b.updated),
SortKey::Version => a
.version_label
.to_lowercase()
.cmp(&b.version_label.to_lowercase()),
};
if desc {
ord.reverse()
} else {
ord
}
});
self.rebuild_view();
}
fn yank_selected(&mut self, kind: YankKind) {
let Some(env) = self.selected_env() else {
self.status_message = Some("nothing to yank".into());
return;
};
let value = match kind {
YankKind::Cname => env.cname.clone(),
YankKind::Name => env.name.clone(),
};
if value.is_empty() {
self.status_message = Some("selected env has no value to yank".into());
return;
}
match yank(&value) {
Ok(()) => {
self.status_message = Some(format!(
"copied {} to clipboard",
match kind {
YankKind::Cname => "CNAME",
YankKind::Name => "name",
}
));
}
Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
}
}
fn export_tsv(&mut self) {
let count = self.cached_filtered.len();
let mut out = String::new();
out.push_str(
"NAME\tAPPLICATION\tTIER\tSTATUS\tHEALTH\tPLATFORM\tVERSION\tCNAME\tUPDATED\n",
);
for &i in &self.cached_filtered {
let e = &self.environments[i];
let cname = if self.redact {
redact_block(&e.cname)
} else {
e.cname.clone()
};
let updated = e.updated.map(|u| u.to_rfc3339()).unwrap_or_default();
out.push_str(&format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
e.name,
e.application,
e.tier,
e.status,
e.health,
e.platform,
e.version_label,
cname,
updated
));
}
match yank(&out) {
Ok(()) => {
self.status_message = Some(format!("exported {count} rows (TSV) to clipboard"));
}
Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
}
}
pub(crate) fn refresh_tf_managed_envs(&mut self) {
self.tf_managed_envs = self
.tf_state
.as_ref()
.map(|s| s.managed_names())
.unwrap_or_default();
}
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 apply_picker_choice(&mut self, kind: PickerKind, value: String) {
match kind {
PickerKind::Profile => {
tracing::info!(
target: "ebman::state",
new_profile = %value,
cleared_override_region = ?self.override_region,
"apply_picker_choice(Profile) clears override_region so SDK re-resolves from new profile config"
);
self.override_profile = Some(value.clone());
self.override_region = None;
self.status_message = Some(format!("switching to profile {value}…"));
self.spawn_rebuild();
}
PickerKind::Region => {
tracing::info!(
target: "ebman::state",
new_region = %value,
prior_override = ?self.override_region,
"apply_picker_choice(Region) sets override_region"
);
self.override_region = Some(value.clone());
self.status_message = Some(format!("switching to region {value}…"));
self.spawn_rebuild();
}
PickerKind::LogGroup => {
let env = match self.current_overlay.as_ref() {
Some(Overlay::LogTail { env_name, .. }) => env_name.clone(),
_ => return,
};
self.spawn_logs_tail(env, Some(value));
}
PickerKind::SshInstance => {
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!(
"stage=dispatched action=SsmSession target={value} via=cmd_ssh_picker"
),
);
self.pending_shell_target = Some(value.clone());
self.status_message = Some(format!("opening SSM session to {value}…"));
}
}
}
fn spawn_rebuild(&mut self) {
self.load_state = LoadState::Loading;
self.loading_since = Some(Instant::now());
let profile = self.override_profile.clone();
let region = self.override_region.clone();
let tx = self.msg_tx.clone();
tokio::spawn(async move {
let result = match AwsClient::with(profile, region).await {
Ok(c) => Ok(Box::new(c)),
Err(e) => Err(flatten_err("aws_client_with", e)),
};
let _ = tx.send(AppMsg::Rebuild(result));
});
}
fn spawn_assume_role_switch(&mut self, account_name: String) {
let Some(spec) = self.accounts.get(&account_name).cloned() else {
self.error_message = Some(format!(
"no `accounts.{account_name}` in config.toml — add `accounts.{account_name}.role_arn = …`"
));
return;
};
self.load_state = LoadState::Loading;
self.loading_since = Some(Instant::now());
self.status_message = Some(format!("assuming role for account '{account_name}'…"));
let tx = self.msg_tx.clone();
tokio::spawn(async move {
let result = match AwsClient::assume_role(&account_name, &spec).await {
Ok(c) => Ok(Box::new(c)),
Err(e) => Err(flatten_err("aws_client_assume_role", e)),
};
let _ = tx.send(AppMsg::Rebuild(result));
});
}
fn spawn_identity(&mut self) {
self.spawn_aws(
"verify_identity",
move |aws| async move { aws.verify_identity().await },
|gen, result| AppMsg::Identity { gen, result },
);
}
fn spawn_update_check(&mut self) {
if self.demo_mode {
return;
}
let tx = self.msg_tx.clone();
tokio::spawn(async move {
let result = crate::update_check::check_async().await;
let _ = tx.send(AppMsg::UpdateCheck(result));
});
}
fn arm_loading_linger(&mut self) {
let now = Instant::now();
if let Some(until) = compute_loading_linger_target(
self.loading_since,
LOADING_INDICATOR_THRESHOLD,
LOADING_INDICATOR_LINGER,
now,
) {
self.loading_visible_until = Some(until);
}
}
fn spawn_refresh(&mut self) {
if self.demo_mode {
return;
}
if matches!(self.load_state, LoadState::Loading) {
return;
}
self.load_state = LoadState::Loading;
self.loading_since = Some(Instant::now());
self.status_snapshot_at_refresh =
Some((self.status_message.clone(), self.error_message.clone()));
let tx = self.msg_tx.clone();
let gen = self.generation;
if self.multi_regions.is_empty() {
let aws = self.aws.clone();
tokio::spawn(async move {
let result = aws
.list_environments()
.await
.map_err(|e| flatten_err("list_environments", e));
let _ = tx.send(AppMsg::Refresh { gen, result });
});
} else {
let regions = self.multi_regions.clone();
let profile = self
.override_profile
.clone()
.or_else(|| self.context.profile.clone());
tokio::spawn(async move {
use futures::future::join_all;
let tasks = regions.into_iter().map(|r| {
let p = profile.clone();
async move { crate::aws::list_environments_in_region(p, r).await }
});
let results = join_all(tasks).await;
let mut envs = Vec::new();
let mut errs = Vec::new();
for r in results {
match r {
Ok(v) => envs.extend(v),
Err(e) => errs.push(format!("{e}")),
}
}
let result = if envs.is_empty() && !errs.is_empty() {
Err(errs.join("; "))
} else {
Ok(envs)
};
let _ = tx.send(AppMsg::Refresh { gen, result });
});
}
if self.event_panel.visible {
self.spawn_events();
}
self.spawn_applications();
if self.latest_stacks.is_empty() {
self.spawn_solution_stacks();
}
}
fn spawn_solution_stacks(&self) {
self.spawn_aws(
"list_solution_stacks",
move |aws| async move { aws.list_solution_stacks().await },
|gen, result| AppMsg::SolutionStacks { gen, result },
);
}
fn spawn_applications(&self) {
self.spawn_aws(
"list_applications",
move |aws| async move { aws.list_applications().await },
|gen, result| AppMsg::Applications { gen, result },
);
}
fn set_scope(&mut self, new: Scope) {
let changed = self.scope != new;
self.scope = new;
if changed && new == Scope::Apps {
self.spawn_app_latest_versions();
}
}
fn spawn_app_latest_versions(&self) {
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let names: Vec<String> = self.applications.iter().map(|a| a.name.clone()).collect();
if names.is_empty() {
return;
}
tokio::spawn(async move {
use futures::future::join_all;
let futs = names.into_iter().map(|name| {
let aws = aws.clone();
async move {
let res = aws.list_application_versions(&name).await;
let head = res.ok().and_then(|mut v| v.drain(..).next());
(
name,
head.as_ref().map(|h| h.label.clone()),
head.and_then(|h| h.created),
)
}
});
let results: Vec<(
String,
Option<String>,
Option<chrono::DateTime<chrono::Utc>>,
)> = join_all(futs).await;
let _ = tx.send(AppMsg::AppLatestVersions { gen, results });
});
}
fn spawn_worker_queue_check(&self) {
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let workers: Vec<(String, String)> = self
.environments
.iter()
.filter(|e| e.tier.eq_ignore_ascii_case("Worker"))
.map(|e| (e.name.clone(), e.application.clone()))
.collect();
if workers.is_empty() {
return;
}
tokio::spawn(async move {
use futures::future::join_all;
let futs = workers.into_iter().map(|(env, app)| {
let aws = aws.clone();
async move {
aws.describe_worker_queues(&app, &env)
.await
.ok()
.and_then(|q| q.dlq_stats.map(|s| (env, s.visible)))
}
});
let results: Vec<(String, i64)> = join_all(futs).await.into_iter().flatten().collect();
let _ = tx.send(AppMsg::WorkerQueueCheck { gen, results });
});
}
fn spawn_env_instance_counts(&self) {
let aws = self.aws.clone();
let tx = self.msg_tx.clone();
let gen = self.generation;
let targets: Vec<String> = self
.environments
.iter()
.filter(|e| {
!matches!(
e.status.as_str(),
"Terminated" | "Terminating" | "Launching"
)
})
.map(|e| e.name.clone())
.collect();
if targets.is_empty() {
return;
}
tokio::spawn(async move {
use futures::future::join_all;
let futs = targets.into_iter().map(|env| {
let aws = aws.clone();
async move {
aws.fetch_env_instance_counts(&env)
.await
.ok()
.map(|counts| (env, counts))
}
});
let results: Vec<(String, crate::aws::EnvInstanceCounts)> =
join_all(futs).await.into_iter().flatten().collect();
let _ = tx.send(AppMsg::EnvInstanceCountsCheck { gen, results });
});
}
fn spawn_events(&mut self) {
let selected = self.selected_env().map(|e| e.name.clone());
self.event_panel.for_env = selected.clone();
self.spawn_aws(
"list_events",
move |aws| async move {
match selected {
Some(name) => aws.list_events_for_env(&name, 50).await,
None => aws.list_events(50).await,
}
},
|gen, result| AppMsg::Events { gen, result },
);
}
fn refresh_events_if_selection_changed(&mut self) {
if !self.event_panel.visible {
return;
}
let selected = self.selected_env().map(|e| e.name.clone());
if selected != self.event_panel.for_env {
self.spawn_events();
}
}
fn apply_detail_msg<T, F>(&mut self, env_name: &str, result: Result<T, String>, apply: F)
where
F: FnOnce(&mut DetailState, Result<T, String>),
{
let Some(detail) = self.detail.as_mut() else {
return;
};
if detail.env_name != env_name {
return;
}
apply(detail, result);
}
fn apply_rebuild(&mut self, result: Result<Box<AwsClient>, String>) {
match result {
Ok(client) => {
self.generation = self.generation.wrapping_add(1);
self.context = client.context.clone();
self.aws = Arc::new(*client);
self.maybe_apply_profile_theme();
self.environments.clear();
self.event_panel.events.clear();
self.event_panel.scroll = 0;
self.history.clear();
self.latest_stacks.clear();
self.current_overlay = None;
if let Some(handle) = self.log_tail_task.take() {
handle.abort();
}
self.log_tail_session = self.log_tail_session.wrapping_add(1);
self.throttle_until = None;
self.consecutive_throttles = 0;
self.prev_health.clear();
self.prev_status.clear();
self.prev_alerts = 0;
self.newly_red.clear();
self.newly_added.clear();
self.health_delta.clear();
self.status_delta.clear();
self.armed_watchdogs.clear();
self.watching_deploys.clear();
self.deploy_snapshots.clear();
self.undo_history.clear();
self.tf_state = crate::terraform::load_from_cwd();
self.refresh_tf_managed_envs();
self.rebuild_view();
self.table_state.select(None);
self.status_message = Some(format!(
"context: {} / {}",
self.context.profile.as_deref().unwrap_or("default"),
self.context.region
));
self.error_message = None;
self.arm_loading_linger();
self.load_state = LoadState::Idle;
self.persist_state();
self.spawn_identity();
self.spawn_refresh();
}
Err(msg) => {
tracing::error!(error = %msg, "rebuild failed");
self.arm_loading_linger();
self.load_state = LoadState::Error;
self.loading_since = None;
self.error_message = Some(self.format_aws_error("context switch", &msg));
}
}
}
fn move_scope_selection(&mut self, delta: i32) {
match self.scope {
Scope::Envs => self.move_selection(delta),
Scope::Apps => {
let n = self.applications.len();
if n == 0 {
self.app_table_state.select(None);
return;
}
let cur = self.app_table_state.selected().unwrap_or(0) as i32;
let next = (cur + delta).rem_euclid(n as i32) as usize;
self.app_table_state.select(Some(next));
}
}
}
fn scope_select_first(&mut self) {
match self.scope {
Scope::Envs => self.select_first(),
Scope::Apps => {
if !self.applications.is_empty() {
self.app_table_state.select(Some(0));
}
}
}
}
fn scope_select_last(&mut self) {
match self.scope {
Scope::Envs => self.select_last(),
Scope::Apps => {
if !self.applications.is_empty() {
self.app_table_state
.select(Some(self.applications.len() - 1));
}
}
}
}
pub(crate) fn open_apps_action_menu(&mut self) {
let Some(idx) = self.app_table_state.selected() else {
return;
};
let Some(app_name) = self.applications.get(idx).map(|a| a.name.clone()) else {
return;
};
let env_names: Vec<String> = self
.environments
.iter()
.filter(|e| e.application == app_name)
.map(|e| e.name.clone())
.collect();
if env_names.is_empty() {
self.status_message = Some(format!(
"application '{app_name}' has no envs — nothing to act on"
));
return;
}
self.current_overlay = Some(Overlay::AppsActionMenu {
app_name,
env_names,
cursor: 0,
});
}
fn handle_apps_action_menu_key(&mut self, key: KeyEvent) {
let n_items = APPS_ACTION_ITEMS.len() as i32;
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
self.current_overlay = None;
}
KeyCode::Down | KeyCode::Char('j') => {
if let Some(Overlay::AppsActionMenu { cursor, .. }) = self.current_overlay.as_mut()
{
let cur = *cursor as i32;
*cursor = (cur + 1).rem_euclid(n_items) as usize;
}
}
KeyCode::Up | KeyCode::Char('k') => {
if let Some(Overlay::AppsActionMenu { cursor, .. }) = self.current_overlay.as_mut()
{
let cur = *cursor as i32;
*cursor = (cur - 1).rem_euclid(n_items) as usize;
}
}
KeyCode::Enter => self.dispatch_apps_action_menu(),
_ => {}
}
}
fn dispatch_apps_action_menu(&mut self) {
let Some(Overlay::AppsActionMenu {
app_name,
env_names,
cursor,
}) = self.current_overlay.as_ref().cloned()
else {
return;
};
self.current_overlay = None;
let item = match APPS_ACTION_ITEMS.get(cursor) {
Some(it) => *it,
None => return,
};
match item {
AppsActionItem::Drill => {
self.filter = app_name.clone();
self.set_scope(Scope::Envs);
self.rebuild_view();
self.status_message = Some(format!("filtered envs to application '{app_name}'"));
}
AppsActionItem::BatchRebuild => {
self.multi_selected = env_names.into_iter().collect();
self.cmd_batch_action(Action::Rebuild);
}
AppsActionItem::BatchRestart => {
self.multi_selected = env_names.into_iter().collect();
self.cmd_batch_action(Action::RestartAppServer);
}
AppsActionItem::BatchDeploy => {
self.multi_selected = env_names.into_iter().collect();
self.mode = Mode::Command;
self.command_input = "batch-deploy ".into();
self.status_message = Some("type a version label and press enter".into());
}
AppsActionItem::OpenInConsole => {
self.open_app_in_console();
}
}
}
pub(crate) fn open_app_in_console(&mut self) {
let Some(idx) = self.app_table_state.selected() else {
self.status_message = Some("no application selected".into());
return;
};
let Some(name) = self.applications.get(idx).map(|a| a.name.clone()) else {
return;
};
let region = &self.context.region;
let app_enc = urlencode(&name);
let url = format!(
"https://{region}.console.aws.amazon.com/elasticbeanstalk/home?region={region}#/application/overview?applicationName={app_enc}"
);
match open_url(&url) {
Ok(()) => {
self.status_message = Some(format!("opened {name} in browser"));
}
Err(e) => {
self.error_message = Some(format!("couldn't open browser: {e}"));
}
}
}
fn drill_into_app(&mut self) {
let Some(idx) = self.app_table_state.selected() else {
return;
};
let Some(name) = self.applications.get(idx).map(|a| a.name.clone()) else {
return;
};
self.filter = name.clone();
self.set_scope(Scope::Envs);
self.rebuild_view();
self.status_message = Some(format!("filtered envs to application '{name}'"));
}
fn select_first(&mut self) {
let rows = self.display_rows();
if let Some(pos) = rows.iter().position(|r| matches!(r, DisplayRow::Env(_))) {
self.table_state.select(Some(pos));
}
}
fn select_last(&mut self) {
let rows = self.display_rows();
if let Some(pos) = rows.iter().rposition(|r| matches!(r, DisplayRow::Env(_))) {
self.table_state.select(Some(pos));
}
}
fn move_selection(&mut self, delta: i32) {
let rows = self.display_rows();
if rows.is_empty() {
self.table_state.select(None);
return;
}
let selectable: Vec<usize> = rows
.iter()
.enumerate()
.filter_map(|(i, r)| matches!(r, DisplayRow::Env(_)).then_some(i))
.collect();
if selectable.is_empty() {
self.table_state.select(None);
return;
}
let current = self.table_state.selected().unwrap_or(selectable[0]);
let pos_in_selectable = selectable.iter().position(|i| *i == current).unwrap_or(0) as i32;
let next = (pos_in_selectable + delta).rem_euclid(selectable.len() as i32) as usize;
self.table_state.select(Some(selectable[next]));
}
pub fn display_rows(&self) -> &[DisplayRow] {
&self.cached_display
}
pub fn filtered_indexes(&self) -> &[usize] {
&self.cached_filtered
}
pub fn rebuild_view(&mut self) {
self.cached_filtered.clear();
if self.filter.is_empty() {
self.cached_filtered.extend(0..self.environments.len());
} else {
let needle = self.filter.to_lowercase();
for (i, e) in self.environments.iter().enumerate() {
let alias_hit = self
.aliases
.get(&e.name)
.map(|a| a.to_lowercase().contains(&needle))
.unwrap_or(false);
if e.name.to_lowercase().contains(&needle)
|| alias_hit
|| e.application.to_lowercase().contains(&needle)
|| e.health.to_lowercase().contains(&needle)
|| e.status.to_lowercase().contains(&needle)
{
self.cached_filtered.push(i);
}
}
}
self.cached_display.clear();
let mut prev_app: Option<&str> = None;
for i in &self.cached_filtered {
let e = &self.environments[*i];
if self.grouped && prev_app.is_some() && prev_app != Some(e.application.as_str()) {
self.cached_display.push(DisplayRow::Separator);
}
self.cached_display.push(DisplayRow::Env(*i));
prev_app = Some(e.application.as_str());
}
self.cached_app_colors = assign_app_colors(
self.cached_filtered
.iter()
.map(|i| self.environments[*i].application.as_str()),
&self.theme.app_palette,
);
self.cached_stale_platforms.clear();
if !self.latest_stacks.is_empty() {
for e in &self.environments {
if let Some(newer) =
crate::aws::newer_stack_version(&e.solution_stack, &self.latest_stacks)
{
self.cached_stale_platforms.insert(e.name.clone(), newer);
}
}
}
}
fn apply_refresh(&mut self, result: Result<Vec<Environment>, String>) {
match result {
Ok(envs) => {
let is_red =
|h: &str| h.eq_ignore_ascii_case("Red") || h.eq_ignore_ascii_case("Severe");
self.newly_red.clear();
self.newly_added.clear();
if !self.prev_health.is_empty() {
for e in &envs {
if !self.prev_health.contains_key(&e.name) {
self.newly_added.insert(e.name.clone());
}
}
}
for e in &envs {
let prev_red = self
.prev_health
.get(&e.name)
.map(|h| is_red(h))
.unwrap_or(false);
if is_red(&e.health) && !prev_red {
self.newly_red.insert(e.name.clone());
tracing::warn!(
env = %e.name,
application = %e.application,
health = %e.health,
region = %self.context.region,
"env transitioned into Red",
);
write_audit_line(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.context.region,
&format!(
"stage=event kind=red_transition env={} application={} health={}",
e.name, e.application, e.health
),
);
}
}
self.health_delta = bucket_delta(&self.prev_health, &envs, |e| e.health.clone());
self.status_delta = bucket_delta(&self.prev_status, &envs, |e| e.status.clone());
self.prev_health = envs
.iter()
.map(|e| (e.name.clone(), e.health.clone()))
.collect();
self.prev_status = envs
.iter()
.map(|e| (e.name.clone(), e.status.clone()))
.collect();
let new_alerts = compute_red_alerts(&envs, &self.worker_dlq_depths);
if self.notify_bell && new_alerts > self.prev_alerts {
use std::io::Write;
let mut err = std::io::stderr().lock();
let _ = err.write_all(b"\x07");
let _ = err.flush();
}
self.prev_alerts = new_alerts;
self.alerts = new_alerts;
self.environments = envs;
self.resort_envs();
let armed: Vec<(String, chrono::DateTime<chrono::Utc>)> = self
.armed_watchdogs
.iter()
.map(|(env, w)| (env.clone(), w.deadline_at))
.collect();
let now = chrono::Utc::now();
for (env_name, deadline_at) in armed {
let (status, health) = self
.environments
.iter()
.find(|e| e.name == env_name)
.map(|e| (e.status.clone(), e.health.clone()))
.unwrap_or_default();
let healthy = deploy_settled_green(&status, &health);
if healthy {
self.armed_watchdogs.remove(&env_name);
self.pin_status(format!(
"auto-rollback for {env_name}: env reached Green, watchdog disarmed"
));
} else if now >= deadline_at {
self.dispatch_auto_rollback(env_name, health);
}
}
let watching: Vec<(String, chrono::DateTime<chrono::Utc>, String, u64)> = self
.watching_deploys
.iter()
.map(|(env, w)| {
let secs = (w.deadline_at - w.armed_at).num_seconds().max(0) as u64;
(env.clone(), w.deadline_at, w.target_label.clone(), secs)
})
.collect();
for (env_name, deadline_at, target_label, total_secs) in watching {
let (status, health) = self
.environments
.iter()
.find(|e| e.name == env_name)
.map(|e| (e.status.clone(), e.health.clone()))
.unwrap_or_default();
let healthy = deploy_settled_green(&status, &health);
if healthy {
self.watching_deploys.remove(&env_name);
let label_hint = if target_label.is_empty() {
String::new()
} else {
format!(" ({target_label})")
};
self.pin_status(format!("✓ deploy reached Green: {env_name}{label_hint}"));
} else if now >= deadline_at {
self.watching_deploys.remove(&env_name);
let label_hint = if target_label.is_empty() {
String::new()
} else {
format!(" ({target_label})")
};
self.pin_error(format!(
"deploy did not reach Green within {total_secs}s: {env_name}{label_hint} — status={status} health={health}"
));
}
}
let live: HashSet<String> =
self.environments.iter().map(|e| e.name.clone()).collect();
for e in &self.environments {
let buf = self.history.entry(e.name.clone()).or_default();
buf.push_back(e.health.clone());
while buf.len() > HISTORY_CAP {
buf.pop_front();
}
}
self.history.retain(|k, _| live.contains(k));
self.arm_loading_linger();
self.load_state = LoadState::Idle;
self.loading_since = None;
self.last_refresh = Some(chrono::Utc::now());
self.consecutive_throttles = 0;
self.throttle_until = None;
if let Some((prev_status, prev_error)) = self.status_snapshot_at_refresh.take() {
if !self.status_message_pinned && self.status_message == prev_status {
self.status_message = None;
}
if self.error_message == prev_error {
self.error_message = None;
}
} else if !self.status_message_pinned {
self.status_message = None;
self.error_message = None;
}
self.status_message_pinned = false;
self.restore_or_clamp_selection();
self.spawn_worker_queue_check();
self.spawn_env_instance_counts();
}
Err(msg) => {
tracing::error!(error = %msg, "refresh failed");
self.arm_loading_linger();
self.load_state = LoadState::Error;
self.loading_since = None;
self.status_snapshot_at_refresh = None;
if is_throttling_error(&msg) {
let backoff =
throttle_backoff(self.refresh_interval, self.consecutive_throttles);
self.consecutive_throttles = self.consecutive_throttles.saturating_add(1);
self.throttle_until = Some(Instant::now() + backoff);
self.error_message = Some(format!(
"rate-limited by AWS — backing off {}s (^R to force)",
backoff.as_secs().max(1)
));
} else {
self.error_message = Some(self.format_aws_error("refresh", &msg));
}
}
}
}
fn restore_or_clamp_selection(&mut self) {
if self.cached_display.is_empty() {
self.table_state.select(None);
return;
}
let first_env_idx = self
.cached_display
.iter()
.position(|r| matches!(r, DisplayRow::Env(_)))
.unwrap_or(0);
let pending = self.pending_select.take();
if let Some(name) = pending {
let pos = self.cached_display.iter().position(|r| match r {
DisplayRow::Env(i) => self.environments[*i].name == name,
DisplayRow::Separator => false,
});
if let Some(p) = pos {
self.table_state.select(Some(p));
return;
}
}
let valid = self
.table_state
.selected()
.is_some_and(|s| matches!(self.cached_display.get(s), Some(DisplayRow::Env(_))));
if !valid {
self.table_state.select(Some(first_env_idx));
}
}
fn format_aws_error(&self, op: &str, msg: &str) -> String {
let lower = msg.to_lowercase();
let sso_signals = [
"expiredtoken",
"expired token",
"token has expired",
"the security token included in the request is expired",
"unable to load credentials",
"no credentials in the property bag",
"sso session has expired",
];
if sso_signals.iter().any(|s| lower.contains(s)) {
let profile = self
.override_profile
.clone()
.or_else(|| self.context.profile.clone())
.unwrap_or_else(|| "default".into());
return format!(
"credentials expired — run: aws sso login --profile {profile} (or refresh your creds, then press Ctrl-R)"
);
}
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 fn previous_version_label(events: &[EbEvent], current: &str) -> Option<String> {
events
.iter()
.filter_map(|e| e.version_label.as_deref())
.filter(|v| !v.is_empty())
.find(|v| *v != current)
.map(|v| v.to_string())
}
pub fn is_config_event(message: &str) -> bool {
let m = message.to_ascii_lowercase();
m.contains("version label")
|| m.contains("deploying")
|| m.contains("configuration")
|| m.contains("config setting")
}
pub(crate) fn render_changes_overlay(env: &str, events: &[EbEvent]) -> String {
let rows: Vec<&EbEvent> = events
.iter()
.filter(|e| is_config_event(&e.message))
.collect();
if rows.is_empty() {
return format!(
"Config change timeline — {env}\n\n\
No deploy / config-change events in the recent window.\n\n\
esc / q to close"
);
}
let mut body = format!(
"Config change timeline — {env}\n\
{} change event(s), newest first.\n\n",
rows.len()
);
for e in rows {
let ts =
e.at.map(|t| t.format("%Y-%m-%d %H:%M:%SZ").to_string())
.unwrap_or_else(|| "—".into());
let ver = e
.version_label
.as_deref()
.map(|v| format!(" [{v}]"))
.unwrap_or_default();
body.push_str(&format!("{ts}{ver}\n {}\n\n", e.message));
}
body.push_str("esc / q to close");
body
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct LineageRow {
pub label: String,
pub first_at: Option<chrono::DateTime<chrono::Utc>>,
pub last_at: Option<chrono::DateTime<chrono::Utc>>,
}
pub(crate) fn build_lineage(events: &[EbEvent]) -> Vec<LineageRow> {
let mut oldest_first: Vec<&EbEvent> = events
.iter()
.filter(|e| {
e.version_label
.as_deref()
.map(|v| !v.is_empty())
.unwrap_or(false)
})
.collect();
oldest_first.reverse();
let mut rows: Vec<LineageRow> = Vec::new();
for e in oldest_first {
let label = e.version_label.clone().unwrap();
match rows.last_mut() {
Some(last) if last.label == label => {
if let Some(t) = e.at {
last.last_at = Some(t);
}
}
_ => rows.push(LineageRow {
label,
first_at: e.at,
last_at: e.at,
}),
}
}
rows.into_iter().rev().collect()
}
pub(crate) fn format_lineage(env: &str, events: &[EbEvent]) -> String {
let rows = build_lineage(events);
if rows.is_empty() {
return format!(
"Deploy lineage — {env}\n\n\
No deploys in the recent event window.\n\n\
esc / q to close"
);
}
let mut body = format!(
"Deploy lineage — {env}\n\
{} deploy(s), newest first. Δ = gap between deploy starts.\n\n",
rows.len()
);
for (i, row) in rows.iter().enumerate() {
let ts = row
.first_at
.map(|t| t.format("%Y-%m-%d %H:%M:%SZ").to_string())
.unwrap_or_else(|| "—".into());
body.push_str(&format!(" ▸ {ts} {}\n", row.label));
if let (Some(f), Some(l)) = (row.first_at, row.last_at) {
let span = l - f;
if span.num_seconds() > 0 {
body.push_str(&format!(
" took {}\n",
humanize_short_age(Duration::from_secs(span.num_seconds() as u64))
));
}
}
if let Some(next) = rows.get(i + 1) {
if let (Some(this), Some(prev)) = (row.first_at, next.first_at) {
let gap = this - prev;
if gap.num_seconds() > 0 {
body.push_str(&format!(
" Δ {} since previous deploy\n",
humanize_short_age(Duration::from_secs(gap.num_seconds() as u64))
));
}
}
}
body.push('\n');
}
body.push_str("esc / q to close");
body
}
pub(crate) fn format_armed_rollbacks(
armed: &std::collections::HashMap<String, ArmedWatchdog>,
now: chrono::DateTime<chrono::Utc>,
) -> String {
if armed.is_empty() {
return "(no auto-rollbacks armed)\n\nesc / q to close".to_string();
}
let mut rows: Vec<&ArmedWatchdog> = armed.values().collect();
rows.sort_by_key(|w| w.deadline_at);
let mut body = String::new();
body.push_str("ENV TARGET ARMED DEADLINE IN\n");
body.push_str("─────────────────────────────────────────────────────────────────────────\n");
for w in rows {
let armed_ago = (now - w.armed_at).num_seconds().max(0) as u64;
let remaining_secs = (w.deadline_at - now).num_seconds();
let armed_str = humanize_short_age(Duration::from_secs(armed_ago));
let remaining_str = if remaining_secs <= 0 {
"fired / expired".to_string()
} else {
humanize_short_age(Duration::from_secs(remaining_secs as u64))
};
body.push_str(&format!(
"{:<32} {:<17} {:>5} ago {}\n",
truncate_armed_cell(&w.env_name, 32),
truncate_armed_cell(&w.target_label, 17),
armed_str,
remaining_str,
));
}
body.push_str("\nesc / q to close");
body
}
pub(crate) fn soonest_armed_rollback(
armed: &std::collections::HashMap<String, ArmedWatchdog>,
now: chrono::DateTime<chrono::Utc>,
) -> Option<(String, String)> {
let next = armed.values().min_by_key(|w| w.deadline_at)?;
let remaining_secs = (next.deadline_at - now).num_seconds();
let remaining_str = if remaining_secs <= 0 {
"now".to_string()
} else {
humanize_short_age(Duration::from_secs(remaining_secs as u64))
};
Some((next.env_name.clone(), remaining_str))
}
pub(crate) fn soonest_watching_deploy(
watching: &std::collections::HashMap<String, WatchingDeploy>,
now: chrono::DateTime<chrono::Utc>,
) -> Option<(String, String)> {
let next = watching.values().min_by_key(|w| w.deadline_at)?;
let remaining_secs = (next.deadline_at - now).num_seconds();
let remaining_str = if remaining_secs <= 0 {
"now".to_string()
} else {
humanize_short_age(Duration::from_secs(remaining_secs as u64))
};
Some((next.env_name.clone(), remaining_str))
}
fn truncate_armed_cell(s: &str, n: usize) -> String {
if s.chars().count() <= n {
return s.to_string();
}
let mut out: String = s.chars().take(n.saturating_sub(1)).collect();
out.push('…');
out
}
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(),
}
}
pub fn expand_command_alias(
line: &str,
aliases: &std::collections::HashMap<String, String>,
) -> String {
let line = line.trim();
if aliases.is_empty() || line.is_empty() {
return line.to_string();
}
let mut parts = line.splitn(2, char::is_whitespace);
let first = match parts.next() {
Some(s) => s,
None => return line.to_string(),
};
let Some(expansion) = aliases.get(first) else {
return line.to_string();
};
match parts.next() {
Some(rest) => format!("{expansion} {rest}"),
None => expansion.clone(),
}
}
pub fn compute_unavailability_count(
policy: &str,
batch_size: i32,
batch_size_type: &str,
asg_max: i32,
) -> i32 {
let asg_max = asg_max.max(1);
match policy {
p if p.eq_ignore_ascii_case("AllAtOnce") => asg_max,
p if p.eq_ignore_ascii_case("Rolling") => {
compute_batch_count(batch_size, batch_size_type, asg_max)
}
p if p.eq_ignore_ascii_case("RollingWithAdditionalBatch") => 0,
p if p.eq_ignore_ascii_case("Immutable") => 0,
p if p.eq_ignore_ascii_case("TrafficSplitting") => 0,
_ => asg_max,
}
}
pub fn compute_batch_count(batch_size: i32, batch_size_type: &str, asg_max: i32) -> i32 {
if batch_size_type.eq_ignore_ascii_case("Percentage") {
let pct = batch_size.clamp(1, 100);
let count = (asg_max * pct + 99) / 100;
count.max(1).min(asg_max)
} else {
batch_size.max(1).min(asg_max)
}
}
pub fn format_unavailability_line(policy: &str, unavailable: i32, asg_max: i32) -> (String, bool) {
let asg_max = asg_max.max(1);
let caution = unavailable > 0;
let plural = if unavailable == 1 {
"instance"
} else {
"instances"
};
let body = if unavailable == 0 {
format!("deploy plan: {policy} → no in-service unavailability")
} else {
format!("deploy plan: {policy} → max {unavailable}/{asg_max} {plural} unavailable")
};
(body, caution)
}
pub fn extract_unavailability_inputs(
opts: &[(String, String, String)],
) -> (String, i32, String, i32) {
let get = |ns: &str, name: &str| -> Option<&String> {
opts.iter()
.find(|(n, k, _)| n == ns && k == name)
.map(|(_, _, v)| v)
};
let policy = get("aws:elasticbeanstalk:command", "DeploymentPolicy")
.cloned()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "AllAtOnce".to_string());
let batch_size = get("aws:elasticbeanstalk:command", "BatchSize")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(1);
let batch_size_type = get("aws:elasticbeanstalk:command", "BatchSizeType")
.cloned()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "Fixed".to_string());
let asg_max = get("aws:autoscaling:asg", "MaxSize")
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(1);
(policy, batch_size, batch_size_type, asg_max)
}
pub fn build_health_check_probe_url(cname: &str, path: &str) -> String {
let path = if path.starts_with('/') {
path.to_string()
} else if path.is_empty() {
"/".to_string()
} else {
format!("/{path}")
};
format!("http://{cname}{path}")
}
pub(crate) async fn run_health_check_probe(url: &str) -> Result<(), String> {
use tokio::process::Command;
let out = Command::new("curl")
.args([
"-s",
"-o",
"/dev/null",
"-L",
"--max-time",
"2",
"-w",
"%{http_code}",
"-I",
])
.arg(url)
.output()
.await
.map_err(|e| format!("could not invoke curl: {e}"))?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let stderr = stderr.trim();
if !stderr.is_empty() {
return Err(stderr
.lines()
.next()
.unwrap_or("transport error")
.to_string());
}
return Err(format!("curl exit {}", out.status.code().unwrap_or(-1)));
}
let code_str = String::from_utf8_lossy(&out.stdout);
let code: u16 = code_str
.trim()
.parse()
.map_err(|_| format!("unparseable status `{}`", code_str.trim()))?;
classify_health_check_status(code)
}
pub(crate) fn classify_health_check_status(code: u16) -> Result<(), String> {
match code {
200..=299 => Ok(()),
0 => Err("no response (transport error)".into()),
300..=399 => Err(format!("HTTP {code} (redirect — curl was told to follow)")),
400..=599 => Err(format!("HTTP {code}")),
_ => Err(format!("HTTP {code}")),
}
}
pub fn deploy_settled_green(status: &str, health: &str) -> bool {
status.eq_ignore_ascii_case("Ready")
&& (health.eq_ignore_ascii_case("Green") || health.eq_ignore_ascii_case("Ok"))
}
pub fn humanize_short_age(d: Duration) -> String {
let secs = d.as_secs();
if secs < 60 {
format!("{secs}s")
} else if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86_400)
}
}
pub fn parse_tag_args(rest: &[&str]) -> Option<(String, String)> {
let key = (*rest.first()?).to_string();
if rest.len() < 2 {
return None;
}
let value = rest[1..].join(" ");
if key.is_empty() || value.is_empty() {
return None;
}
Some((key, value))
}
pub fn delta_toast_key(text: &str) -> Option<String> {
let trimmed = text.trim_start();
let mut chars = trimmed.chars();
let first = chars.next()?;
if first != '▲' && first != '▼' {
return None;
}
let rest: String = chars.collect();
let first_rest = rest.chars().next()?;
if !first_rest.is_ascii_digit() {
return None;
}
let bucket_start = rest.find(|c: char| !c.is_ascii_digit())?;
let after_digits = &rest[bucket_start..];
let bucket = after_digits.trim_start();
if bucket.is_empty() || !bucket.starts_with(|c: char| c.is_ascii_alphabetic()) {
return None;
}
let word: String = bucket
.chars()
.take_while(|c| c.is_ascii_alphabetic())
.collect();
Some(word)
}
fn yank(text: &str) -> std::result::Result<(), String> {
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)]
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)| {
v.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.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,
})
}
fn merge_app_latest_versions(prev: &[Application], next: &mut [Application]) {
let by_name: std::collections::HashMap<
&str,
(&Option<String>, &Option<chrono::DateTime<chrono::Utc>>),
> = prev
.iter()
.map(|a| {
(
a.name.as_str(),
(&a.latest_version_label, &a.latest_version_created),
)
})
.collect();
for app in next.iter_mut() {
let Some((label, created)) = by_name.get(app.name.as_str()) else {
continue;
};
if app.latest_version_label.is_none() {
app.latest_version_label = (*label).clone();
}
if app.latest_version_created.is_none() {
app.latest_version_created = **created;
}
}
}
pub(crate) fn redact_for_log(value: &str, on: bool) -> String {
if !on || value.is_empty() || value == "—" {
return value.to_string();
}
"▓".repeat(value.chars().count())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UpdateKind {
Deploy { version_label: Option<String> },
Config,
Scale,
Platform,
Generic,
}
pub fn classify_update_kind(events: &[crate::aws::Event]) -> UpdateKind {
for e in events {
let lower = e.message.to_lowercase();
if lower.contains("version label") {
return UpdateKind::Deploy {
version_label: extract_quoted_after(&e.message, "version label"),
};
}
if lower.contains("deploying") && lower.contains("version") {
return UpdateKind::Deploy {
version_label: extract_quoted_after(&e.message, "version"),
};
}
if lower.contains("platform") && (lower.contains("updat") || lower.contains("upgrad")) {
return UpdateKind::Platform;
}
if lower.contains("configuration") && lower.contains("updat") {
return UpdateKind::Config;
}
if (lower.contains("adding") || lower.contains("removing")) && lower.contains("instance") {
return UpdateKind::Scale;
}
}
UpdateKind::Generic
}
fn extract_quoted_after(msg: &str, needle: &str) -> Option<String> {
let lower = msg.to_lowercase();
let needle_lower = needle.to_lowercase();
let after = lower.find(&needle_lower)? + needle_lower.len();
let tail = msg.get(after..)?;
let start = tail.find('\'')?;
let body = &tail[start + 1..];
let end = body.find('\'')?;
Some(body[..end].to_string())
}
fn flatten_err(op: &str, e: color_eyre::eyre::Report) -> String {
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 parse_sort(raw: Option<&str>) -> (SortKey, bool) {
let Some(s) = raw else {
return (SortKey::App, false);
};
let (k, dir) = s.split_once(':').unwrap_or((s, "asc"));
let key = SortKey::parse(k.trim()).unwrap_or(SortKey::App);
let desc = dir.trim().eq_ignore_ascii_case("desc");
(key, desc)
}
fn health_rank(h: &str) -> u8 {
match h.to_lowercase().as_str() {
"green" | "ok" => 0,
"grey" | "gray" | "info" | "no data" | "pending" => 1,
"yellow" | "warning" => 2,
"red" | "severe" | "degraded" => 3,
_ => 4,
}
}
fn parse_toggle(arg: Option<&str>, current: bool) -> bool {
match arg.map(str::to_ascii_lowercase).as_deref() {
Some("on") | Some("true") | Some("yes") | Some("1") => true,
Some("off") | Some("false") | Some("no") | Some("0") => false,
_ => !current,
}
}
fn scroll_apply(current: u16, delta: i32) -> u16 {
let next = current as i32 + delta;
next.max(0) as u16
}
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()
}
pub fn format_env_vars(vars: &[(String, String)]) -> String {
if vars.is_empty() {
return "(no env vars set)".into();
}
let key_width = vars
.iter()
.map(|(k, _)| k.chars().count())
.max()
.unwrap_or(0)
.clamp(8, 40);
let mut out = String::new();
for (k, v) in vars {
let rendered = if v.is_empty() {
"\"\"".to_string()
} else {
v.clone()
};
out.push_str(&format!("{k:<key_width$} = {rendered}\n"));
}
out
}
pub fn parse_metric_extra_args(args: &[&str]) -> (String, Vec<(String, String)>) {
let mut stat: Option<String> = None;
let mut dims: Vec<(String, String)> = Vec::new();
for tok in args {
if tok.contains('=') {
for kv in tok.split(',') {
if let Some((k, v)) = kv.split_once('=') {
let k = k.trim();
let v = v.trim();
if !k.is_empty() && !v.is_empty() {
dims.push((k.to_string(), v.to_string()));
}
}
}
} else if stat.is_none() {
stat = Some(tok.to_string());
}
}
(stat.unwrap_or_else(|| "Average".into()), dims)
}
pub fn parse_s3_url(raw: &str) -> Option<(String, String)> {
let rest = raw.strip_prefix("s3://")?;
let (bucket, key) = rest.split_once('/')?;
if bucket.is_empty() || key.is_empty() {
return None;
}
Some((bucket.to_string(), key.to_string()))
}
pub fn expand_tilde(path: &str) -> String {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = std::env::var_os("HOME") {
let mut p = std::path::PathBuf::from(home);
p.push(rest);
return p.display().to_string();
}
}
path.to_string()
}
pub fn derive_version_label(path: &str, unix_ts: i64) -> String {
let stem = std::path::Path::new(path)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("bundle");
let sanitised: String = stem
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' {
c
} else {
'_'
}
})
.collect();
format!("{sanitised}_{unix_ts}")
}
#[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>,
) {
let outcome = match &result {
Ok(()) => {
format!("stage=completed action=DeployFromLocal target={env_name} label={label} ok")
}
Err(e) => format!(
"stage=completed action=DeployFromLocal target={env_name} label={label} err=\"{}\"",
e.replace('"', "'")
),
};
write_audit_line(account, profile, region, &outcome);
let _ = tx.send(AppMsg::DeployFromLocal {
gen,
env_name,
label,
summary,
result,
});
}
pub fn pick_default_log_group(groups: &[String]) -> Option<String> {
const PRIORITIES: &[&str] = &[
"/var/log/web.stdout.log",
"/var/log/eb-engine.log",
"/var/log/eb-hooks.log",
"/var/log/nginx/access.log",
];
for needle in PRIORITIES {
if let Some(g) = groups.iter().find(|g| g.ends_with(needle)) {
return Some(g.clone());
}
}
groups.first().cloned()
}
pub fn parse_named_arg<T: std::str::FromStr>(rest: &[&str], flag: &str) -> Option<T> {
let pos = rest.iter().position(|s| *s == flag)?;
rest.get(pos + 1).and_then(|v| v.parse().ok())
}
pub fn format_org_accounts(
accounts: &[crate::aws::OrgAccount],
configured: &std::collections::HashMap<String, String>,
) -> String {
if accounts.is_empty() {
return "no accounts returned by organizations:ListAccounts\n\nesc / q to close".into();
}
let mut out = String::new();
out.push_str(&format!(
"Org accounts ({})\n────────────────────\n\n",
accounts.len()
));
let max_name = accounts
.iter()
.map(|a| a.name.len())
.max()
.unwrap_or(0)
.min(28);
for a in accounts {
let switchable = configured
.keys()
.find(|n| {
n.eq_ignore_ascii_case(&a.name)
|| n.eq_ignore_ascii_case(&a.id)
|| n.eq_ignore_ascii_case(&format!("acct-{}", a.id))
})
.cloned();
let switch_hint = match switchable {
Some(n) => format!(" :account {n}"),
None => String::new(),
};
let status_marker = match a.status.as_str() {
"ACTIVE" => "●",
"SUSPENDED" => "⊘",
_ => "○",
};
out.push_str(&format!(
" {status_marker} {name:<width$} {id} [{status}]{switch_hint}\n",
name = a.name,
width = max_name,
id = a.id,
status = a.status,
));
if let Some(email) = a.email.as_ref() {
out.push_str(&format!(
" {pad:<width$} ↳ {email}\n",
pad = "",
width = max_name,
));
}
}
out.push('\n');
out.push_str(
"To switch into an account, add `accounts.NAME.role_arn = …` to config.toml\n\
then use `:account NAME`. esc / q to close.",
);
out
}
pub fn format_deploy_preview(
env_name: &str,
current_label: &str,
candidate_label: &str,
versions: &[crate::aws::AppVersion],
) -> String {
let now = chrono::Utc::now();
let humanize = |d: Option<chrono::DateTime<chrono::Utc>>| -> String {
d.map(|t| {
let dur = now.signed_duration_since(t);
let secs = dur.num_seconds().max(0);
if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86_400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86_400)
}
})
.unwrap_or_else(|| "—".into())
};
let candidate = versions.iter().find(|v| v.label == candidate_label);
let current = if current_label.is_empty() {
None
} else {
versions.iter().find(|v| v.label == current_label)
};
let mut out = String::new();
out.push_str(&format!("env: {env_name}\n"));
out.push_str(&format!(
"current: {}{}\n",
if current_label.is_empty() {
"(none deployed)".to_string()
} else {
current_label.to_string()
},
match current.and_then(|v| v.created) {
Some(t) => format!(" ({})", humanize(Some(t))),
None => String::new(),
}
));
out.push_str(&format!("candidate: {candidate_label}"));
match candidate {
Some(v) => {
out.push_str(&format!(" ({})\n", humanize(v.created)));
if !v.description.is_empty() {
out.push_str(&format!("description: {}\n", v.description));
}
}
None => {
out.push_str("\n\n");
out.push_str(&format!(
"⚠ candidate label '{candidate_label}' not found in this app's version list — \
deploy will fail. Run :versions to see available labels.\n"
));
return out;
}
}
if let (Some(cand), Some(curr)) = (
candidate.and_then(|v| v.created),
current.and_then(|v| v.created),
) {
if cand < curr {
let secs = curr.signed_duration_since(cand).num_seconds().max(0) as u32;
let diff = if secs < 3600 {
format!("{}m", secs / 60)
} else if secs < 86_400 {
format!("{}h", secs / 3600)
} else {
format!("{}d", secs / 86_400)
};
out.push('\n');
out.push_str(&format!(
"⚠ candidate is {diff} older than the currently-deployed version — \
looks like a rollback. Confirm intent.\n"
));
}
}
out.push_str("\nrun :deploy without --preview to dispatch, or :versions for the full list.\n");
out
}
pub fn format_app_versions(
versions: &[crate::aws::AppVersion],
deployed_label: Option<&str>,
limit: usize,
) -> String {
let mut out = String::new();
let total = versions.len();
let shown = total.min(limit);
if total > limit {
out.push_str(&format!(
"showing {shown} of {total} (newest first; deploy older with `:deploy LABEL`)\n\n",
));
}
for v in versions.iter().take(limit) {
let desc = v
.description
.strip_prefix("Application version created from ")
.unwrap_or(&v.description);
let marker = if deployed_label == Some(v.label.as_str()) {
"▶ "
} else {
" "
};
let suffix = if deployed_label == Some(v.label.as_str()) {
" ◀ deployed"
} else {
""
};
if desc.is_empty() {
out.push_str(&format!("{marker}{}{}\n", v.label, suffix));
} else {
out.push_str(&format!("{marker}{} {desc}{}\n", v.label, suffix));
}
}
out.push('\n');
out.push_str("Use `:deploy <label>` to ship one to the selected env.");
out
}
pub fn alarm_kind_to_metric(kind: &str) -> Option<(&'static str, &'static str, &'static str)> {
match kind {
"health" => Some(("EnvironmentHealth", "LessThanOrEqualToThreshold", "Maximum")),
"4xx" | "req4xx" => Some(("ApplicationRequests4xx", "GreaterThanThreshold", "Sum")),
"5xx" | "req5xx" => Some(("ApplicationRequests5xx", "GreaterThanThreshold", "Sum")),
"latency" | "p90" => Some(("ApplicationLatencyP90", "GreaterThanThreshold", "Average")),
_ => None,
}
}
pub fn format_template_settings(settings: &[(String, String, String)]) -> String {
if settings.is_empty() {
return "(no option settings)".into();
}
let key_width = settings
.iter()
.map(|(_, name, _)| name.chars().count())
.max()
.unwrap_or(0)
.clamp(16, 40);
let mut out = String::new();
let mut prev_ns: Option<&str> = None;
for (ns, name, value) in settings {
if Some(ns.as_str()) != prev_ns {
if prev_ns.is_some() {
out.push('\n');
}
out.push_str(&format!("[{ns}]\n"));
prev_ns = Some(ns.as_str());
}
let rendered = if value.is_empty() {
"\"\"".to_string()
} else {
value.clone()
};
out.push_str(&format!(" {name:<key_width$} = {rendered}\n"));
}
out
}
pub fn collect_saved_configs(apps: &[Application]) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = apps
.iter()
.flat_map(|a| a.templates.iter().map(|t| (a.name.clone(), t.clone())))
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
out
}
fn format_saved_configs(apps: &[Application]) -> String {
if apps.is_empty() {
return "no applications loaded — wait for first refresh or :region NAME".into();
}
let mut out = String::new();
out.push_str("EB saved configurations (templates per application)\n");
out.push_str("──────────────────────────────────────────────────\n\n");
let mut any = false;
for a in apps {
if a.templates.is_empty() {
continue;
}
any = true;
out.push_str(&format!("Application: {}\n", a.name));
for t in &a.templates {
out.push_str(&format!(" ▸ {t}\n"));
}
out.push('\n');
}
if !any {
out.push_str("no saved configuration templates in any application\n");
}
out
}
fn diff_envs(left: &Environment, right: &Environment, redact_on: bool) -> String {
let cn = |s: &str| {
if redact_on {
redact_block(s)
} else {
s.to_string()
}
};
let updated = |e: &Environment| {
e.updated
.map(|u| u.to_rfc3339())
.unwrap_or_else(|| "—".into())
};
let rows: Vec<(&str, String, String)> = vec![
("Name", left.name.clone(), right.name.clone()),
(
"Application",
left.application.clone(),
right.application.clone(),
),
("Tier", left.tier.clone(), right.tier.clone()),
("Status", left.status.clone(), right.status.clone()),
("Health", left.health.clone(), right.health.clone()),
("Platform", left.platform.clone(), right.platform.clone()),
(
"Version",
left.version_label.clone(),
right.version_label.clone(),
),
("CNAME", cn(&left.cname), cn(&right.cname)),
("Updated", updated(left), updated(right)),
];
let width: usize = 28;
let truncate = |s: &str| -> String {
if s.chars().count() > width {
let mut t: String = s.chars().take(width.saturating_sub(1)).collect();
t.push('…');
t
} else {
s.to_string()
}
};
let left_label = truncate(&format!("◄ {}", left.name));
let right_label = truncate(&format!("{} ►", right.name));
let mut out = String::new();
out.push_str(&format!(
"{:<14} {:<width$} {}\n",
"", left_label, right_label,
));
out.push_str(&"─".repeat(14 + 4 + width + 4 + width));
out.push('\n');
for (field, l, r) in rows {
let differs = l != r;
let marker = if differs { "≠" } else { " " };
out.push_str(&format!(
"{marker} {:<12} {:<width$} {}\n",
field,
truncate(&l),
truncate(&r),
));
}
out
}
pub(crate) fn format_ssm_results(command: &str, rows: &[crate::aws::SsmRunResult]) -> String {
if rows.is_empty() {
return format!("ssm-run — `{command}`\n\nNo instances targeted.\n\nesc / q to close");
}
const MAX_LINES_PER_STREAM: usize = 50;
const MAX_LINE_CHARS: usize = 200;
let truncate_line = |line: &str| -> String {
if line.chars().count() <= MAX_LINE_CHARS {
line.to_string()
} else {
let mut out: String = line.chars().take(MAX_LINE_CHARS - 1).collect();
out.push('…');
out
}
};
let truncate_block = |block: &str| -> String {
let lines: Vec<&str> = block.lines().collect();
if lines.len() <= MAX_LINES_PER_STREAM {
return lines
.iter()
.map(|l| truncate_line(l))
.collect::<Vec<_>>()
.join("\n");
}
let head = lines
.iter()
.take(MAX_LINES_PER_STREAM)
.map(|l| truncate_line(l))
.collect::<Vec<_>>()
.join("\n");
format!(
"{head}\n… ({} more lines truncated)",
lines.len() - MAX_LINES_PER_STREAM
)
};
let mut body = format!(
"ssm-run — `{command}`\n\
{} instance(s)\n\n",
rows.len()
);
for r in rows {
body.push_str(&format!(
"─── {} [{}, exit={}] ───\n",
r.instance_id, r.status, r.exit_code
));
if r.stdout.is_empty() && r.stderr.is_empty() {
body.push_str(" (no output)\n");
}
if !r.stdout.is_empty() {
body.push_str("stdout:\n");
body.push_str(&truncate_block(&r.stdout));
body.push('\n');
}
if !r.stderr.is_empty() {
body.push_str("stderr:\n");
body.push_str(&truncate_block(&r.stderr));
body.push('\n');
}
body.push('\n');
}
body.push_str("esc / q to close");
body
}
pub(crate) fn format_alarm_history(
alarm_name: &str,
entries: &[crate::aws::AlarmHistoryEntry],
) -> String {
if entries.is_empty() {
return format!(
"Alarm history — {alarm_name}\n\n\
No history items in the recent window.\n\
(CloudWatch retains alarm history for 90 days.)\n\n\
esc / q to close"
);
}
let mut body = format!(
"Alarm history — {alarm_name}\n\
{} entries, newest first.\n\n",
entries.len()
);
for e in entries {
let ts =
e.at.map(|t| t.format("%Y-%m-%d %H:%M:%SZ").to_string())
.unwrap_or_else(|| "—".into());
body.push_str(&format!("{ts} [{}]\n {}\n\n", e.kind, e.summary));
}
body.push_str("esc / q to close");
body
}
fn format_alarms(result: Result<Vec<CwAlarm>, String>) -> String {
match result {
Err(e) => format!("error fetching alarms: {e}"),
Ok(alarms) if alarms.is_empty() => "no CloudWatch alarms reference this env".into(),
Ok(alarms) => {
let mut out = String::new();
out.push_str(&format!("CloudWatch alarms ({})\n", alarms.len()));
out.push_str("──────────────────────────────────────────\n\n");
for a in alarms {
out.push_str(&format!(
"{:<10} {} ({}/{})\n",
a.state, a.name, a.namespace, a.metric_name,
));
if !a.state_reason.is_empty() {
let lead = " ↳ ";
let cont = " ";
out.push_str(&wrap_with_hanging_indent(&a.state_reason, 100, lead, cont));
out.push('\n');
}
out.push('\n');
}
out
}
}
}
pub fn wrap_with_hanging_indent(text: &str, width: usize, lead: &str, cont: &str) -> String {
if text.is_empty() {
return lead.to_string();
}
let body_width = width.saturating_sub(lead.chars().count()).max(1);
let mut out = String::new();
let mut first = true;
let mut current = String::new();
let prefix = |first: bool| if first { lead } else { cont };
for word in text.split_whitespace() {
if word.chars().count() > body_width {
if !current.is_empty() {
out.push_str(prefix(first));
out.push_str(¤t);
out.push('\n');
first = false;
current.clear();
}
let mut chars = word.chars();
loop {
let chunk: String = (&mut chars).take(body_width).collect();
if chunk.is_empty() {
break;
}
out.push_str(prefix(first));
out.push_str(&chunk);
out.push('\n');
first = false;
}
continue;
}
let candidate_len = if current.is_empty() {
word.chars().count()
} else {
current.chars().count() + 1 + word.chars().count()
};
if candidate_len > body_width {
out.push_str(prefix(first));
out.push_str(¤t);
out.push('\n');
first = false;
current.clear();
}
if !current.is_empty() {
current.push(' ');
}
current.push_str(word);
}
if !current.is_empty() {
out.push_str(prefix(first));
out.push_str(¤t);
out.push('\n');
}
out.pop(); out
}
fn encode_view(app: &App) -> String {
let mut parts: Vec<String> = Vec::new();
if !app.filter.is_empty() {
parts.push(format!("filter={}", app.filter));
}
parts.push(format!(
"sort={}:{}",
app.sort_key.label(),
if app.sort_desc { "desc" } else { "asc" }
));
parts.push(format!("grouped={}", app.grouped));
let scope = match app.scope {
Scope::Envs => "envs",
Scope::Apps => "apps",
};
parts.push(format!("scope={scope}"));
parts.join(";")
}
pub fn encode_filter_only_view(filter: &str) -> String {
format!("filter={filter}")
}
pub fn view_filter_value(encoded: &str) -> &str {
for part in encoded.split(';') {
if let Some(rest) = part.trim().strip_prefix("filter=") {
return rest;
}
}
""
}
fn apply_view(app: &mut App, snap: &str) {
let mut new_filter = String::new();
for part in snap.split(';') {
let Some((k, v)) = part.split_once('=') else {
continue;
};
match k.trim() {
"filter" => new_filter = v.trim().to_string(),
"sort" => {
let (key, desc) = parse_sort(Some(v.trim()));
app.sort_key = key;
app.sort_desc = desc;
}
"grouped" => app.grouped = v.trim().eq_ignore_ascii_case("true"),
"scope" => {
app.scope = match v.trim() {
"apps" => Scope::Apps,
_ => Scope::Envs,
};
}
_ => {}
}
}
app.filter = new_filter;
app.resort_envs(); }
pub fn instance_hourly_usd(instance_type: &str) -> Option<f64> {
match instance_type {
"t2.nano" => Some(0.0058),
"t2.micro" => Some(0.0116),
"t2.small" => Some(0.023),
"t2.medium" => Some(0.0464),
"t2.large" => Some(0.0928),
"t3.nano" => Some(0.0052),
"t3.micro" => Some(0.0104),
"t3.small" => Some(0.0208),
"t3.medium" => Some(0.0416),
"t3.large" => Some(0.0832),
"t3.xlarge" => Some(0.1664),
"t3.2xlarge" => Some(0.3328),
"t3a.nano" => Some(0.0047),
"t3a.micro" => Some(0.0094),
"t3a.small" => Some(0.0188),
"t3a.medium" => Some(0.0376),
"t3a.large" => Some(0.0752),
"t4g.nano" => Some(0.0042),
"t4g.micro" => Some(0.0084),
"t4g.small" => Some(0.0168),
"t4g.medium" => Some(0.0336),
"t4g.large" => Some(0.0672),
"m5.large" => Some(0.096),
"m5.xlarge" => Some(0.192),
"m5.2xlarge" => Some(0.384),
"m5.4xlarge" => Some(0.768),
"m6i.large" => Some(0.096),
"m6i.xlarge" => Some(0.192),
"m6i.2xlarge" => Some(0.384),
"m6g.large" => Some(0.077),
"m6g.xlarge" => Some(0.154),
"c5.large" => Some(0.085),
"c5.xlarge" => Some(0.17),
"c5.2xlarge" => Some(0.34),
"c6i.large" => Some(0.085),
"c6i.xlarge" => Some(0.17),
"r5.large" => Some(0.126),
"r5.xlarge" => Some(0.252),
"r6i.large" => Some(0.126),
_ => None,
}
}
pub fn estimate_cost(instances: &[Instance]) -> (f64, usize) {
let mut total = 0.0;
let mut missing = 0;
for i in instances {
match instance_hourly_usd(&i.instance_type) {
Some(p) => total += p,
None => missing += 1,
}
}
(total, missing)
}
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 shell_quote(s: &str) -> String {
if s.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
{
s.to_string()
} else {
let escaped = s.replace('\'', "'\\''");
format!("'{escaped}'")
}
}
fn md_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('|', "\\|")
}
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(),
};
let detail = format!("stage=dispatched action={action:?} target={target}");
write_audit_line(account, profile, region, &detail);
}
fn write_audit_outcome(
account: Option<&str>,
profile: Option<&str>,
region: &str,
action: Action,
env: &str,
result: Result<(), &str>,
) {
let outcome = match result {
Ok(()) => "ok".to_string(),
Err(e) => format!("err=\"{}\"", e.replace('"', "'")),
};
let detail = format!("stage=completed action={action:?} target={env} {outcome}");
write_audit_line(account, profile, region, &detail);
}
const AUDIT_LOG_MAX_BYTES: u64 = 1 << 20;
fn write_audit_line(account: Option<&str>, profile: Option<&str>, region: &str, detail: &str) {
let dir = crate::util::cache_dir();
if std::fs::create_dir_all(&dir).is_err() {
return;
}
let path = dir.join("audit.log");
rotate_if_oversize(&path, AUDIT_LOG_MAX_BYTES);
let when = chrono::Utc::now().to_rfc3339();
let line = format!(
"{when}\taccount={}\tprofile={}\tregion={}\t{detail}\n",
account.unwrap_or("-"),
profile.unwrap_or("-"),
region,
);
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let _ = f.write_all(line.as_bytes());
}
if let Some(url) = NOTIFY_WEBHOOK_URL.get().and_then(|o| o.as_deref()) {
fire_audit_webhook(url, account, profile, region, detail, &when);
}
}
pub(crate) static NOTIFY_WEBHOOK_URL: std::sync::OnceLock<Option<String>> =
std::sync::OnceLock::new();
pub(crate) fn build_audit_webhook_body(
account: Option<&str>,
profile: Option<&str>,
region: &str,
detail: &str,
when: &str,
) -> String {
let text = format!(
"[ebman] {} account={} profile={} region={} {}",
when,
account.unwrap_or("-"),
profile.unwrap_or("-"),
region,
detail,
);
format!(
"{{\"text\":\"{}\",\"at\":\"{}\",\"account\":\"{}\",\"profile\":\"{}\",\"region\":\"{}\",\"detail\":\"{}\"}}",
json_escape(&text),
json_escape(when),
json_escape(account.unwrap_or("")),
json_escape(profile.unwrap_or("")),
json_escape(region),
json_escape(detail),
)
}
fn fire_audit_webhook(
url: &str,
account: Option<&str>,
profile: Option<&str>,
region: &str,
detail: &str,
when: &str,
) {
let body = build_audit_webhook_body(account, profile, region, detail, when);
let url = url.to_string();
if tokio::runtime::Handle::try_current().is_err() {
return;
}
tokio::spawn(async move {
use tokio::process::Command;
let result = Command::new("curl")
.args([
"-s",
"-S",
"-X",
"POST",
"-H",
"Content-Type: application/json",
"--max-time",
"10",
"--data-binary",
"@-",
])
.arg(&url)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn();
let Ok(mut child) = result else {
tracing::warn!(
target: "ebman::notify",
url = %url,
"audit webhook: could not spawn curl"
);
return;
};
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
let _ = stdin.write_all(body.as_bytes()).await;
let _ = stdin.shutdown().await;
}
match child.wait_with_output().await {
Ok(out) if out.status.success() => {}
Ok(out) => {
tracing::warn!(
target: "ebman::notify",
url = %url,
status = ?out.status.code(),
stderr = %String::from_utf8_lossy(&out.stderr).trim(),
"audit webhook returned non-zero"
);
}
Err(e) => {
tracing::warn!(
target: "ebman::notify",
url = %url,
error = %e,
"audit webhook curl exited with error"
);
}
}
});
}
fn rotate_if_oversize(path: &std::path::Path, max_bytes: u64) {
let Ok(meta) = std::fs::metadata(path) else {
return;
};
if meta.len() <= max_bytes {
return;
}
let backup = {
let mut name = path
.file_name()
.map(|s| s.to_os_string())
.unwrap_or_default();
name.push(".1");
path.with_file_name(name)
};
let _ = std::fs::rename(path, backup);
}
#[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);
#[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,
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct AppRollup {
pub env_count: usize,
pub red_count: usize,
pub updating_count: usize,
pub worker_dlq_alerts: usize,
}
pub fn app_rollup(
envs: &[crate::aws::Environment],
app_name: &str,
dlq_depths: &HashMap<String, i64>,
) -> AppRollup {
let mut out = AppRollup::default();
for e in envs.iter().filter(|e| e.application == app_name) {
out.env_count += 1;
if matches!(
e.health.to_lowercase().as_str(),
"red" | "severe" | "degraded"
) {
out.red_count += 1;
}
if e.status.eq_ignore_ascii_case("Updating")
|| e.status.eq_ignore_ascii_case("Launching")
|| e.status.eq_ignore_ascii_case("Terminating")
{
out.updating_count += 1;
}
if e.tier.eq_ignore_ascii_case("Worker")
&& dlq_depths.get(&e.name).copied().unwrap_or(0) > 0
{
out.worker_dlq_alerts += 1;
}
}
out
}
pub(crate) fn build_env_edit_body(env_name: &str, vars: &[(String, String)]) -> String {
let mut out = String::new();
out.push_str(&format!("# ebman env-var editor — {env_name}\n"));
out.push_str("#\n");
out.push_str("# Lines that look like KEY=VALUE are interpreted as env vars.\n");
out.push_str("# Lines starting with # are comments.\n");
out.push_str("# Blank lines are ignored.\n");
out.push_str("#\n");
out.push_str("# Save and quit to apply changes. Saving an unchanged file is a clean\n");
out.push_str("# no-op. To reference a Secrets Manager value, store the ARN here\n");
out.push_str("# (e.g. `DB_PASSWORD_SECRET_ARN=arn:aws:secretsmanager:...`) and have\n");
out.push_str("# your app's bootstrap call GetSecretValue at runtime — EB does not\n");
out.push_str("# resolve secretsmanager:// references natively.\n\n");
let mut sorted: Vec<&(String, String)> = vars.iter().collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
for (k, v) in sorted {
out.push_str(&format!("{k}={v}\n"));
}
out
}
pub(crate) fn parse_env_edit_body(text: &str) -> std::collections::BTreeMap<String, String> {
let mut out = std::collections::BTreeMap::new();
for raw in text.lines() {
let trimmed = raw.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
let key = key.trim();
if key.is_empty() || key.chars().any(char::is_whitespace) {
continue;
}
let value = value.trim_end_matches('\r').trim_end_matches('\n');
out.insert(key.to_string(), value.to_string());
}
out
}
pub(crate) type OptionSet = Vec<(String, String, String)>;
pub(crate) type OptionRemove = Vec<(String, String)>;
pub(crate) fn diff_env_vars(
namespace: &str,
original: &std::collections::BTreeMap<String, String>,
edited: &std::collections::BTreeMap<String, String>,
) -> (OptionSet, OptionRemove) {
let mut to_set: OptionSet = Vec::new();
let mut to_remove: OptionRemove = Vec::new();
for (k, v) in edited {
match original.get(k) {
Some(prev) if prev == v => continue,
_ => to_set.push((namespace.to_string(), k.clone(), v.clone())),
}
}
for k in original.keys() {
if !edited.contains_key(k) {
to_remove.push((namespace.to_string(), k.clone()));
}
}
(to_set, to_remove)
}
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 = if let Some(rest) = principal_raw.strip_prefix("arn:aws:sts::") {
let parts: Vec<&str> = rest.splitn(2, ':').collect();
let account = parts.first()?;
let role_part = parts.get(1)?;
let role_name = role_part.strip_prefix("assumed-role/")?.split('/').next()?;
format!("arn:aws:iam::{account}:role/{role_name}")
} else {
principal_raw.to_string()
};
Some((principal, action))
}
pub(crate) fn render_explain_overlay(principal: &str, rows: &[crate::aws::IamSimResult]) -> String {
let mut out = String::new();
out.push_str(&format!("IAM diagnosis for {principal}\n"));
out.push_str("═══════════════════════════════════════════════════\n\n");
if rows.is_empty() {
out.push_str("(no evaluation results returned)\n\nesc / q to close");
return out;
}
for (idx, r) in rows.iter().enumerate() {
if idx > 0 {
out.push('\n');
}
out.push_str(&format!("Action: {}\n", r.action));
if !r.resource.is_empty() {
out.push_str(&format!("Resource: {}\n", r.resource));
}
let (mark, label) = match r.decision.as_str() {
"allowed" => ("✓", "allowed"),
"explicitDeny" => ("✗", "explicitDeny — a policy *denies* this action"),
"implicitDeny" => ("✗", "implicitDeny — no policy allows this action"),
other => ("?", other),
};
out.push_str(&format!("Decision: {mark} {label}\n"));
if r.blocked_by_scp {
out.push_str(" ⚠ also blocked by an Organizations SCP at the org level\n");
}
if r.blocked_by_boundary {
out.push_str(" ⚠ also blocked by the role's permission boundary\n");
}
if !r.matched_statements.is_empty() {
out.push_str("Matched statements:\n");
for s in &r.matched_statements {
out.push_str(&format!(" ▸ {s}\n"));
}
}
if !r.missing_context.is_empty() {
out.push_str("Missing context keys (conditions unsatisfied):\n");
for c in &r.missing_context {
out.push_str(&format!(" ▸ {c}\n"));
}
}
if r.decision == "implicitDeny" {
out.push_str(&format!(
"\nTo allow, add this statement to one of the role's policies:\n\
\n\
{{\n\
\x20\x20\"Effect\": \"Allow\",\n\
\x20\x20\"Action\": \"{}\",\n\
\x20\x20\"Resource\": \"*\"\n\
}}\n",
r.action
));
} else if r.decision == "explicitDeny" {
out.push_str(
"\nAn explicit Deny in the matched statement(s) above is\n\
overriding any Allow. Remove or scope down the Deny to\n\
unblock — explicit Deny always wins.\n",
);
}
}
out.push_str("\nesc / q to close");
out
}
pub(crate) fn render_env_resources_tree(
res: &crate::aws::EnvResources,
env_name: &str,
tier: &str,
) -> String {
let mut out = String::new();
out.push_str(&format!("Resources for {env_name} ({tier})\n"));
out.push_str("═══════════════════════════════════════\n\n");
let mut sections: Vec<(String, Vec<String>)> = Vec::new();
if !res.asgs.is_empty() {
let mut lines: Vec<String> = Vec::new();
let n_asgs = res.asgs.len();
for (asg_idx, asg) in res.asgs.iter().enumerate() {
let last_asg = asg_idx + 1 == n_asgs;
let asg_prefix = if last_asg { "└─" } else { "├─" };
lines.push(format!(" {asg_prefix} {asg}"));
if asg_idx == 0 && !res.instances.is_empty() {
let n_inst = res.instances.len();
let cont = if last_asg { " " } else { "│ " };
for (i, id) in res.instances.iter().enumerate() {
let last_inst = i + 1 == n_inst;
let glyph = if last_inst { "└─" } else { "├─" };
lines.push(format!(" {cont} {glyph} {id}"));
}
}
}
sections.push((format!("Auto-scaling groups ({})", res.asgs.len()), lines));
} else if !res.instances.is_empty() {
let mut lines: Vec<String> = Vec::new();
let n = res.instances.len();
for (i, id) in res.instances.iter().enumerate() {
let last = i + 1 == n;
let glyph = if last { "└─" } else { "├─" };
lines.push(format!(" {glyph} {id}"));
}
sections.push((format!("Instances ({n}) — orphan (no ASG attached)"), lines));
}
if !res.launch_templates.is_empty() {
let mut lines: Vec<String> = Vec::new();
let n = res.launch_templates.len();
for (i, t) in res.launch_templates.iter().enumerate() {
let glyph = if i + 1 == n { "└─" } else { "├─" };
lines.push(format!(" {glyph} {t}"));
}
sections.push((format!("Launch templates ({n})"), lines));
}
if !res.launch_configs.is_empty() {
let mut lines: Vec<String> = Vec::new();
let n = res.launch_configs.len();
for (i, lc) in res.launch_configs.iter().enumerate() {
let glyph = if i + 1 == n { "└─" } else { "├─" };
lines.push(format!(" {glyph} {lc}"));
}
sections.push((format!("Launch configurations ({n})"), lines));
}
if !res.load_balancers.is_empty() {
let mut lines: Vec<String> = Vec::new();
let n = res.load_balancers.len();
for (i, lb) in res.load_balancers.iter().enumerate() {
let glyph = if i + 1 == n { "└─" } else { "├─" };
lines.push(format!(" {glyph} {lb}"));
}
sections.push((format!("Load balancers ({n})"), lines));
}
if !res.triggers.is_empty() {
let mut lines: Vec<String> = Vec::new();
let n = res.triggers.len();
for (i, t) in res.triggers.iter().enumerate() {
let glyph = if i + 1 == n { "└─" } else { "├─" };
lines.push(format!(" {glyph} {t}"));
}
sections.push((format!("Triggers ({n})"), lines));
}
if !res.queues.is_empty() {
let mut lines: Vec<String> = Vec::new();
let n = res.queues.len();
for (i, q) in res.queues.iter().enumerate() {
let last = i + 1 == n;
let glyph = if last { "└─" } else { "├─" };
lines.push(format!(" {glyph} {}", q.name));
if !q.url.is_empty() {
let url_prefix = if last { " " } else { " │ " };
lines.push(format!("{url_prefix}{}", q.url));
}
}
sections.push((format!("Queues ({n})"), lines));
}
if sections.is_empty() {
out.push_str(" (no resources reported — env may still be launching)\n");
} else {
let n_sections = sections.len();
for (idx, (label, lines)) in sections.iter().enumerate() {
let last_section = idx + 1 == n_sections;
let section_glyph = if last_section { "└─" } else { "├─" };
out.push_str(&format!("{section_glyph} {label}\n"));
let prefix = if last_section { " " } else { "│ " };
for line in lines {
out.push_str(&format!("{prefix}{line}\n"));
}
if !last_section {
out.push_str("│\n");
}
}
}
out.push_str("\nesc / q to close");
out
}
pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
let a_bytes = a.as_bytes();
let b_bytes = b.as_bytes();
if a_bytes.is_empty() {
return b_bytes.len();
}
if b_bytes.is_empty() {
return a_bytes.len();
}
let (short, long) = if a_bytes.len() < b_bytes.len() {
(a_bytes, b_bytes)
} else {
(b_bytes, a_bytes)
};
let mut prev: Vec<usize> = (0..=short.len()).collect();
let mut curr: Vec<usize> = vec![0; short.len() + 1];
for (i, lc) in long.iter().enumerate() {
curr[0] = i + 1;
for (j, sc) in short.iter().enumerate() {
let cost = if lc == sc { 0 } else { 1 };
curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[short.len()]
}
pub(crate) fn suggest_command(input: &str) -> Option<String> {
let threshold = if input.len() <= 3 { 1 } else { 2 };
let mut best: Option<(usize, String)> = None;
for name in crate::commands::all_names() {
let d = edit_distance(input, name);
if d <= threshold && best.as_ref().is_none_or(|(bd, _)| d < *bd) {
best = Some((d, name.to_string()));
}
}
best.map(|(_, name)| name)
}
pub(crate) fn completion_candidates(prefix: &str) -> Vec<String> {
let mut names: Vec<String> = crate::commands::all_names()
.into_iter()
.filter(|n| n.starts_with(prefix))
.map(String::from)
.collect();
names.sort();
names.dedup();
names
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigDiff {
pub namespace: String,
pub name: String,
pub left: Option<String>,
pub right: Option<String>,
}
pub fn diff_config_options(
left: &[crate::aws::ConfigOption],
right: &[crate::aws::ConfigOption],
) -> Vec<ConfigDiff> {
use std::collections::{BTreeMap, BTreeSet};
let norm = |v: &Option<String>| v.clone().filter(|s| !s.is_empty());
let to_map = |opts: &[crate::aws::ConfigOption]| -> BTreeMap<(String, String), Option<String>> {
opts.iter()
.map(|o| ((o.namespace.clone(), o.name.clone()), norm(&o.value)))
.collect()
};
let lmap = to_map(left);
let rmap = to_map(right);
let mut keys: BTreeSet<(String, String)> = lmap.keys().cloned().collect();
keys.extend(rmap.keys().cloned());
keys.into_iter()
.filter_map(|k| {
let l = lmap.get(&k).cloned().flatten();
let r = rmap.get(&k).cloned().flatten();
if l == r {
None
} else {
Some(ConfigDiff {
namespace: k.0,
name: k.1,
left: l,
right: r,
})
}
})
.collect()
}
pub(crate) fn render_config_diff_overlay(
left_env: &str,
right_env: &str,
diffs: &[ConfigDiff],
) -> String {
if diffs.is_empty() {
return format!(
"Config diff — {left_env} ↔ {right_env}\n\n\
✓ identical: every operator-set option-setting matches.\n\n\
esc / q to close"
);
}
let mut body = format!(
"Config diff — {left_env} ↔ {right_env}\n\
{n} option-setting(s) differ. L = {left_env} R = {right_env}\n\
(unset = at the platform default)\n\n",
n = diffs.len()
);
let mut current_ns: Option<&str> = None;
let show = |v: &Option<String>| v.clone().unwrap_or_else(|| "(unset)".into());
for d in diffs {
if Some(d.namespace.as_str()) != current_ns {
if current_ns.is_some() {
body.push('\n');
}
body.push_str(&format!("── {} ──\n", d.namespace));
current_ns = Some(d.namespace.as_str());
}
body.push_str(&format!(
" {}\n L: {}\n R: {}\n",
d.name,
show(&d.left),
show(&d.right),
));
}
body.push_str("\nesc / q to close");
body
}
pub(crate) fn render_options_overlay(
rows: &[crate::aws::ConfigOption],
filter_ns: Option<&str>,
env_name: &str,
) -> String {
let filtered: Vec<&crate::aws::ConfigOption> = rows
.iter()
.filter(|r| filter_ns.is_none_or(|ns| r.namespace == ns))
.collect();
if filtered.is_empty() {
return match filter_ns {
Some(ns) => format!(
"No options found for namespace '{ns}' on env '{env_name}'.\n\n\
Spelling? Try `:options` (no arg) to see the full list of\n\
namespaces available for this env's platform.\n\n\
esc / q to close"
),
None => format!(
"No configuration options returned for env '{env_name}'.\n\n\
This usually means the env's platform doesn't expose an option\n\
vocabulary (custom platform or stale solution-stack). Try\n\
`:set-option` directly if you know what you want to change.\n\n\
esc / q to close"
),
};
}
let mut max_name_per_ns: std::collections::HashMap<&str, usize> =
std::collections::HashMap::new();
for r in &filtered {
let e = max_name_per_ns.entry(r.namespace.as_str()).or_insert(0);
*e = (*e).max(r.name.chars().count()).min(38);
}
let user_set = filtered.iter().filter(|r| r.value.is_some()).count();
let mut body = String::new();
body.push_str(&format!(
"Configuration vocabulary for {env_name}\n\
{user_set}/{total} options are operator-set; the rest are at default.\n\n\
▸ = operator-set • = default severity warns when changing rolls instances\n\n",
total = filtered.len()
));
let mut current_ns: Option<&str> = None;
for r in &filtered {
if Some(r.namespace.as_str()) != current_ns {
if current_ns.is_some() {
body.push('\n');
}
body.push_str(&format!("── {} ──\n", r.namespace));
current_ns = Some(r.namespace.as_str());
}
let marker = if r.value.is_some() { "▸" } else { "•" };
let name_width = max_name_per_ns
.get(r.namespace.as_str())
.copied()
.unwrap_or(20);
let name_padded = if r.name.chars().count() < name_width {
format!("{name:<width$}", name = r.name, width = name_width)
} else {
r.name.clone()
};
let value_str = match &r.value {
Some(v) => format!(" = {v}"),
None => String::new(),
};
let mut meta: Vec<String> = Vec::new();
if let Some(d) = &r.default_value {
if !d.is_empty() {
meta.push(format!("default: {d}"));
}
}
if !r.value_type.is_empty() && r.value_type != "Scalar" {
meta.push(format!("type: {}", r.value_type));
}
if let Some(s) = &r.change_severity {
if s != "NoInterruption" && s != "Unknown" {
meta.push(format!("severity: {s}"));
}
}
match (r.min_value, r.max_value) {
(Some(min), Some(max)) => meta.push(format!("range: {min}-{max}")),
(Some(min), None) => meta.push(format!("min: {min}")),
(None, Some(max)) => meta.push(format!("max: {max}")),
(None, None) => {}
}
if let Some(maxlen) = r.max_length {
meta.push(format!("max_len: {maxlen}"));
}
if !r.value_options.is_empty() {
let preview: Vec<&str> = r.value_options.iter().take(5).map(String::as_str).collect();
let more = r.value_options.len().saturating_sub(5);
let suffix = if more > 0 {
format!(", … +{more}")
} else {
String::new()
};
meta.push(format!("oneof: {}{suffix}", preview.join(", ")));
}
let meta_str = if meta.is_empty() {
String::new()
} else {
format!(" ({})", meta.join(", "))
};
body.push_str(&format!(" {marker} {name_padded}{value_str}{meta_str}\n"));
}
body.push_str(
"\n`:set-option NAMESPACE NAME VALUE` to change a setting.\n\
`:options NAMESPACE` to filter to one family.\n\
esc / q to close",
);
body
}
pub(crate) fn render_secrets_overlay(
rows: &[crate::aws::SecretSummary],
filter: Option<&str>,
) -> String {
if rows.is_empty() {
return match filter {
Some(f) => format!(
"No secrets matching '{f}'.\n\n\
`:secrets` (no arg) to see everything in this region.\n\
Secrets Manager is region-scoped — switch with `:region` first if needed.\n\n\
esc / q to close"
),
None => "No Secrets Manager secrets in this region.\n\n\
Either none have been created, or the caller is missing\n\
`secretsmanager:ListSecrets`. Try `:explain :secrets` to check.\n\n\
esc / q to close"
.to_string(),
};
}
let now = chrono::Utc::now();
let mut body = String::new();
body.push_str(&match filter {
Some(f) => format!(
"Secrets Manager — {n} matching '{f}'\n\
Sorted by last-changed (newest first). Values not shown — use `:secret NAME`.\n\n",
n = rows.len()
),
None => format!(
"Secrets Manager — {n} secrets\n\
Sorted by last-changed (newest first). Values not shown — use `:secret NAME`.\n\n",
n = rows.len()
),
});
for r in rows {
body.push_str(&format!("▸ {}\n", r.name));
if !r.arn.is_empty() {
body.push_str(&format!(" arn: {}\n", r.arn));
}
if let Some(d) = &r.description {
body.push_str(&format!(" desc: {d}\n"));
}
let changed = r.last_changed.map(|t| format_age(now, t));
let rotated = r.last_rotated.map(|t| format_age(now, t));
match (changed, rotated) {
(Some(c), Some(r)) => {
body.push_str(&format!(" changed: {c} rotated: {r}\n"));
}
(Some(c), None) => {
body.push_str(&format!(" changed: {c} rotated: never\n"));
}
(None, Some(r)) => {
body.push_str(&format!(" rotated: {r}\n"));
}
(None, None) => {}
}
if let Some(k) = &r.kms_key_id {
body.push_str(&format!(" kms: {k}\n"));
}
body.push('\n');
}
body.push_str(
"y to yank an ARN (select first) · `:secret NAME` to read the value\n\
esc / q to close",
);
body
}
pub(crate) fn render_secret_value_overlay(name: &str, value: &str, redact: bool) -> String {
let mut body = String::new();
body.push_str(&format!("Secret — {name}\n\n"));
if redact {
body.push_str(&format!(
"value: <redacted; {} chars, fingerprint {}>\n\
Run `:redact off` then re-fetch if you need the cleartext.\n\n\
esc / q to close",
value.chars().count(),
short_fingerprint(value),
));
return body;
}
let pretty = try_pretty_json(value);
body.push_str("value:\n");
body.push_str(&pretty);
if !pretty.ends_with('\n') {
body.push('\n');
}
body.push_str("\ny to yank the value · esc / q to close");
body
}
fn short_fingerprint(s: &str) -> String {
let mut h: u32 = 0x811C_9DC5;
for b in s.as_bytes() {
h ^= *b as u32;
h = h.wrapping_mul(0x0100_0193);
}
format!("{h:08x}")
}
fn try_pretty_json(s: &str) -> String {
let trimmed = s.trim();
if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
return s.to_string();
}
let mut out = String::with_capacity(s.len() + 32);
let mut depth: usize = 0;
let mut in_str = false;
let mut escape = false;
let mut chars = trimmed.chars().peekable();
while let Some(c) = chars.next() {
if in_str {
out.push(c);
if escape {
escape = false;
} else if c == '\\' {
escape = true;
} else if c == '"' {
in_str = false;
}
continue;
}
match c {
'"' => {
in_str = true;
out.push(c);
}
'{' | '[' => {
out.push(c);
if matches!(chars.peek(), Some('}') | Some(']')) {
if let Some(close) = chars.next() {
out.push(close);
}
continue;
}
depth += 1;
out.push('\n');
out.push_str(&" ".repeat(depth));
}
'}' | ']' => {
depth = depth.saturating_sub(1);
out.push('\n');
out.push_str(&" ".repeat(depth));
out.push(c);
}
',' => {
out.push(c);
out.push('\n');
out.push_str(&" ".repeat(depth));
}
':' => {
out.push(c);
out.push(' ');
}
' ' | '\n' | '\t' | '\r' => {} _ => out.push(c),
}
}
out
}
fn format_age(now: chrono::DateTime<chrono::Utc>, t: chrono::DateTime<chrono::Utc>) -> String {
let d = now.signed_duration_since(t);
let secs = d.num_seconds().max(0);
if secs < 60 {
return format!("{secs}s ago");
}
let mins = secs / 60;
if mins < 60 {
return format!("{mins}m ago");
}
let hrs = mins / 60;
if hrs < 48 {
return format!("{hrs}h ago");
}
let days = hrs / 24;
if days < 60 {
return format!("{days}d ago");
}
let months = days / 30;
if months < 24 {
return format!("~{months}mo ago");
}
format!("~{}y ago", days / 365)
}
fn console_url(region: &str, app_name: &str, env_name: &str) -> String {
let app = urlencode(app_name);
let env = urlencode(env_name);
format!(
"https://{region}.console.aws.amazon.com/elasticbeanstalk/home?region={region}#/environment/dashboard?applicationName={app}&environmentName={env}"
)
}
fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
out.push(c);
} else {
for b in c.to_string().bytes() {
out.push_str(&format!("%{b:02X}"));
}
}
}
out
}
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())
}
}
fn describe_env(e: &Environment) -> String {
let updated = e
.updated
.map(|u| u.to_rfc3339())
.unwrap_or_else(|| "null".into());
format!(
"{{\n \"name\": \"{}\",\n \"application\": \"{}\",\n \"tier\": \"{}\",\n \"status\": \"{}\",\n \"health\": \"{}\",\n \"platform\": \"{}\",\n \"version_label\": \"{}\",\n \"cname\": \"{}\",\n \"updated\": {}\n}}",
json_escape(&e.name),
json_escape(&e.application),
json_escape(&e.tier),
json_escape(&e.status),
json_escape(&e.health),
json_escape(&e.platform),
json_escape(&e.version_label),
json_escape(&e.cname),
if updated == "null" { updated } else { format!("\"{updated}\"") },
)
}
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out
}
fn redact_block(value: &str) -> String {
if value.is_empty() {
return value.to_string();
}
"▓".repeat(value.chars().count())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loading_linger_target_none_when_no_load() {
let now = Instant::now();
assert!(compute_loading_linger_target(
None,
Duration::from_millis(300),
Duration::from_millis(500),
now,
)
.is_none());
}
#[test]
fn loading_linger_target_none_when_under_threshold() {
let now = Instant::now();
let started = now - Duration::from_millis(100);
assert!(compute_loading_linger_target(
Some(started),
Duration::from_millis(300),
Duration::from_millis(500),
now,
)
.is_none());
}
#[test]
fn loading_linger_target_arms_past_threshold() {
let now = Instant::now();
let started = now - Duration::from_millis(400);
let until = compute_loading_linger_target(
Some(started),
Duration::from_millis(300),
Duration::from_millis(500),
now,
)
.expect("should arm linger past threshold");
let target_delta = until.duration_since(now);
assert!(
target_delta >= Duration::from_millis(495)
&& target_delta <= Duration::from_millis(505),
"linger target should be ~500ms in the future, got {target_delta:?}"
);
}
#[test]
fn sort_key_cycle_matches_ui_column_order() {
let order = [
SortKey::Name,
SortKey::App,
SortKey::Status,
SortKey::Health,
SortKey::Version,
SortKey::Age,
];
let mut cur = order[0];
for expected in order.iter().skip(1).chain(std::iter::once(&order[0])) {
cur = cur.next();
assert_eq!(cur, *expected);
}
}
#[test]
fn sort_key_parse_roundtrip() {
for k in [
SortKey::Name,
SortKey::App,
SortKey::Status,
SortKey::Health,
SortKey::Version,
SortKey::Age,
] {
assert_eq!(SortKey::parse(k.label()), Some(k));
}
assert_eq!(SortKey::parse("bogus"), None);
}
#[test]
fn parse_sort_handles_directions() {
assert_eq!(parse_sort(Some("app:desc")), (SortKey::App, true));
assert_eq!(parse_sort(Some("name:asc")), (SortKey::Name, false));
assert_eq!(parse_sort(Some("name")), (SortKey::Name, false));
assert_eq!(parse_sort(Some("bogus:desc")), (SortKey::App, true)); assert_eq!(parse_sort(None), (SortKey::App, false));
}
#[test]
fn parse_toggle_explicit_and_default() {
assert!(parse_toggle(Some("on"), false));
assert!(parse_toggle(Some("yes"), false));
assert!(parse_toggle(Some("1"), false));
assert!(!parse_toggle(Some("off"), true));
assert!(!parse_toggle(Some("no"), true));
assert!(parse_toggle(None, false));
assert!(!parse_toggle(None, true));
assert!(parse_toggle(Some("maybe"), false));
}
#[test]
fn health_rank_orders_severities() {
assert!(health_rank("green") < health_rank("grey"));
assert!(health_rank("grey") < health_rank("yellow"));
assert!(health_rank("yellow") < health_rank("red"));
assert_eq!(health_rank("ok"), health_rank("Green"));
}
#[test]
fn scroll_apply_clamps_at_zero() {
assert_eq!(scroll_apply(0, -1), 0);
assert_eq!(scroll_apply(0, 0), 0);
assert_eq!(scroll_apply(0, 1), 1);
assert_eq!(scroll_apply(5, -10), 0);
assert_eq!(scroll_apply(5, 3), 8);
}
#[test]
fn redact_block_preserves_length() {
assert_eq!(redact_block(""), "");
assert_eq!(redact_block("hello").chars().count(), 5);
assert_eq!(redact_block("über-café").chars().count(), 9);
}
#[test]
fn scope_next_alternates() {
assert_eq!(Scope::Envs.next(), Scope::Apps);
assert_eq!(Scope::Apps.next(), Scope::Envs);
}
#[test]
fn action_destructive_only_for_terminate() {
assert!(Action::Terminate.destructive());
assert!(!Action::Rebuild.destructive());
assert!(!Action::RestartAppServer.destructive());
assert!(!Action::SwapCnames.destructive());
}
#[test]
fn scope_prev_is_inverse_of_next() {
assert_eq!(Scope::Envs.next(), Scope::Apps);
assert_eq!(Scope::Envs.prev(), Scope::Apps);
assert_eq!(Scope::Apps.next().next(), Scope::Apps);
assert_eq!(Scope::Envs.prev().prev(), Scope::Envs);
}
#[test]
fn view_mode_labels() {
assert_eq!(ViewMode::Default.label(), "default");
assert_eq!(ViewMode::Compact.label(), "compact");
assert_eq!(ViewMode::Spacious.label(), "spacious");
}
#[test]
fn console_url_includes_region_app_env() {
let url = console_url("us-east-1", "myapp", "myenv");
assert!(url.contains("us-east-1.console.aws.amazon.com"));
assert!(url.contains("region=us-east-1"));
assert!(url.contains("applicationName=myapp"));
assert!(url.contains("environmentName=myenv"));
}
#[test]
fn console_url_encodes_special_chars() {
let url = console_url("us-east-1", "my app", "env/with?slash");
assert!(url.contains("applicationName=my%20app"));
assert!(url.contains("environmentName=env%2Fwith%3Fslash"));
}
#[test]
fn urlencode_keeps_safe_chars() {
assert_eq!(urlencode("hello-world_1.0"), "hello-world_1.0");
assert_eq!(urlencode("a b"), "a%20b");
assert_eq!(urlencode("a/b?c=d"), "a%2Fb%3Fc%3Dd");
assert!(urlencode("café").starts_with("caf"));
}
#[test]
fn json_escape_handles_quotes_and_controls() {
assert_eq!(json_escape("hello"), "hello");
assert_eq!(json_escape(r#"he said "hi""#), r#"he said \"hi\""#);
assert_eq!(json_escape("line\nbreak"), "line\\nbreak");
assert_eq!(json_escape("\\path"), "\\\\path");
let out = json_escape("\u{0001}");
assert_eq!(out, "\\u0001");
}
#[test]
fn build_describe_cli_no_profile() {
let cmd = build_describe_cli("my-env", "eu-west-2", None);
assert_eq!(
cmd,
"aws elasticbeanstalk describe-environments --environment-names my-env --region eu-west-2"
);
}
#[test]
fn build_describe_cli_with_profile_and_special_chars() {
let cmd = build_describe_cli("my env!", "eu-west-2", Some("prod"));
assert!(cmd.contains("--environment-names 'my env!'"));
assert!(cmd.contains("--profile prod"));
}
fn fake_env_with(
name: &str,
status: &str,
health: &str,
updated_minutes_ago: Option<i64>,
) -> Environment {
let updated =
updated_minutes_ago.map(|m| chrono::Utc::now() - chrono::Duration::minutes(m));
Environment {
name: name.into(),
application: "app".into(),
status: status.into(),
health: health.into(),
platform: "Java 17".into(),
solution_stack: String::new(),
tier: "Web".into(),
cname: "x.elb".into(),
version_label: "v1".into(),
arn: None,
updated,
id: None,
region: None,
}
}
#[test]
fn app_rollup_counts_envs_red_and_updating() {
let envs = vec![
crate::aws::Environment {
name: "prod".into(),
application: "foo".into(),
status: "Ready".into(),
health: "Green".into(),
platform: "Java 17".into(),
solution_stack: String::new(),
tier: "WebServer".into(),
cname: String::new(),
version_label: String::new(),
arn: None,
updated: None,
id: None,
region: None,
},
crate::aws::Environment {
name: "staging".into(),
application: "foo".into(),
status: "Updating".into(),
health: "Red".into(),
platform: "Java 17".into(),
solution_stack: String::new(),
tier: "WebServer".into(),
cname: String::new(),
version_label: String::new(),
arn: None,
updated: None,
id: None,
region: None,
},
crate::aws::Environment {
name: "other-app".into(),
application: "bar".into(),
status: "Ready".into(),
health: "Green".into(),
platform: "Java 17".into(),
solution_stack: String::new(),
tier: "WebServer".into(),
cname: String::new(),
version_label: String::new(),
arn: None,
updated: None,
id: None,
region: None,
},
];
let dlq: HashMap<String, i64> = HashMap::new();
let r = super::app_rollup(&envs, "foo", &dlq);
assert_eq!(r.env_count, 2, "foo has 2 envs (prod + staging)");
assert_eq!(r.red_count, 1, "staging is Red");
assert_eq!(r.updating_count, 1, "staging is Updating");
assert_eq!(r.worker_dlq_alerts, 0, "no worker envs in foo");
}
#[test]
fn app_rollup_worker_dlq_alert_counts() {
let envs = vec![crate::aws::Environment {
name: "worker-prod".into(),
application: "wapp".into(),
status: "Ready".into(),
health: "Green".into(),
platform: "Java 17".into(),
solution_stack: String::new(),
tier: "Worker".into(),
cname: String::new(),
version_label: String::new(),
arn: None,
updated: None,
id: None,
region: None,
}];
let mut dlq: HashMap<String, i64> = HashMap::new();
dlq.insert("worker-prod".into(), 7);
let r = super::app_rollup(&envs, "wapp", &dlq);
assert_eq!(r.env_count, 1);
assert_eq!(r.red_count, 0, "EB health stays Green");
assert_eq!(
r.worker_dlq_alerts, 1,
"worker env with DLQ depth > 0 counts as alerting"
);
}
#[test]
fn app_rollup_empty_for_unknown_app() {
let envs: Vec<crate::aws::Environment> = vec![];
let dlq: HashMap<String, i64> = HashMap::new();
let r = super::app_rollup(&envs, "nope", &dlq);
assert_eq!(r, super::AppRollup::default());
}
fn opt(
ns: &str,
name: &str,
value: Option<&str>,
default: Option<&str>,
) -> crate::aws::ConfigOption {
crate::aws::ConfigOption {
namespace: ns.into(),
name: name.into(),
value: value.map(String::from),
default_value: default.map(String::from),
value_type: "Scalar".into(),
value_options: vec![],
change_severity: None,
user_defined: Some(true),
min_value: None,
max_value: None,
max_length: None,
}
}
#[test]
fn diff_config_options_reports_only_differences() {
let left = vec![
opt("aws:autoscaling:asg", "MinSize", Some("2"), None),
opt("aws:autoscaling:asg", "MaxSize", Some("4"), None),
opt(
"aws:elasticbeanstalk:application:environment",
"LOG",
Some("info"),
None,
),
opt("aws:foo", "Same", Some("x"), None),
];
let right = vec![
opt("aws:autoscaling:asg", "MinSize", Some("3"), None), opt("aws:autoscaling:asg", "MaxSize", Some("4"), None), opt(
"aws:elasticbeanstalk:application:environment",
"LOG",
None,
None,
), opt("aws:foo", "Same", Some("x"), None), ];
let diffs = super::diff_config_options(&left, &right);
assert_eq!(diffs.len(), 2, "got {diffs:?}");
let min = diffs.iter().find(|d| d.name == "MinSize").unwrap();
assert_eq!(min.left.as_deref(), Some("2"));
assert_eq!(min.right.as_deref(), Some("3"));
let log = diffs.iter().find(|d| d.name == "LOG").unwrap();
assert_eq!(log.left.as_deref(), Some("info"));
assert_eq!(log.right, None);
}
#[test]
fn diff_config_options_treats_empty_string_as_unset() {
let left = vec![opt("aws:foo", "Bar", Some(""), None)];
let right = vec![opt("aws:foo", "Bar", None, None)];
assert!(super::diff_config_options(&left, &right).is_empty());
}
#[test]
fn render_config_diff_overlay_states() {
let body = super::render_config_diff_overlay("staging", "prod", &[]);
assert!(body.contains("identical"));
let diffs = vec![super::ConfigDiff {
namespace: "aws:autoscaling:asg".into(),
name: "MinSize".into(),
left: Some("2".into()),
right: None,
}];
let body = super::render_config_diff_overlay("staging", "prod", &diffs);
assert!(body.contains("aws:autoscaling:asg"));
assert!(body.contains("MinSize"));
assert!(body.contains("L: 2"));
assert!(body.contains("R: (unset)"));
}
#[test]
fn build_env_edit_body_sorts_keys_and_emits_header() {
let vars = vec![
("LOG_LEVEL".into(), "info".into()),
("DB_HOST".into(), "db.example".into()),
("DB_PORT".into(), "5432".into()),
];
let body = super::build_env_edit_body("prod", &vars);
assert!(body.starts_with("# ebman env-var editor — prod\n"));
assert!(body.contains("Secrets Manager"));
let db_host_pos = body.find("DB_HOST=").expect("DB_HOST line");
let db_port_pos = body.find("DB_PORT=").expect("DB_PORT line");
let log_pos = body.find("LOG_LEVEL=").expect("LOG_LEVEL line");
assert!(db_host_pos < db_port_pos && db_port_pos < log_pos);
}
#[test]
fn parse_env_edit_body_round_trip() {
let vars = vec![
("LOG_LEVEL".into(), "info".into()),
(
"DB_URL".into(),
"postgres://user:pass@host:5432/db?sslmode=require".into(),
),
];
let body = super::build_env_edit_body("env", &vars);
let parsed = super::parse_env_edit_body(&body);
assert_eq!(parsed.get("LOG_LEVEL").map(String::as_str), Some("info"));
assert_eq!(
parsed.get("DB_URL").map(String::as_str),
Some("postgres://user:pass@host:5432/db?sslmode=require")
);
}
#[test]
fn parse_env_edit_body_skips_comments_and_blanks() {
let body = "# comment\n\nDB_HOST=localhost\n # indented comment\n\nLOG=debug\n";
let parsed = super::parse_env_edit_body(body);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed.get("DB_HOST").map(String::as_str), Some("localhost"));
assert_eq!(parsed.get("LOG").map(String::as_str), Some("debug"));
}
#[test]
fn parse_env_edit_body_drops_invalid_keys() {
let body = "= no-key\n KEY WITH SPACES=foo\nGOOD=val\n";
let parsed = super::parse_env_edit_body(body);
assert_eq!(parsed.len(), 1);
assert!(parsed.contains_key("GOOD"));
}
#[test]
fn diff_env_vars_produces_set_and_remove_lists() {
let mut original = std::collections::BTreeMap::new();
original.insert("KEEP".into(), "same".into());
original.insert("CHANGE".into(), "old".into());
original.insert("DROP".into(), "going".into());
let mut edited = std::collections::BTreeMap::new();
edited.insert("KEEP".into(), "same".into()); edited.insert("CHANGE".into(), "new".into()); edited.insert("NEW".into(), "added".into());
let (to_set, to_remove) = super::diff_env_vars("ns", &original, &edited);
let set_keys: std::collections::BTreeSet<&str> =
to_set.iter().map(|(_, k, _)| k.as_str()).collect();
assert_eq!(
set_keys,
["CHANGE", "NEW"]
.into_iter()
.collect::<std::collections::BTreeSet<_>>(),
"to_set should include changed + added keys"
);
assert!(
!set_keys.contains("KEEP"),
"unchanged key must not re-dispatch"
);
assert_eq!(to_remove.len(), 1);
assert_eq!(to_remove[0].1, "DROP");
}
#[test]
fn diff_env_vars_empty_when_unchanged() {
let mut original = std::collections::BTreeMap::new();
original.insert("A".into(), "1".into());
original.insert("B".into(), "2".into());
let edited = original.clone();
let (to_set, to_remove) = super::diff_env_vars("ns", &original, &edited);
assert!(to_set.is_empty());
assert!(to_remove.is_empty());
}
#[test]
fn parse_access_denied_handles_assumed_role() {
let msg = "User: arn:aws:sts::123456789012:assumed-role/EbmanReadOnly/session-abc \
is not authorized to perform: elasticbeanstalk:RebuildEnvironment \
on resource: arn:aws:elasticbeanstalk:eu-west-2:123:environment/foo/bar";
let parsed = super::parse_access_denied(msg);
assert_eq!(
parsed,
Some((
"arn:aws:iam::123456789012:role/EbmanReadOnly".into(),
"elasticbeanstalk:RebuildEnvironment".into()
)),
"assumed-role should be rewritten to the role ARN"
);
}
#[test]
fn parse_access_denied_handles_iam_user() {
let msg = "User: arn:aws:iam::123456789012:user/alice is not authorized to \
perform: s3:GetObject on resource: arn:aws:s3:::bucket/key";
let parsed = super::parse_access_denied(msg);
assert_eq!(
parsed,
Some((
"arn:aws:iam::123456789012:user/alice".into(),
"s3:GetObject".into()
)),
"IAM-user ARN should pass through unchanged"
);
}
#[test]
fn parse_access_denied_returns_none_on_unrelated_error() {
assert_eq!(
super::parse_access_denied("ThrottlingException: rate exceeded"),
None
);
assert_eq!(super::parse_access_denied("random garbage text"), None);
}
#[test]
fn render_explain_overlay_marks_decisions_and_suggests_fix() {
let rows = vec![
crate::aws::IamSimResult {
action: "elasticbeanstalk:RebuildEnvironment".into(),
resource: "*".into(),
decision: "implicitDeny".into(),
matched_statements: vec![],
missing_context: vec![],
blocked_by_scp: false,
blocked_by_boundary: false,
},
crate::aws::IamSimResult {
action: "ec2:DescribeInstances".into(),
resource: "*".into(),
decision: "allowed".into(),
matched_statements: vec![
"arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess @ 0:0".into()
],
missing_context: vec![],
blocked_by_scp: false,
blocked_by_boundary: false,
},
];
let body = super::render_explain_overlay("arn:aws:iam::123:role/EbmanReadOnly", &rows);
assert!(body.contains("Action: elasticbeanstalk:RebuildEnvironment"));
assert!(body.contains("✗ implicitDeny"));
assert!(body.contains("Action: ec2:DescribeInstances"));
assert!(body.contains("✓ allowed"));
assert!(body.contains("\"Effect\": \"Allow\""));
assert!(body.contains("\"Action\": \"elasticbeanstalk:RebuildEnvironment\""));
assert!(body.matches("To allow, add this statement").count() == 1);
assert!(body.contains("AmazonEC2ReadOnlyAccess"));
}
#[test]
fn render_explain_overlay_flags_scp_and_boundary_blockers() {
let rows = vec![crate::aws::IamSimResult {
action: "ec2:TerminateInstances".into(),
resource: "*".into(),
decision: "explicitDeny".into(),
matched_statements: vec!["org-scp/SCPDenyTerminate @ 0:0".into()],
missing_context: vec![],
blocked_by_scp: true,
blocked_by_boundary: true,
}];
let body = super::render_explain_overlay("arn:aws:iam::123:role/X", &rows);
assert!(body.contains("Organizations SCP"));
assert!(body.contains("permission boundary"));
assert!(body.contains("explicit Deny always wins"));
assert!(!body.contains("\"Effect\": \"Allow\""));
}
fn empty_resources() -> crate::aws::EnvResources {
crate::aws::EnvResources::default()
}
#[test]
fn render_env_resources_tree_shows_asg_with_nested_instances() {
let mut res = empty_resources();
res.asgs = vec!["awseb-AWSEBAutoScalingGroup-XYZ".into()];
res.instances = vec!["i-0abc".into(), "i-0def".into(), "i-0ghi".into()];
let body = super::render_env_resources_tree(&res, "prod-api", "Web");
assert!(body.contains("Auto-scaling groups (1)"));
assert!(body.contains("└─ awseb-AWSEBAutoScalingGroup-XYZ"));
assert!(body.contains("├─ i-0abc"));
assert!(body.contains("├─ i-0def"));
assert!(body.contains("└─ i-0ghi"));
}
#[test]
fn render_env_resources_tree_skips_empty_sections() {
let mut res = empty_resources();
res.asgs = vec!["asg-1".into()];
let body = super::render_env_resources_tree(&res, "small-env", "Web");
assert!(body.contains("Auto-scaling groups (1)"));
assert!(!body.contains("Load balancers"));
assert!(!body.contains("Launch configurations"));
assert!(!body.contains("Queues"));
}
#[test]
fn render_env_resources_tree_marks_orphan_instances_when_no_asg() {
let mut res = empty_resources();
res.instances = vec!["i-stranded".into()];
let body = super::render_env_resources_tree(&res, "env", "Web");
assert!(body.contains("orphan (no ASG attached)"));
assert!(body.contains("i-stranded"));
}
#[test]
fn render_env_resources_tree_renders_queue_urls_inline() {
let mut res = empty_resources();
res.queues = vec![
crate::aws::EnvResourceQueue {
name: "WorkerQueue".into(),
url: "https://sqs.eu-west-2.amazonaws.com/123/main".into(),
},
crate::aws::EnvResourceQueue {
name: "WorkerDeadLetterQueue".into(),
url: "https://sqs.eu-west-2.amazonaws.com/123/dlq".into(),
},
];
let body = super::render_env_resources_tree(&res, "worker-prod", "Worker");
assert!(body.contains("├─ WorkerQueue"));
assert!(body.contains("https://sqs.eu-west-2.amazonaws.com/123/main"));
assert!(body.contains("└─ WorkerDeadLetterQueue"));
assert!(body.contains("https://sqs.eu-west-2.amazonaws.com/123/dlq"));
}
#[test]
fn render_env_resources_tree_handles_zero_resources() {
let res = empty_resources();
let body = super::render_env_resources_tree(&res, "fresh-env", "Web");
assert!(body.contains("(no resources reported"));
}
#[tokio::test]
async fn first_run_hint_dismisses_on_first_key() {
let mut app = test_app();
app.first_run_hint = true;
press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE);
assert!(
!app.first_run_hint,
"first key event should clear first_run_hint"
);
}
#[tokio::test]
async fn first_run_hint_stays_false_for_subsequent_launches() {
let app = test_app();
assert!(
!app.first_run_hint,
"test harness must default first_run_hint=false (state.toml presumed present)"
);
}
#[test]
fn edit_distance_basic_cases() {
assert_eq!(super::edit_distance("", ""), 0);
assert_eq!(super::edit_distance("abc", ""), 3);
assert_eq!(super::edit_distance("", "abc"), 3);
assert_eq!(super::edit_distance("kitten", "sitting"), 3);
assert_eq!(super::edit_distance("restart", "restart"), 0);
assert_eq!(super::edit_distance("restrt", "restart"), 1);
assert_eq!(super::edit_distance("rebild", "rebuild"), 1);
assert_eq!(super::edit_distance("scal", "scale"), 1);
}
#[test]
fn suggest_command_catches_one_char_typos() {
assert_eq!(super::suggest_command("restrt").as_deref(), Some("restart"));
assert_eq!(super::suggest_command("rebild").as_deref(), Some("rebuild"));
assert_eq!(super::suggest_command("scal").as_deref(), Some("scale"));
}
#[test]
fn suggest_command_returns_none_when_too_far() {
assert_eq!(super::suggest_command("zzzzzz"), None);
}
#[test]
fn suggest_command_threshold_is_strict_for_short_input() {
let suggestion = super::suggest_command("zz");
assert!(
suggestion.is_none(),
"2-char typo should require distance ≤ 1; got {suggestion:?}"
);
}
#[test]
fn completion_candidates_filters_by_prefix() {
let c = super::completion_candidates("ba");
assert!(
c.iter().any(|s| s == "batch-rebuild"),
"expected batch-rebuild among ba-prefixed candidates; got {c:?}"
);
assert!(
c.iter().all(|s| s.starts_with("ba")),
"every candidate must start with the prefix; got {c:?}"
);
assert_eq!(
c.clone(),
{
let mut sorted = c.clone();
sorted.sort();
sorted
},
"candidates must be alphabetically sorted"
);
}
#[test]
fn completion_candidates_with_empty_prefix_returns_full_list() {
let c = super::completion_candidates("");
assert!(
c.len() > 50,
"expected the full command list; got {} entries",
c.len()
);
assert!(c.iter().any(|s| s == "why"));
assert!(c.iter().any(|s| s == "rebuild"));
}
#[tokio::test]
async fn tab_in_command_mode_cycles_through_matches() {
let mut app = test_app();
app.mode = Mode::Command;
app.command_input = "bat".into();
press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
let first = app.command_input.clone();
assert!(
first.starts_with("bat"),
"Tab should keep the bat-prefix; got {first:?}"
);
assert!(
crate::commands::all_names().contains(&first.as_str()),
"Tab should expand to a real command name; got {first:?}"
);
press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
let second = app.command_input.clone();
assert_ne!(first, second, "second Tab should advance the cycle");
}
#[tokio::test]
async fn typing_in_command_mode_breaks_the_completion_cycle() {
let mut app = test_app();
app.mode = Mode::Command;
app.command_input = "re".into();
press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
assert!(app.completion.origin.is_some());
press(&mut app, KeyCode::Char('s'), KeyModifiers::NONE);
assert!(
app.completion.origin.is_none(),
"typing must reset the completion origin"
);
}
#[tokio::test]
async fn shift_tab_cycles_backward() {
let mut app = test_app();
app.mode = Mode::Command;
app.command_input = "ba".into();
press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
let forward = app.command_input.clone();
press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
press(&mut app, KeyCode::BackTab, KeyModifiers::NONE);
assert_eq!(
app.command_input, forward,
"Tab Tab BackTab should land on the first match"
);
}
#[test]
fn render_options_overlay_groups_by_namespace_and_marks_set_vs_default() {
let rows = vec![
opt("aws:autoscaling:asg", "MinSize", Some("2"), Some("1")),
opt("aws:autoscaling:asg", "MaxSize", None, Some("4")),
opt(
"aws:elasticbeanstalk:command",
"DeploymentPolicy",
Some("Rolling"),
Some("AllAtOnce"),
),
];
let body = super::render_options_overlay(&rows, None, "uflexi-prod");
assert!(body.contains("── aws:autoscaling:asg ──"));
assert!(body.contains("── aws:elasticbeanstalk:command ──"));
assert!(body.contains("▸ MinSize"));
assert!(body.contains("• MaxSize"));
assert!(body.contains("▸ DeploymentPolicy"));
assert!(body.contains("default: 1"));
assert!(body.contains("default: 4"));
assert!(body.contains("2/3 options are operator-set"));
}
#[test]
fn render_options_overlay_filters_to_namespace_when_given() {
let rows = vec![
opt("aws:autoscaling:asg", "MinSize", Some("2"), None),
opt(
"aws:elasticbeanstalk:command",
"DeploymentPolicy",
Some("Rolling"),
None,
),
];
let body = super::render_options_overlay(&rows, Some("aws:autoscaling:asg"), "uflexi-prod");
assert!(body.contains("MinSize"));
assert!(!body.contains("DeploymentPolicy"));
}
#[test]
fn render_options_overlay_handles_unknown_namespace() {
let rows = vec![opt("aws:autoscaling:asg", "MinSize", Some("2"), None)];
let body = super::render_options_overlay(&rows, Some("aws:bogus:ns"), "uflexi-prod");
assert!(body.contains("No options found"));
assert!(body.contains("aws:bogus:ns"));
}
#[test]
fn render_secrets_overlay_empty_with_filter_explains_region_scope() {
let body = super::render_secrets_overlay(&[], Some("prod-db"));
assert!(body.contains("No secrets matching 'prod-db'"));
assert!(body.contains("region-scoped"));
}
#[test]
fn render_secrets_overlay_empty_no_filter_hints_at_iam() {
let body = super::render_secrets_overlay(&[], None);
assert!(body.contains("No Secrets Manager secrets"));
assert!(body.contains("ListSecrets"));
}
#[test]
fn render_secrets_overlay_lists_metadata_only() {
let now = chrono::Utc::now();
let rows = vec![crate::aws::SecretSummary {
name: "prod/db/password".into(),
arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/db/password-AbCdEf"
.into(),
description: Some("RDS master".into()),
last_changed: Some(now - chrono::Duration::days(3)),
last_rotated: Some(now - chrono::Duration::days(30)),
kms_key_id: Some("alias/aws/secretsmanager".into()),
}];
let body = super::render_secrets_overlay(&rows, None);
assert!(body.contains("prod/db/password"));
assert!(body.contains("RDS master"));
assert!(body.contains("arn:aws:secretsmanager"));
assert!(body.contains("changed:"));
assert!(body.contains("rotated:"));
assert!(body.contains("alias/aws/secretsmanager"));
assert!(!body.to_lowercase().contains("password:"));
}
#[test]
fn render_secrets_overlay_marks_never_rotated() {
let now = chrono::Utc::now();
let rows = vec![crate::aws::SecretSummary {
name: "api-key".into(),
arn: "arn:aws:secretsmanager:us-east-1:1:secret:api-key-x".into(),
description: None,
last_changed: Some(now - chrono::Duration::hours(2)),
last_rotated: None,
kms_key_id: None,
}];
let body = super::render_secrets_overlay(&rows, None);
assert!(body.contains("rotated: never"));
}
#[test]
fn render_secret_value_overlay_redacts_when_redact_on() {
let body = super::render_secret_value_overlay("api-key", "hunter2", true);
assert!(body.contains("<redacted; 7 chars"));
assert!(body.contains("fingerprint"));
assert!(!body.contains("hunter2"));
assert!(body.contains(":redact off"));
}
#[test]
fn render_secret_value_overlay_shows_value_when_redact_off() {
let body = super::render_secret_value_overlay("api-key", "hunter2", false);
assert!(body.contains("hunter2"));
assert!(body.contains("yank"));
}
#[test]
fn render_secret_value_overlay_pretty_prints_json() {
let body = super::render_secret_value_overlay(
"prod/db",
r#"{"USERNAME":"app","PASSWORD":"x"}"#,
false,
);
assert!(body.contains("USERNAME"));
assert!(body.contains("PASSWORD"));
assert!(
body.matches('\n').count() >= 4,
"should pretty-print: {body}"
);
}
#[test]
fn render_secret_value_overlay_leaves_non_json_alone() {
let body = super::render_secret_value_overlay("flat", "ABC-DEF-GHI", false);
assert!(body.contains("ABC-DEF-GHI"));
}
#[test]
fn short_fingerprint_is_stable_and_diffs() {
let a = super::short_fingerprint("hunter2");
let b = super::short_fingerprint("hunter2");
let c = super::short_fingerprint("hunter3");
assert_eq!(a, b);
assert_ne!(a, c);
assert_eq!(a.len(), 8);
}
#[test]
fn try_pretty_json_passes_through_non_json() {
assert_eq!(super::try_pretty_json("just a string"), "just a string");
assert_eq!(super::try_pretty_json(""), "");
}
#[test]
fn try_pretty_json_indents_objects() {
let pretty = super::try_pretty_json(r#"{"a":1,"b":2}"#);
let lines: Vec<&str> = pretty.lines().collect();
assert!(lines.len() >= 4, "lines={lines:?}");
assert!(lines.iter().any(|l| l.contains("\"a\": 1")));
assert!(lines.iter().any(|l| l.contains("\"b\": 2")));
}
#[test]
fn try_pretty_json_emits_empty_containers_inline() {
assert_eq!(super::try_pretty_json("{}"), "{}");
assert_eq!(super::try_pretty_json("[]"), "[]");
let pretty = super::try_pretty_json(r#"{"a":{}}"#);
assert!(pretty.contains("\"a\": {}"), "got: {pretty}");
}
#[test]
fn try_pretty_json_preserves_strings_with_braces() {
let pretty = super::try_pretty_json(r#"{"msg":"hello {world}"}"#);
assert!(pretty.contains("hello {world}"));
}
#[test]
fn format_age_buckets() {
let now = chrono::Utc::now();
assert!(super::format_age(now, now).ends_with("s ago"));
assert!(super::format_age(now, now - chrono::Duration::seconds(120)).ends_with("m ago"));
assert!(super::format_age(now, now - chrono::Duration::hours(5)).ends_with("h ago"));
assert!(super::format_age(now, now - chrono::Duration::days(10)).ends_with("d ago"));
let body = super::format_age(now, now - chrono::Duration::days(120));
assert!(body.starts_with('~') && body.contains("mo"));
}
#[test]
fn render_options_overlay_truncates_long_value_options_list() {
let mut row = opt("aws:foo", "Enum", Some("a"), None);
row.value_options = (0..20).map(|i| format!("v{i}")).collect();
let rows = vec![row];
let body = super::render_options_overlay(&rows, None, "env");
assert!(body.contains("oneof: v0, v1, v2, v3, v4, … +15"));
}
#[test]
fn flatten_err_marks_access_denied() {
let e = color_eyre::eyre::eyre!("operation failed")
.wrap_err("AccessDeniedException: User: arn:aws:sts::1234 is not authorized");
let out = super::flatten_err_to_string(&e);
assert!(out.starts_with("AccessDenied:"), "got: {out}");
}
#[test]
fn flatten_err_marks_not_found() {
let e = color_eyre::eyre::eyre!("operation failed")
.wrap_err("ResourceNotFoundException: alarm 'foo' does not exist");
let out = super::flatten_err_to_string(&e);
assert!(out.starts_with("NotFound:"), "got: {out}");
}
#[test]
fn flatten_err_marks_dependency_violation() {
let e = color_eyre::eyre::eyre!("operation failed")
.wrap_err("DependencyViolation: resource still has dependencies");
let out = super::flatten_err_to_string(&e);
assert!(out.starts_with("Conflict:"), "got: {out}");
}
#[test]
fn flatten_err_marks_expired_token() {
let e = color_eyre::eyre::eyre!("operation failed")
.wrap_err("ExpiredToken: session credentials expired");
let out = super::flatten_err_to_string(&e);
assert!(out.starts_with("ExpiredToken:"), "got: {out}");
}
#[test]
fn flatten_err_passes_unknown_through_unchanged() {
let e = color_eyre::eyre::eyre!("some other failure");
let out = super::flatten_err_to_string(&e);
assert!(
!out.contains(":"),
"expected no classification prefix; got: {out}"
);
}
#[test]
fn traffic_warning_flags_updating() {
let e = fake_env_with("prod", "Updating", "Yellow", Some(20));
assert!(super::compute_traffic_warning(&e)
.unwrap()
.contains("ACTIVE DEPLOY"));
}
#[test]
fn traffic_warning_flags_recent_change() {
let e = fake_env_with("prod", "Ready", "Green", Some(2));
assert!(super::compute_traffic_warning(&e)
.unwrap()
.contains("RECENT CHANGE"));
}
#[test]
fn traffic_warning_silent_on_quiet_env() {
let e = fake_env_with("prod", "Ready", "Green", Some(60));
assert!(super::compute_traffic_warning(&e).is_none());
}
#[test]
fn traffic_warning_flags_red_health() {
let e = fake_env_with("prod", "Ready", "Red", Some(120));
assert!(super::compute_traffic_warning(&e).unwrap().contains("Red"));
}
#[test]
fn is_throttling_error_matches_common_aws_strings() {
assert!(is_throttling_error("ThrottlingException: Rate exceeded"));
assert!(is_throttling_error(
"service error: ThrottlingException — please slow down"
));
assert!(is_throttling_error("RequestLimitExceeded"));
assert!(is_throttling_error("HTTP 429 Too Many Requests"));
assert!(is_throttling_error("rate exceeded for this account"));
assert!(!is_throttling_error("EnvironmentNotFound"));
assert!(!is_throttling_error("AccessDenied"));
assert!(!is_throttling_error(""));
}
#[test]
fn throttle_backoff_grows_then_caps() {
let base = Duration::from_secs(15);
let b0 = throttle_backoff(base, 0);
let b1 = throttle_backoff(base, 1);
let b2 = throttle_backoff(base, 2);
assert_eq!(b0, Duration::from_secs(30));
assert_eq!(b1, Duration::from_secs(60));
assert_eq!(b2, Duration::from_secs(120));
let bn = throttle_backoff(base, 30);
assert_eq!(bn, Duration::from_secs(300));
}
#[test]
fn throttle_backoff_handles_overflow_safely() {
let base = Duration::MAX;
let b = throttle_backoff(base, 5);
assert_eq!(b, Duration::from_secs(300));
}
#[test]
fn delta_toast_key_extracts_bucket_for_delta_shapes() {
assert_eq!(super::delta_toast_key("▲2 Red").as_deref(), Some("Red"));
assert_eq!(
super::delta_toast_key("▼1 Yellow").as_deref(),
Some("Yellow")
);
assert_eq!(
super::delta_toast_key(" ▲10 Green").as_deref(),
Some("Green")
);
}
#[test]
fn format_app_versions_marks_deployed_and_shows_total_when_truncated() {
use crate::aws::AppVersion;
let mk = |label: &str, desc: &str| AppVersion {
label: label.into(),
description: desc.into(),
created: None,
};
let versions: Vec<AppVersion> = (1..=30)
.map(|i| {
mk(
&format!("build-{i}"),
&format!("Application version created from https://example.com/build/{i}"),
)
})
.rev()
.collect();
let out = super::format_app_versions(&versions, Some("build-5"), 20);
assert!(out.contains("showing 20 of 30"));
assert!(!out.contains("◀ deployed"));
assert!(out.contains("https://example.com/build/"));
assert!(!out.contains("Application version created from "));
}
#[test]
fn format_app_versions_marks_deployed_when_present() {
use crate::aws::AppVersion;
let versions = vec![
AppVersion {
label: "build-3".into(),
description: String::new(),
created: None,
},
AppVersion {
label: "build-2".into(),
description: String::new(),
created: None,
},
];
let out = super::format_app_versions(&versions, Some("build-2"), 20);
assert!(out.contains("◀ deployed"));
assert!(!out.contains("showing "));
}
#[test]
fn wrap_with_hanging_indent_first_line_keeps_lead_marker() {
let out = super::wrap_with_hanging_indent(
"Threshold Crossed: alarm details continue",
30,
" ↳ ",
" ",
);
let lines: Vec<&str> = out.lines().collect();
assert!(lines[0].starts_with(" ↳ "));
if lines.len() > 1 {
assert!(lines[1].starts_with(" "));
}
}
#[test]
fn wrap_with_hanging_indent_hard_breaks_oversize_words() {
let big_word = "x".repeat(50);
let out = super::wrap_with_hanging_indent(&big_word, 20, " ", " ");
let lines: Vec<&str> = out.lines().collect();
assert!(lines.len() >= 3);
}
#[test]
fn parse_s3_url_extracts_bucket_and_key() {
let (b, k) = super::parse_s3_url("s3://my-bucket/path/to/bundle.zip").unwrap();
assert_eq!(b, "my-bucket");
assert_eq!(k, "path/to/bundle.zip");
}
#[test]
fn parse_s3_url_rejects_malformed() {
assert!(super::parse_s3_url("/local/path.zip").is_none());
assert!(super::parse_s3_url("s3://").is_none());
assert!(super::parse_s3_url("s3://bucket").is_none());
assert!(super::parse_s3_url("s3://bucket/").is_none());
assert!(super::parse_s3_url("s3:///key").is_none());
}
#[test]
fn parse_metric_extra_args_defaults_to_average() {
let (stat, dims) = super::parse_metric_extra_args(&[]);
assert_eq!(stat, "Average");
assert!(dims.is_empty());
}
#[test]
fn parse_metric_extra_args_picks_stat_first() {
let (stat, dims) = super::parse_metric_extra_args(&["Sum"]);
assert_eq!(stat, "Sum");
assert!(dims.is_empty());
}
#[test]
fn parse_metric_extra_args_picks_dims_when_present() {
let (stat, dims) = super::parse_metric_extra_args(&["InstanceId=i-abc"]);
assert_eq!(stat, "Average");
assert_eq!(dims, vec![("InstanceId".into(), "i-abc".into())]);
}
#[test]
fn parse_metric_extra_args_supports_both_in_any_order() {
let (stat, dims) = super::parse_metric_extra_args(&["Sum", "InstanceId=i-abc,Tier=web"]);
assert_eq!(stat, "Sum");
assert_eq!(
dims,
vec![
("InstanceId".into(), "i-abc".into()),
("Tier".into(), "web".into()),
]
);
let (stat, dims) = super::parse_metric_extra_args(&["InstanceId=i-abc", "Sum"]);
assert_eq!(stat, "Sum");
assert_eq!(dims, vec![("InstanceId".into(), "i-abc".into())]);
}
#[test]
fn derive_version_label_uses_filename_stem_and_timestamp() {
let l = super::derive_version_label("./build.zip", 1684512345);
assert_eq!(l, "build_1684512345");
let l = super::derive_version_label("/tmp/myapp-2.1.0.zip", 42);
assert_eq!(l, "myapp-2.1.0_42");
}
#[test]
fn derive_version_label_sanitises_disallowed_chars() {
let l = super::derive_version_label("/tmp/build with spaces & specials!.zip", 1);
assert_eq!(l, "build_with_spaces___specials__1");
}
#[test]
fn derive_version_label_falls_back_to_bundle_on_pathological_input() {
let l = super::derive_version_label("/", 9);
assert_eq!(l, "bundle_9");
}
#[test]
fn expand_tilde_only_replaces_leading() {
let prev = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", "/Users/tester");
}
assert_eq!(super::expand_tilde("~/foo/bar"), "/Users/tester/foo/bar");
assert_eq!(super::expand_tilde("/abs/path"), "/abs/path");
assert_eq!(super::expand_tilde("~tom/foo"), "~tom/foo");
assert_eq!(super::expand_tilde("/foo/~/bar"), "/foo/~/bar");
if let Some(v) = prev {
unsafe {
std::env::set_var("HOME", v);
}
} else {
unsafe {
std::env::remove_var("HOME");
}
}
}
#[test]
fn pick_default_log_group_prefers_web_stdout() {
let groups: Vec<String> = vec![
"/aws/elasticbeanstalk/myenv/var/log/eb-engine.log".into(),
"/aws/elasticbeanstalk/myenv/var/log/web.stdout.log".into(),
"/aws/elasticbeanstalk/myenv/var/log/nginx/access.log".into(),
];
assert_eq!(
super::pick_default_log_group(&groups).as_deref(),
Some("/aws/elasticbeanstalk/myenv/var/log/web.stdout.log")
);
}
#[test]
fn pick_default_log_group_falls_back_to_first() {
let groups: Vec<String> = vec!["/aws/elasticbeanstalk/myenv/var/log/custom.log".into()];
assert_eq!(
super::pick_default_log_group(&groups).as_deref(),
Some("/aws/elasticbeanstalk/myenv/var/log/custom.log")
);
assert_eq!(super::pick_default_log_group(&[]), None);
}
#[test]
fn pick_default_log_group_prefers_engine_log_when_stdout_absent() {
let groups: Vec<String> = vec![
"/aws/elasticbeanstalk/myenv/var/log/nginx/access.log".into(),
"/aws/elasticbeanstalk/myenv/var/log/eb-engine.log".into(),
];
assert_eq!(
super::pick_default_log_group(&groups).as_deref(),
Some("/aws/elasticbeanstalk/myenv/var/log/eb-engine.log")
);
}
#[test]
fn format_env_vars_aligns_on_equals() {
let vars = vec![
("DEBUG".into(), "1".into()),
("DATABASE_URL".into(), "postgres://x".into()),
];
let out = super::format_env_vars(&vars);
assert!(out.contains("DEBUG"));
assert!(out.contains("= 1"));
assert!(out.contains("DATABASE_URL"));
let vars = vec![("EMPTY".into(), "".into())];
assert!(super::format_env_vars(&vars).contains("\"\""));
}
#[test]
fn format_env_vars_handles_empty_input() {
assert_eq!(super::format_env_vars(&[]), "(no env vars set)");
}
#[test]
fn parse_named_arg_picks_up_value_after_flag() {
let rest: Vec<&str> = vec!["on", "--retention", "14"];
assert_eq!(
super::parse_named_arg::<i32>(&rest, "--retention"),
Some(14)
);
assert_eq!(super::parse_named_arg::<i32>(&["on"], "--retention"), None);
assert_eq!(
super::parse_named_arg::<i32>(&["on", "--retention"], "--retention"),
None
);
assert_eq!(
super::parse_named_arg::<i32>(&["on", "--retention", "abc"], "--retention"),
None
);
}
#[test]
fn alarm_kind_to_metric_covers_known_kinds() {
use crate::app::alarm_kind_to_metric;
let (m, op, _) = alarm_kind_to_metric("health").unwrap();
assert_eq!(m, "EnvironmentHealth");
assert_eq!(op, "LessThanOrEqualToThreshold");
let (m, op, _) = alarm_kind_to_metric("5xx").unwrap();
assert_eq!(m, "ApplicationRequests5xx");
assert_eq!(op, "GreaterThanThreshold");
assert_eq!(alarm_kind_to_metric("req5xx"), alarm_kind_to_metric("5xx"));
assert_eq!(alarm_kind_to_metric("p90"), alarm_kind_to_metric("latency"));
assert!(alarm_kind_to_metric("cpu").is_none());
assert!(alarm_kind_to_metric("").is_none());
}
#[test]
fn format_template_settings_groups_by_namespace() {
let s = vec![
(
"aws:elasticbeanstalk:environment".into(),
"EnvironmentType".into(),
"LoadBalanced".into(),
),
("aws:autoscaling:asg".into(), "MinSize".into(), "2".into()),
("aws:autoscaling:asg".into(), "MaxSize".into(), "8".into()),
];
let out = super::format_template_settings(&s);
assert!(out.contains("[aws:autoscaling:asg]"));
assert!(out.contains("[aws:elasticbeanstalk:environment]"));
assert!(out.contains("MinSize"));
assert!(out.contains("= 2"));
let s = vec![(
"aws:elasticbeanstalk:application:environment".into(),
"DEBUG".into(),
String::new(),
)];
assert!(super::format_template_settings(&s).contains("DEBUG"));
assert!(super::format_template_settings(&s).contains("\"\""));
}
#[test]
fn format_template_settings_handles_empty_input() {
assert_eq!(super::format_template_settings(&[]), "(no option settings)");
}
#[test]
fn action_labels_are_distinct_and_non_empty() {
use crate::app::Action;
use std::collections::HashSet;
let all = [
Action::Rebuild,
Action::RestartAppServer,
Action::SwapCnames,
Action::Terminate,
Action::Deploy,
Action::UpgradePlatform,
Action::Clone,
Action::Scale,
Action::AbortUpdate,
Action::ConfigSave,
Action::ConfigDelete,
Action::ConfigApply,
Action::TerminateInstance,
];
let mut labels = HashSet::new();
for a in all {
let l = a.label();
assert!(!l.is_empty(), "{a:?} has empty label");
assert!(labels.insert(l), "{a:?} reuses label {l:?}");
}
}
#[test]
fn collect_saved_configs_flattens_and_sorts_stably() {
use crate::aws::Application;
let app = |name: &str, templates: Vec<String>| Application {
name: name.into(),
description: String::new(),
date_created: None,
date_updated: None,
version_count: 0,
templates,
latest_version_label: None,
latest_version_created: None,
};
let apps = vec![
app("beta", vec!["prod".into(), "canary".into()]),
app("alpha", vec![]),
app("alpha", vec!["staging".into()]),
];
let out = super::collect_saved_configs(&apps);
assert_eq!(
out,
vec![
("alpha".into(), "staging".into()),
("beta".into(), "canary".into()),
("beta".into(), "prod".into()),
]
);
}
#[test]
fn collect_saved_configs_empty_when_no_templates() {
use crate::aws::Application;
let apps = vec![Application {
name: "alpha".into(),
description: String::new(),
date_created: None,
date_updated: None,
version_count: 0,
templates: vec![],
latest_version_label: None,
latest_version_created: None,
}];
assert!(super::collect_saved_configs(&apps).is_empty());
}
#[test]
fn merge_app_latest_versions_carries_previous_values_by_name() {
use crate::aws::Application;
let mk = |name: &str,
label: Option<&str>,
created: Option<chrono::DateTime<chrono::Utc>>|
-> Application {
Application {
name: name.into(),
description: String::new(),
date_created: None,
date_updated: None,
version_count: 0,
templates: vec![],
latest_version_label: label.map(|s| s.into()),
latest_version_created: created,
}
};
let t0 = chrono::Utc::now();
let prev = vec![
mk("alpha", Some("build-1"), Some(t0)),
mk("beta", Some("build-9"), Some(t0)),
];
let mut next = vec![
mk("alpha", None, None),
mk("beta", None, None),
mk("gamma", None, None),
];
super::merge_app_latest_versions(&prev, &mut next);
assert_eq!(next[0].latest_version_label.as_deref(), Some("build-1"));
assert_eq!(next[0].latest_version_created, Some(t0));
assert_eq!(next[1].latest_version_label.as_deref(), Some("build-9"));
assert_eq!(next[2].latest_version_label, None);
assert_eq!(next[2].latest_version_created, None);
}
#[test]
fn merge_app_latest_versions_does_not_overwrite_already_populated_slots() {
use crate::aws::Application;
let mk = |name: &str, label: Option<&str>| -> Application {
Application {
name: name.into(),
description: String::new(),
date_created: None,
date_updated: None,
version_count: 0,
templates: vec![],
latest_version_label: label.map(|s| s.into()),
latest_version_created: None,
}
};
let prev = vec![mk("alpha", Some("OLD"))];
let mut next = vec![mk("alpha", Some("NEW"))];
super::merge_app_latest_versions(&prev, &mut next);
assert_eq!(next[0].latest_version_label.as_deref(), Some("NEW"));
}
#[test]
fn merge_app_latest_versions_handles_app_disappearance() {
use crate::aws::Application;
let mk = |name: &str, label: Option<&str>| -> Application {
Application {
name: name.into(),
description: String::new(),
date_created: None,
date_updated: None,
version_count: 0,
templates: vec![],
latest_version_label: label.map(|s| s.into()),
latest_version_created: None,
}
};
let prev = vec![mk("alpha", Some("build-old")), mk("beta", Some("build-2"))];
let mut next = vec![mk("beta", None)];
super::merge_app_latest_versions(&prev, &mut next);
assert_eq!(next.len(), 1);
assert_eq!(next[0].latest_version_label.as_deref(), Some("build-2"));
}
#[test]
fn format_org_accounts_includes_switch_hint_when_configured() {
use crate::aws::OrgAccount;
let accounts = vec![
OrgAccount {
id: "111122223333".into(),
name: "prod".into(),
email: Some("prod@example.com".into()),
status: "ACTIVE".into(),
},
OrgAccount {
id: "444455556666".into(),
name: "sandbox".into(),
email: None,
status: "SUSPENDED".into(),
},
];
let mut configured = std::collections::HashMap::new();
configured.insert("prod".to_string(), "prod".to_string());
let body = super::format_org_accounts(&accounts, &configured);
assert!(body.contains("● prod"));
assert!(body.contains("⊘ sandbox"));
assert!(body.contains("prod@example.com"));
assert!(body.contains(":account prod"));
assert!(!body.contains(":account sandbox"));
}
#[test]
fn format_org_accounts_empty_returns_hint() {
let body = super::format_org_accounts(&[], &std::collections::HashMap::new());
assert!(body.contains("no accounts returned"));
}
#[test]
fn format_org_accounts_matches_id_when_named_by_id() {
use crate::aws::OrgAccount;
let accounts = vec![OrgAccount {
id: "111122223333".into(),
name: "prod".into(),
email: None,
status: "ACTIVE".into(),
}];
let mut configured = std::collections::HashMap::new();
configured.insert("111122223333".to_string(), "111122223333".to_string());
let body = super::format_org_accounts(&accounts, &configured);
assert!(body.contains(":account 111122223333"));
}
#[test]
fn format_deploy_preview_happy_path() {
use crate::aws::AppVersion;
let now = chrono::Utc::now();
let versions = vec![
AppVersion {
label: "build-142".into(),
description: "fix: idempotent retries".into(),
created: Some(now - chrono::Duration::hours(2)),
},
AppVersion {
label: "build-141".into(),
description: "feat: /metrics endpoint".into(),
created: Some(now - chrono::Duration::days(1)),
},
];
let body = super::format_deploy_preview("uflexi-prod", "build-141", "build-142", &versions);
assert!(body.contains("env: uflexi-prod"));
assert!(body.contains("current: build-141"));
assert!(body.contains("candidate: build-142"));
assert!(body.contains("fix: idempotent retries"));
assert!(!body.contains("rollback"));
}
#[test]
fn format_deploy_preview_rollback_warning_fires_when_older() {
use crate::aws::AppVersion;
let now = chrono::Utc::now();
let versions = vec![
AppVersion {
label: "build-old".into(),
description: String::new(),
created: Some(now - chrono::Duration::days(7)),
},
AppVersion {
label: "build-new".into(),
description: String::new(),
created: Some(now - chrono::Duration::hours(1)),
},
];
let body = super::format_deploy_preview("uflexi-prod", "build-new", "build-old", &versions);
assert!(
body.contains("rollback"),
"expected rollback warning, got: {body}"
);
}
#[test]
fn format_deploy_preview_unknown_label_calls_out_the_gap() {
use crate::aws::AppVersion;
let versions = vec![AppVersion {
label: "build-141".into(),
description: String::new(),
created: Some(chrono::Utc::now()),
}];
let body = super::format_deploy_preview(
"uflexi-prod",
"build-141",
"build-DOES-NOT-EXIST",
&versions,
);
assert!(body.contains("not found"));
assert!(body.contains("build-DOES-NOT-EXIST"));
}
fn make_event(msg: &str) -> crate::aws::Event {
crate::aws::Event {
at: Some(chrono::Utc::now()),
env: "uflexi-prod".into(),
application: "uflexi".into(),
message: msg.into(),
severity: "INFO".into(),
version_label: None,
}
}
#[test]
fn previous_version_label_finds_prior_deploy() {
let ev = |vl: Option<&str>| crate::aws::Event {
at: None,
env: "e".into(),
application: "a".into(),
message: String::new(),
severity: "INFO".into(),
version_label: vl.map(String::from),
};
let events = vec![
ev(Some("build-3")),
ev(None),
ev(Some("build-3")),
ev(Some("build-2")),
ev(Some("build-1")),
];
assert_eq!(
super::previous_version_label(&events, "build-3"),
Some("build-2".into())
);
let only_current = vec![ev(Some("build-3")), ev(None), ev(Some("build-3"))];
assert_eq!(
super::previous_version_label(&only_current, "build-3"),
None
);
assert_eq!(
super::previous_version_label(&[ev(None), ev(None)], "build-3"),
None
);
assert_eq!(super::previous_version_label(&[], "build-3"), None);
assert_eq!(
super::previous_version_label(&[ev(Some("")), ev(Some("build-1"))], "build-3"),
Some("build-1".into())
);
}
#[test]
fn is_config_event_keeps_deploys_and_config_changes() {
assert!(super::is_config_event(
"Updating environment uflexi-prod to use version label 'build-9'."
));
assert!(super::is_config_event(
"Deploying new version to instance(s)."
));
assert!(super::is_config_event(
"Updating environment uflexi-prod's configuration settings."
));
assert!(!super::is_config_event(
"Environment health transitioned from Ok to Severe."
));
assert!(!super::is_config_event(
"Added instance 'i-abc' to environment."
));
}
#[test]
fn render_changes_overlay_states() {
let ev = |msg: &str, vl: Option<&str>| crate::aws::Event {
at: None,
env: "e".into(),
application: "a".into(),
message: msg.into(),
severity: "INFO".into(),
version_label: vl.map(String::from),
};
let noise = vec![ev("Environment health transitioned to Ok.", None)];
assert!(super::render_changes_overlay("prod", &noise).contains("No deploy"));
let evs = vec![
ev("Deploying new version to instance(s).", Some("build-9")),
ev("Environment health transitioned to Ok.", None),
];
let body = super::render_changes_overlay("prod", &evs);
assert!(body.contains("Deploying new version"));
assert!(body.contains("[build-9]"));
assert!(!body.contains("health transitioned"));
}
#[test]
fn build_lineage_collapses_consecutive_same_label_events() {
use chrono::TimeZone;
let ts = |y, mo, d, h, mi| chrono::Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap();
let mk = |t, vl: &str| crate::aws::Event {
at: Some(t),
env: "e".into(),
application: "a".into(),
message: "deploy event".into(),
severity: "INFO".into(),
version_label: Some(vl.into()),
};
let evs = vec![
mk(ts(2026, 5, 24, 12, 7), "build-9"),
mk(ts(2026, 5, 24, 12, 5), "build-9"),
mk(ts(2026, 5, 24, 12, 0), "build-9"),
mk(ts(2026, 5, 24, 11, 3), "build-8"),
mk(ts(2026, 5, 24, 11, 0), "build-8"),
];
let rows = super::build_lineage(&evs);
assert_eq!(rows.len(), 2, "expected 2 distinct deploys, got {rows:?}");
assert_eq!(rows[0].label, "build-9");
assert_eq!(rows[1].label, "build-8");
assert_eq!(rows[0].first_at, Some(ts(2026, 5, 24, 12, 0)));
assert_eq!(rows[0].last_at, Some(ts(2026, 5, 24, 12, 7)));
assert_eq!(rows[1].first_at, Some(ts(2026, 5, 24, 11, 0)));
assert_eq!(rows[1].last_at, Some(ts(2026, 5, 24, 11, 3)));
}
#[test]
fn build_lineage_drops_events_without_version_label() {
let ev = |vl: Option<&str>| crate::aws::Event {
at: None,
env: "e".into(),
application: "a".into(),
message: "noise".into(),
severity: "INFO".into(),
version_label: vl.map(String::from),
};
let evs = vec![ev(None), ev(Some("")), ev(None)];
assert!(super::build_lineage(&evs).is_empty());
}
#[test]
fn format_lineage_shows_gap_and_span_between_deploys() {
use chrono::TimeZone;
let ts = |h, mi| chrono::Utc.with_ymd_and_hms(2026, 5, 24, h, mi, 0).unwrap();
let mk = |t, vl: &str| crate::aws::Event {
at: Some(t),
env: "e".into(),
application: "a".into(),
message: "deploy event".into(),
severity: "INFO".into(),
version_label: Some(vl.into()),
};
assert!(super::format_lineage("prod", &[]).contains("No deploys"));
let evs = vec![
mk(ts(12, 5), "build-9"),
mk(ts(12, 0), "build-9"),
mk(ts(10, 0), "build-8"),
];
let body = super::format_lineage("prod", &evs);
let p9 = body.find("build-9").expect("build-9 row");
let p8 = body.find("build-8").expect("build-8 row");
assert!(p9 < p8, "build-9 should come before build-8 (newest first)");
assert!(
body.contains("took"),
"expected `took` span line, got:\n{body}"
);
assert!(
body.contains("Δ"),
"expected `Δ since previous` line, got:\n{body}"
);
}
#[test]
fn classify_update_kind_deploy_extracts_label() {
let evs = vec![make_event(
"Updating environment uflexi-prod to use version label 'build-142'.",
)];
match super::classify_update_kind(&evs) {
super::UpdateKind::Deploy { version_label } => {
assert_eq!(version_label.as_deref(), Some("build-142"));
}
other => panic!("expected Deploy, got {other:?}"),
}
}
#[test]
fn classify_update_kind_deploy_without_label_still_classifies() {
let evs = vec![make_event("Deploying new version to instance i-abc123.")];
match super::classify_update_kind(&evs) {
super::UpdateKind::Deploy { version_label } => {
assert!(version_label.is_none());
}
other => panic!("expected Deploy, got {other:?}"),
}
}
#[test]
fn classify_update_kind_platform_update() {
let evs = vec![make_event(
"Updating environment to use platform 'arn:aws:elasticbeanstalk:…:platform/Corretto 17'.",
)];
assert_eq!(
super::classify_update_kind(&evs),
super::UpdateKind::Platform
);
}
#[test]
fn classify_update_kind_config_change() {
let evs = vec![make_event("Updating environment configuration completed.")];
assert_eq!(super::classify_update_kind(&evs), super::UpdateKind::Config);
}
#[test]
fn classify_update_kind_scale_event() {
let evs = vec![make_event("Adding instance 'i-abc123' to environment.")];
assert_eq!(super::classify_update_kind(&evs), super::UpdateKind::Scale);
}
#[test]
fn classify_update_kind_unknown_message_falls_through_to_generic() {
let evs = vec![make_event("Something cryptic happened.")];
assert_eq!(
super::classify_update_kind(&evs),
super::UpdateKind::Generic
);
}
#[test]
fn classify_update_kind_picks_most_recent_match() {
let evs = vec![
make_event("Updating environment to use version label 'build-99'."),
make_event("Adding instance 'i-old' to environment."),
];
match super::classify_update_kind(&evs) {
super::UpdateKind::Deploy { version_label } => {
assert_eq!(version_label.as_deref(), Some("build-99"));
}
other => panic!("expected Deploy from newest match, got {other:?}"),
}
}
#[test]
fn classify_update_kind_empty_events_is_generic() {
assert_eq!(super::classify_update_kind(&[]), super::UpdateKind::Generic);
}
#[test]
fn compute_red_alerts_counts_eb_red_and_worker_dlq() {
use crate::aws::Environment;
let mk = |name: &str, tier: &str, health: &str| Environment {
name: name.into(),
application: "uflexi".into(),
status: "Ready".into(),
health: health.into(),
platform: "Java 17".into(),
solution_stack: String::new(),
tier: tier.into(),
cname: String::new(),
version_label: String::new(),
arn: None,
updated: None,
id: None,
region: None,
};
let envs = vec![
mk("web-prod", "Web", "Green"),
mk("web-red", "Web", "Red"),
mk("worker-green-dlq", "Worker", "Green"),
mk("worker-clean", "Worker", "Green"),
mk("worker-red", "Worker", "Severe"),
];
let mut dlq = std::collections::HashMap::new();
dlq.insert("worker-green-dlq".to_string(), 3);
dlq.insert("worker-clean".to_string(), 0);
assert_eq!(super::compute_red_alerts(&envs, &dlq), 3);
}
#[test]
fn compute_red_alerts_ignores_dlq_for_web_tier() {
use crate::aws::Environment;
let env = Environment {
name: "web-prod".into(),
application: "uflexi".into(),
status: "Ready".into(),
health: "Green".into(),
platform: "Java 17".into(),
solution_stack: String::new(),
tier: "Web".into(),
cname: String::new(),
version_label: String::new(),
arn: None,
updated: None,
id: None,
region: None,
};
let mut dlq = std::collections::HashMap::new();
dlq.insert("web-prod".to_string(), 99);
assert_eq!(super::compute_red_alerts(&[env], &dlq), 0);
}
#[test]
fn compute_red_alerts_zero_dlq_is_not_alert_worthy() {
use crate::aws::Environment;
let env = Environment {
name: "worker-clean".into(),
application: "uflexi".into(),
status: "Ready".into(),
health: "Green".into(),
platform: "Java 17".into(),
solution_stack: String::new(),
tier: "Worker".into(),
cname: String::new(),
version_label: String::new(),
arn: None,
updated: None,
id: None,
region: None,
};
let mut dlq = std::collections::HashMap::new();
dlq.insert("worker-clean".to_string(), 0);
assert_eq!(super::compute_red_alerts(&[env], &dlq), 0);
}
#[test]
fn redact_for_log_preserves_length_with_block_chars() {
assert_eq!(super::redact_for_log("540847557034", true), "▓".repeat(12));
assert_eq!(super::redact_for_log("540847557034", false), "540847557034");
assert_eq!(super::redact_for_log("—", true), "—");
assert_eq!(super::redact_for_log("", true), "");
}
#[test]
fn parse_tag_args_happy_path() {
let v: Vec<&str> = vec!["Owner", "platform-team"];
let (k, v) = super::parse_tag_args(&v).unwrap();
assert_eq!(k, "Owner");
assert_eq!(v, "platform-team");
}
#[test]
fn parse_tag_args_joins_value_tokens_with_spaces() {
let v: Vec<&str> = vec!["Description", "owned", "by", "platform"];
let (k, v) = super::parse_tag_args(&v).unwrap();
assert_eq!(k, "Description");
assert_eq!(v, "owned by platform");
}
#[test]
fn parse_tag_args_rejects_missing_value() {
let v: Vec<&str> = vec!["Owner"];
assert!(super::parse_tag_args(&v).is_none());
let v: Vec<&str> = vec![];
assert!(super::parse_tag_args(&v).is_none());
}
#[test]
fn delta_toast_key_returns_none_for_non_delta_text() {
assert_eq!(super::delta_toast_key("refreshing…"), None);
assert_eq!(super::delta_toast_key(""), None);
assert_eq!(super::delta_toast_key("▲"), None);
assert_eq!(super::delta_toast_key("▲ Red"), None);
assert_eq!(super::delta_toast_key("▲5 "), None);
}
#[test]
fn assign_app_colors_stable_first_appearance() {
use ratatui::style::Color;
let palette = vec![Color::Red, Color::Green, Color::Blue];
let names = ["app-a", "app-b", "app-a", "app-c", "app-b"];
let m = assign_app_colors(names.iter().copied(), &palette);
assert_eq!(m.get("app-a").copied(), Some(Color::Red));
assert_eq!(m.get("app-b").copied(), Some(Color::Green));
assert_eq!(m.get("app-c").copied(), Some(Color::Blue));
assert_eq!(m.len(), 3);
}
#[test]
fn assign_app_colors_wraps_when_palette_exhausted() {
use ratatui::style::Color;
let palette = vec![Color::Red, Color::Green];
let names = ["a", "b", "c", "d"];
let m = assign_app_colors(names.iter().copied(), &palette);
assert_eq!(m.get("a").copied(), Some(Color::Red));
assert_eq!(m.get("b").copied(), Some(Color::Green));
assert_eq!(m.get("c").copied(), Some(Color::Red));
assert_eq!(m.get("d").copied(), Some(Color::Green));
}
#[test]
fn assign_app_colors_empty_palette_yields_empty_map() {
let m = assign_app_colors(["a", "b"].iter().copied(), &[]);
assert!(m.is_empty());
}
#[test]
fn rotate_if_oversize_renames_when_too_big() {
let dir = std::env::temp_dir().join(format!("ebman-rotate-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("audit.log");
let backup = dir.join("audit.log.1");
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(&backup);
std::fs::write(&path, vec![b'x'; 100]).unwrap();
rotate_if_oversize(&path, 50);
assert!(!path.exists(), "current file should have been renamed");
assert!(backup.exists(), "rotated backup should now exist");
let _ = std::fs::remove_file(&backup);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn rotate_if_oversize_leaves_small_files_alone() {
let dir = std::env::temp_dir().join(format!("ebman-rotate-small-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("audit.log");
let _ = std::fs::remove_file(&path);
std::fs::write(&path, b"tiny").unwrap();
rotate_if_oversize(&path, 1_000);
assert!(path.exists());
assert!(!dir.join("audit.log.1").exists());
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_dir(&dir);
}
#[test]
fn event_time_format_cycles_utc_local_age() {
let f = EventTimeFormat::default();
assert_eq!(f, EventTimeFormat::Utc);
assert_eq!(f.next(), EventTimeFormat::Local);
assert_eq!(f.next().next(), EventTimeFormat::Age);
assert_eq!(f.next().next().next(), EventTimeFormat::Utc);
}
#[test]
fn event_time_format_parse_round_trips() {
for f in [
EventTimeFormat::Utc,
EventTimeFormat::Local,
EventTimeFormat::Age,
] {
assert_eq!(EventTimeFormat::parse(f.label()), Some(f));
}
assert_eq!(EventTimeFormat::parse("UTC"), Some(EventTimeFormat::Utc));
assert_eq!(
EventTimeFormat::parse("relative"),
Some(EventTimeFormat::Age)
);
assert_eq!(EventTimeFormat::parse("nonsense"), None);
}
#[test]
fn shell_quote_passes_safe_chars_unchanged() {
assert_eq!(shell_quote("safe-Name_1.0"), "safe-Name_1.0");
assert_eq!(shell_quote("with space"), "'with space'");
assert_eq!(shell_quote("o'clock"), "'o'\\''clock'");
}
#[test]
fn instance_hourly_usd_known_types() {
assert!(instance_hourly_usd("t3.micro").unwrap() > 0.0);
assert!(instance_hourly_usd("m5.large").unwrap() > 0.0);
assert_eq!(instance_hourly_usd("not-a-real-type"), None);
}
#[test]
fn estimate_cost_handles_mixed() {
let mk = |t: &str, az: &str| Instance {
id: "i-1".into(),
health: "Ok".into(),
color: "Green".into(),
causes: vec![],
instance_type: t.into(),
availability_zone: az.into(),
launched_at: None,
};
let instances = vec![
mk("t3.micro", "us-east-1a"),
mk("t3.micro", "us-east-1b"),
mk("unknown-type-xyz", "us-east-1c"),
];
let (hourly, missing) = estimate_cost(&instances);
assert_eq!(missing, 1);
assert!((hourly - 0.0208).abs() < 1e-9);
}
fn fake_env(name: &str, status: &str, health: &str, version: &str) -> Environment {
Environment {
name: name.into(),
application: "my-app".into(),
status: status.into(),
health: health.into(),
platform: "Java 17".into(),
solution_stack: String::new(),
tier: "Web".into(),
cname: format!("{name}.elb.amazonaws.com"),
version_label: version.into(),
arn: None,
updated: None,
id: None,
region: None,
}
}
#[test]
fn palette_score_prefers_label_prefix_then_substring_then_detail() {
assert_eq!(palette_score("", "anything", "anything"), Some(0));
assert_eq!(palette_score("reg", "region", "switch AWS region"), Some(0));
let s_label = palette_score("ion", "region", "switch AWS region").unwrap();
assert!(s_label > 0 && s_label < 1_000);
let s_detail = palette_score("aws", ":region", "switch AWS profile").unwrap();
let s_label_match = palette_score("aws", "aws-thing", "irrelevant").unwrap();
assert!(s_detail >= 1_000);
assert!(s_label_match < s_detail);
assert_eq!(palette_score("xyzzy", "region", "switch AWS region"), None);
}
#[test]
fn bucket_delta_only_envs_in_both() {
let mut prev = HashMap::new();
prev.insert("a".into(), "Green".into());
prev.insert("b".into(), "Red".into());
prev.insert("c".into(), "Green".into()); let next = vec![
fake_env("a", "Ready", "Yellow", "v1"), fake_env("b", "Ready", "Red", "v1"), fake_env("d", "Ready", "Green", "v1"), ];
let delta = bucket_delta(&prev, &next, |e| e.health.clone());
let map: BTreeMap<String, i32> = delta.into_iter().collect();
assert_eq!(map.get("Green").copied(), Some(-1));
assert_eq!(map.get("Yellow").copied(), Some(1));
assert_eq!(map.get("Red").copied(), None);
}
#[test]
fn bucket_delta_empty_prev_yields_no_deltas() {
let prev = HashMap::new();
let next = vec![
fake_env("a", "Ready", "Green", "v1"),
fake_env("b", "Ready", "Red", "v1"),
];
let delta = bucket_delta(&prev, &next, |e| e.health.clone());
assert!(
delta.is_empty(),
"expected no deltas with empty prev, got {delta:?}"
);
}
#[test]
fn diff_envs_marks_differing_fields() {
let a = fake_env("prod", "Ready", "Green", "v1");
let b = fake_env("staging", "Updating", "Yellow", "v2");
let out = diff_envs(&a, &b, false);
assert!(out.contains("≠ Status"));
assert!(out.contains("≠ Health"));
assert!(out.contains("≠ Version"));
assert!(out.contains("≠ Name"));
assert!(out.contains("≠ CNAME"));
assert!(out.contains(" Application"));
assert!(out.contains(" Tier"));
assert!(out.contains(" Platform"));
}
#[test]
fn diff_envs_redacts_cname() {
let a = fake_env("prod", "Ready", "Green", "v1");
let b = fake_env("staging", "Updating", "Yellow", "v2");
let out = diff_envs(&a, &b, true);
assert!(!out.contains("prod.elb.amazonaws.com"));
assert!(out.contains("▓"));
}
#[test]
fn encode_filter_only_view_emits_just_the_filter_part() {
let encoded = super::encode_filter_only_view("tag:env=prod");
assert_eq!(encoded, "filter=tag:env=prod");
assert_eq!(super::encode_filter_only_view(""), "filter=");
}
#[test]
fn view_filter_value_extracts_filter_or_empty() {
assert_eq!(
super::view_filter_value("filter=tag:env=prod"),
"tag:env=prod"
);
assert_eq!(
super::view_filter_value("sort=name:asc;filter=tag:env=prod;grouped=false"),
"tag:env=prod",
);
assert_eq!(super::view_filter_value("sort=name:asc;grouped=true"), "");
assert_eq!(super::view_filter_value(""), "");
assert_eq!(super::view_filter_value("sort=name:asc; filter=foo"), "foo",);
}
#[tokio::test]
async fn cycle_saved_view_wraps_forward_through_saved_views() {
let mut app = test_app();
app.saved_views
.insert("dev".into(), super::encode_filter_only_view("tag:env=dev"));
app.saved_views.insert(
"prod".into(),
super::encode_filter_only_view("tag:env=prod"),
);
app.saved_views.insert(
"staging".into(),
super::encode_filter_only_view("tag:env=staging"),
);
app.filter = "tag:env=dev".into();
app.cycle_saved_view(1);
assert_eq!(app.filter, "tag:env=prod");
app.cycle_saved_view(1);
assert_eq!(app.filter, "tag:env=staging");
app.cycle_saved_view(1);
assert_eq!(app.filter, "tag:env=dev");
}
#[tokio::test]
async fn cycle_saved_view_wraps_backward_and_handles_no_active() {
let mut app = test_app();
app.saved_views
.insert("dev".into(), super::encode_filter_only_view("tag:env=dev"));
app.saved_views.insert(
"staging".into(),
super::encode_filter_only_view("tag:env=staging"),
);
app.filter = "tag:env=dev".into();
app.cycle_saved_view(-1);
assert_eq!(app.filter, "tag:env=staging");
app.filter = "some-random-text".into();
app.cycle_saved_view(1);
assert_eq!(app.filter, "tag:env=dev", "forward-with-no-active → first");
app.filter = "some-random-text".into();
app.cycle_saved_view(-1);
assert_eq!(
app.filter, "tag:env=staging",
"backward-with-no-active → last"
);
}
#[tokio::test]
async fn cycle_saved_view_noop_with_empty_views() {
let mut app = test_app();
app.filter = "keep-me".into();
app.cycle_saved_view(1);
assert_eq!(app.filter, "keep-me");
}
#[tokio::test]
async fn cycle_saved_view_with_full_view_applies_sort_and_group_too() {
let mut app = test_app();
app.saved_views
.insert("dev".into(), super::encode_filter_only_view("tag:env=dev"));
app.saved_views.insert(
"by-app".into(),
"filter=tag:env=prod;sort=app:asc;grouped=true;scope=envs".into(),
);
app.filter = "tag:env=dev".into();
app.grouped = false;
app.cycle_saved_view(1); assert_eq!(app.filter, "tag:env=prod");
assert!(
app.grouped,
"full view must apply its grouped=true alongside the filter"
);
}
#[tokio::test]
async fn ssh_with_instance_id_arg_queues_pending_shell_target() {
let mut app = test_app();
app.execute_command("ssh i-0abc1234567890def");
assert_eq!(
app.pending_shell_target.as_deref(),
Some("i-0abc1234567890def")
);
assert!(
app.error_message.is_none(),
"unexpected: {:?}",
app.error_message
);
assert!(
app.mode == Mode::Normal,
"ssh-with-arg should not change mode"
);
}
#[tokio::test]
async fn ssh_rejects_non_instance_id_arg() {
let mut app = test_app();
app.execute_command("ssh staging-web");
assert!(app.pending_shell_target.is_none());
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("instance ID") && err.contains("staging-web"),
"expected guidance + offending value, got: {err}"
);
}
#[tokio::test]
async fn ssh_no_arg_without_detail_errors_clearly() {
let mut app = test_app();
app.execute_command("ssh");
assert!(app.picker.is_none());
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("Detail") || err.contains("instance ID"),
"expected guidance about Detail/Instances or instance ID, got: {err}"
);
}
#[test]
fn deploy_snapshot_round_trips_through_persisted_form() {
use chrono::TimeZone;
let original = DeploySnapshot {
env_name: "prod-api".into(),
previous_version_label: "build-825".into(),
taken_at: chrono::Utc
.with_ymd_and_hms(2026, 5, 25, 14, 30, 0)
.unwrap(),
};
let raw = original.to_persisted();
assert_eq!(raw, "build-825|2026-05-25T14:30:00+00:00");
let parsed = DeploySnapshot::parse_persisted("prod-api", &raw).expect("parses");
assert_eq!(parsed.env_name, original.env_name);
assert_eq!(
parsed.previous_version_label,
original.previous_version_label
);
assert_eq!(parsed.taken_at, original.taken_at);
}
#[test]
fn deploy_snapshot_parse_persisted_rejects_garbage() {
assert!(DeploySnapshot::parse_persisted("e", "nopipe").is_none());
assert!(DeploySnapshot::parse_persisted("e", "|2026-05-25T14:30:00Z").is_none());
assert!(DeploySnapshot::parse_persisted("e", "label|not-a-timestamp").is_none());
assert!(DeploySnapshot::parse_persisted("e", "label|").is_none());
}
#[tokio::test]
async fn rebuild_clears_armed_watchdogs_and_snapshots() {
let mut app = test_app();
let now = chrono::Utc::now();
app.armed_watchdogs.insert(
"prod".into(),
ArmedWatchdog {
env_name: "prod".into(),
target_label: "build-old".into(),
armed_at: now,
deadline_at: now + chrono::Duration::seconds(300),
},
);
app.deploy_snapshots.insert(
"prod".into(),
DeploySnapshot {
env_name: "prod".into(),
previous_version_label: "build-old".into(),
taken_at: now,
},
);
app.apply_rebuild(Ok(Box::new(crate::aws::AwsClient::stub())));
assert!(
app.armed_watchdogs.is_empty(),
"context switch should drop armed watchdogs"
);
assert!(
app.deploy_snapshots.is_empty(),
"context switch should drop deploy snapshots"
);
}
#[tokio::test]
async fn rollback_to_label_opens_confirm_for_named_label() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Red")];
app.rebuild_view();
app.table_state.select(Some(0));
app.deploy_snapshots.insert(
"prod".into(),
DeploySnapshot {
env_name: "prod".into(),
previous_version_label: "build-snap".into(),
taken_at: chrono::Utc::now(),
},
);
app.execute_command("rollback --to build-820");
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert_eq!(modal.deploy_version.as_deref(), Some("build-820"));
assert!(modal.auto_rollback_secs.is_none());
}
_ => panic!("expected confirm modal open"),
}
}
#[tokio::test]
async fn rollback_to_label_with_auto_rollback_threads_secs_through() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Red")];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("rollback --to build-820 --auto-rollback 5m");
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert_eq!(modal.deploy_version.as_deref(), Some("build-820"));
assert_eq!(modal.auto_rollback_secs, Some(300));
}
_ => panic!("expected confirm modal open"),
}
}
#[tokio::test]
async fn rollback_to_same_label_as_deployed_refuses() {
let mut app = test_app();
let mut env = mk_env("prod", "shop", "Web", "Red");
env.version_label = "build-822".into();
app.environments = vec![env];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("rollback --to build-822");
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("already the deployed version"),
"expected idempotent guard, got: {err}"
);
assert!(app.action_flow.is_none(), "no confirm modal on no-op");
}
#[tokio::test]
async fn rollback_auto_rollback_without_snapshot_errors_clearly() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Red")];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("rollback --auto-rollback 5m");
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("needs an in-memory snapshot") && err.contains("--to LABEL"),
"expected refusal + hint, got: {err}"
);
}
#[tokio::test]
async fn deploy_wait_for_green_threads_secs_through_to_modal() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("deploy build-900 --wait-for-green 5m");
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert_eq!(modal.deploy_version.as_deref(), Some("build-900"));
assert_eq!(modal.wait_for_green_secs, Some(300));
assert!(modal.auto_rollback_secs.is_none());
}
_ => panic!("expected confirm modal open"),
}
}
#[tokio::test]
async fn deploy_wait_for_green_rejects_malformed_duration() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("deploy build-900 --wait-for-green forever");
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("--wait-for-green") && err.contains("duration"),
"expected parse refusal, got: {err}"
);
assert!(
app.action_flow.is_none(),
"no modal should open on malformed duration"
);
}
#[tokio::test]
async fn deploy_with_both_flags_threads_both_through() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("deploy build-900 --auto-rollback 10m --wait-for-green 5m");
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert_eq!(modal.deploy_version.as_deref(), Some("build-900"));
assert_eq!(modal.auto_rollback_secs, Some(600));
assert_eq!(modal.wait_for_green_secs, Some(300));
}
_ => panic!("expected confirm modal open"),
}
}
#[tokio::test]
async fn apply_refresh_keeps_watching_when_status_is_updating_even_if_health_is_green() {
let mut app = test_app();
let now = chrono::Utc::now();
app.watching_deploys.insert(
"prod".into(),
WatchingDeploy {
env_name: "prod".into(),
target_label: "build-900".into(),
armed_at: now,
deadline_at: now + chrono::Duration::seconds(300),
},
);
let mut env = mk_env("prod", "shop", "Web", "Green");
env.status = "Updating".into();
app.apply_refresh(Ok(vec![env]));
assert!(
app.watching_deploys.contains_key("prod"),
"Updating+Green is mid-deploy — watcher must remain armed"
);
let pinned = app.status_message.as_deref().unwrap_or("");
assert!(
!pinned.contains("reached Green"),
"must not pin success during Updating, got: {pinned:?}"
);
}
#[tokio::test]
async fn apply_refresh_keeps_armed_watchdog_when_status_is_updating_even_if_health_is_green() {
let mut app = test_app();
let now = chrono::Utc::now();
app.deploy_snapshots.insert(
"prod".into(),
DeploySnapshot {
env_name: "prod".into(),
previous_version_label: "build-820".into(),
taken_at: now,
},
);
app.armed_watchdogs.insert(
"prod".into(),
ArmedWatchdog {
env_name: "prod".into(),
target_label: "build-820".into(),
armed_at: now,
deadline_at: now + chrono::Duration::seconds(300),
},
);
let mut env = mk_env("prod", "shop", "Web", "Green");
env.status = "Updating".into();
app.apply_refresh(Ok(vec![env]));
assert!(
app.armed_watchdogs.contains_key("prod"),
"Updating+Green is mid-deploy — watchdog must remain armed"
);
}
#[test]
fn deploy_settled_green_requires_both_status_ready_and_health_green_or_ok() {
assert!(super::deploy_settled_green("Ready", "Green"));
assert!(super::deploy_settled_green("Ready", "Ok"));
assert!(super::deploy_settled_green("ready", "green")); assert!(super::deploy_settled_green("READY", "OK"));
assert!(!super::deploy_settled_green("Updating", "Green"));
assert!(!super::deploy_settled_green("Launching", "Ok"));
assert!(!super::deploy_settled_green("Terminating", "Green"));
assert!(!super::deploy_settled_green("Ready", "Red"));
assert!(!super::deploy_settled_green("Ready", "Yellow"));
assert!(!super::deploy_settled_green("Ready", "Severe"));
assert!(!super::deploy_settled_green("", ""));
}
#[test]
fn build_health_check_probe_url_normalises_path() {
assert_eq!(
super::build_health_check_probe_url("api.example.com", "/healthz"),
"http://api.example.com/healthz"
);
assert_eq!(
super::build_health_check_probe_url("api.example.com", "healthz"),
"http://api.example.com/healthz"
);
assert_eq!(
super::build_health_check_probe_url("api.example.com", ""),
"http://api.example.com/"
);
assert_eq!(
super::build_health_check_probe_url("api.example.com", "/"),
"http://api.example.com/"
);
}
#[test]
fn classify_health_check_status_treats_2xx_as_ok_and_others_as_warning() {
assert!(super::classify_health_check_status(200).is_ok());
assert!(super::classify_health_check_status(201).is_ok());
assert!(super::classify_health_check_status(299).is_ok());
let err = super::classify_health_check_status(0).unwrap_err();
assert!(err.contains("no response"));
let err = super::classify_health_check_status(301).unwrap_err();
assert!(err.contains("301"));
let err = super::classify_health_check_status(404).unwrap_err();
assert!(err.contains("404"));
let err = super::classify_health_check_status(503).unwrap_err();
assert!(err.contains("503"));
}
#[test]
fn compute_unavailability_count_per_policy() {
assert_eq!(
super::compute_unavailability_count("AllAtOnce", 1, "Fixed", 4),
4
);
assert_eq!(
super::compute_unavailability_count("Rolling", 1, "Fixed", 4),
1
);
assert_eq!(
super::compute_unavailability_count("Rolling", 2, "Fixed", 4),
2
);
assert_eq!(
super::compute_unavailability_count("Rolling", 50, "Percentage", 4),
2
);
assert_eq!(
super::compute_unavailability_count("Rolling", 33, "Percentage", 4),
2
);
assert_eq!(
super::compute_unavailability_count("RollingWithAdditionalBatch", 1, "Fixed", 4),
0
);
assert_eq!(
super::compute_unavailability_count("Immutable", 1, "Fixed", 4),
0
);
assert_eq!(
super::compute_unavailability_count("TrafficSplitting", 1, "Fixed", 4),
0
);
assert_eq!(
super::compute_unavailability_count("WeirdCustomPolicy", 1, "Fixed", 4),
4
);
assert_eq!(
super::compute_unavailability_count("allatonce", 1, "Fixed", 4),
4
);
}
#[test]
fn compute_batch_count_clamps_and_rounds_up() {
assert_eq!(super::compute_batch_count(0, "Fixed", 4), 1);
assert_eq!(super::compute_batch_count(10, "Fixed", 4), 4);
assert_eq!(super::compute_batch_count(2, "Fixed", 4), 2);
assert_eq!(super::compute_batch_count(33, "Percentage", 4), 2); assert_eq!(super::compute_batch_count(25, "Percentage", 4), 1);
assert_eq!(super::compute_batch_count(26, "Percentage", 4), 2); assert_eq!(super::compute_batch_count(100, "Percentage", 4), 4);
assert_eq!(super::compute_batch_count(0, "Percentage", 4), 1);
assert_eq!(super::compute_batch_count(200, "Percentage", 4), 4);
}
#[test]
fn format_unavailability_line_distinguishes_zero_from_partial_from_full() {
let (text, caution) = super::format_unavailability_line("Immutable", 0, 4);
assert!(text.contains("no in-service unavailability"));
assert!(!caution);
let (text, caution) = super::format_unavailability_line("Rolling", 1, 4);
assert!(text.contains("max 1/4 instance unavailable"));
assert!(caution);
let (text, caution) = super::format_unavailability_line("AllAtOnce", 4, 4);
assert!(text.contains("max 4/4 instances unavailable"));
assert!(caution);
}
#[test]
fn extract_unavailability_inputs_uses_eb_defaults_on_missing_settings() {
let (policy, batch, btype, asg) = super::extract_unavailability_inputs(&[]);
assert_eq!(policy, "AllAtOnce");
assert_eq!(batch, 1);
assert_eq!(btype, "Fixed");
assert_eq!(asg, 1);
let opts = vec![("aws:autoscaling:asg".into(), "MaxSize".into(), "6".into())];
let (_, _, _, asg) = super::extract_unavailability_inputs(&opts);
assert_eq!(asg, 6);
let opts = vec![(
"aws:elasticbeanstalk:command".into(),
"DeploymentPolicy".into(),
String::new(),
)];
let (policy, _, _, _) = super::extract_unavailability_inputs(&opts);
assert_eq!(policy, "AllAtOnce");
}
#[tokio::test]
async fn handle_unavailability_estimate_stuffs_line_into_modal() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("deploy build-900");
app.handle_msg(AppMsg::UnavailabilityEstimate {
gen: app.generation,
env_name: "prod".into(),
line: Some((
"deploy plan: Rolling → max 1/4 instance unavailable".into(),
true,
)),
});
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert!(!modal.loading_unavailability);
let (text, caution) = modal.unavailability_line.as_ref().unwrap();
assert!(text.contains("max 1/4"));
assert!(*caution);
}
_ => panic!("expected confirm modal"),
}
}
#[test]
fn build_undo_entry_set_with_prior_value_reverses_to_set() {
let pre = vec![(
"aws:autoscaling:launchconfiguration".into(),
"EC2KeyName".into(),
"bar".into(),
)];
let to_set = vec![(
"aws:autoscaling:launchconfiguration".into(),
"EC2KeyName".into(),
"foo".into(),
)];
let entry = super::build_undo_entry("prod", "keypair foo", &to_set, &[], &pre);
assert_eq!(entry.to_set.len(), 1);
assert_eq!(entry.to_set[0].2, "bar");
assert!(entry.to_remove.is_empty());
assert_eq!(entry.env_name, "prod");
assert_eq!(entry.original_summary, "keypair foo");
}
#[test]
fn build_undo_entry_set_with_no_prior_value_reverses_to_remove() {
let pre: Vec<(String, String, String)> = vec![];
let to_set = vec![(
"aws:elasticbeanstalk:application".into(),
"Application Healthcheck URL".into(),
"/healthz".into(),
)];
let entry =
super::build_undo_entry("prod", "health-check-url /healthz", &to_set, &[], &pre);
assert!(entry.to_set.is_empty());
assert_eq!(entry.to_remove.len(), 1);
assert_eq!(entry.to_remove[0].1, "Application Healthcheck URL");
}
#[test]
fn build_undo_entry_empty_string_prior_treated_as_unset() {
let pre = vec![(
"aws:autoscaling:launchconfiguration".into(),
"EC2KeyName".into(),
String::new(),
)];
let to_set = vec![(
"aws:autoscaling:launchconfiguration".into(),
"EC2KeyName".into(),
"foo".into(),
)];
let entry = super::build_undo_entry("prod", "keypair foo", &to_set, &[], &pre);
assert!(entry.to_set.is_empty());
assert_eq!(entry.to_remove.len(), 1);
}
#[test]
fn build_undo_entry_remove_with_prior_value_reverses_to_set() {
let pre = vec![(
"aws:autoscaling:launchconfiguration".into(),
"EC2KeyName".into(),
"bar".into(),
)];
let to_remove = vec![(
"aws:autoscaling:launchconfiguration".into(),
"EC2KeyName".into(),
)];
let entry = super::build_undo_entry("prod", "clear keypair", &[], &to_remove, &pre);
assert_eq!(entry.to_set.len(), 1);
assert_eq!(entry.to_set[0].2, "bar");
assert!(entry.to_remove.is_empty());
}
#[test]
fn build_undo_entry_remove_with_no_prior_value_is_a_noop_reverse() {
let entry = super::build_undo_entry(
"prod",
"clear keypair",
&[],
&[(
"aws:autoscaling:launchconfiguration".into(),
"EC2KeyName".into(),
)],
&[],
);
assert!(entry.to_set.is_empty());
assert!(entry.to_remove.is_empty());
}
#[tokio::test]
async fn batch_set_option_skips_envs_no_longer_in_view() {
let mut app = test_app();
app.environments = vec![mk_env("prod-api", "shop", "Web", "Green")];
app.rebuild_view();
app.spawn_batch_set_option(
"vanished".into(),
"aws:elasticbeanstalk:application".into(),
"Application Healthcheck URL".into(),
"/healthz".into(),
);
assert!(
app.pending_actions.iter().all(|p| p.target != "vanished"),
"expected no pending action for vanished env"
);
}
#[tokio::test]
async fn handle_undo_captured_pushes_into_history_with_cap() {
let mut app = test_app();
for i in 0..(super::UNDO_HISTORY_CAP + 2) {
let entry = super::UndoEntry {
env_name: "prod".into(),
to_set: vec![("ns".into(), format!("k{i}"), "v".into())],
to_remove: vec![],
original_summary: format!("write #{i}"),
captured_at: chrono::Utc::now(),
};
app.handle_msg(AppMsg::UndoCaptured {
gen: app.generation,
entry,
});
}
assert_eq!(app.undo_history.len(), super::UNDO_HISTORY_CAP);
assert_eq!(
app.undo_history.front().unwrap().original_summary,
"write #2"
);
assert_eq!(
app.undo_history.back().unwrap().original_summary,
format!("write #{}", super::UNDO_HISTORY_CAP + 1)
);
}
#[tokio::test]
async fn cmd_undo_with_empty_history_hints_at_the_buffer() {
let mut app = test_app();
app.execute_command("undo");
let status = app.status_message.as_deref().unwrap_or("");
assert!(
status.contains("no undo history"),
"expected empty-history hint, got: {status}"
);
}
#[tokio::test]
async fn cmd_undo_with_no_op_reverse_surfaces_clearly() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
app.rebuild_view();
app.undo_history.push_back(super::UndoEntry {
env_name: "prod".into(),
to_set: vec![],
to_remove: vec![],
original_summary: "keypair foo".into(),
captured_at: chrono::Utc::now(),
});
app.execute_command("undo");
let status = app.status_message.as_deref().unwrap_or("");
assert!(
status.contains("prior state was identical"),
"expected no-op hint, got: {status}"
);
}
#[tokio::test]
async fn cmd_undo_uses_display_row_index_not_envs_vec_index() {
let mut app = test_app();
let mut prod_api = mk_env("prod-api", "shop", "Web", "Green");
prod_api.application = "shop".into();
let mut staging_api = mk_env("staging-api", "shop", "Web", "Green");
staging_api.application = "shop".into();
let mut prod_web = mk_env("prod-web", "shop", "Web", "Green");
prod_web.application = "shop".into();
app.environments = vec![prod_api, staging_api, prod_web];
app.filter = "prod-".into();
app.rebuild_view();
app.undo_history.push_back(super::UndoEntry {
env_name: "prod-web".into(),
to_set: vec![(
"aws:autoscaling:launchconfiguration".into(),
"EC2KeyName".into(),
"bar".into(),
)],
to_remove: vec![],
original_summary: "keypair foo".into(),
captured_at: chrono::Utc::now(),
});
app.table_state.select(Some(0));
app.execute_command("undo");
assert!(
app.error_message.is_none(),
"expected dispatch to succeed, got error: {:?}",
app.error_message
);
assert_eq!(
app.table_state.selected(),
Some(0),
"cursor must be restored to the prior selection"
);
}
#[tokio::test]
async fn cmd_undo_refuses_with_hint_when_env_filtered_out() {
let mut app = test_app();
app.environments = vec![
mk_env("prod-api", "shop", "Web", "Green"),
mk_env("staging-api", "shop", "Web", "Green"),
];
app.filter = "staging-".into();
app.rebuild_view();
app.undo_history.push_back(super::UndoEntry {
env_name: "prod-api".into(),
to_set: vec![("ns".into(), "k".into(), "v".into())],
to_remove: vec![],
original_summary: "keypair foo".into(),
captured_at: chrono::Utc::now(),
});
app.execute_command("undo");
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("filtered out") && err.contains("clear the filter"),
"expected filter hint, got: {err}"
);
assert_eq!(
app.undo_history.len(),
1,
"entry must be put back on the deque so the operator can retry"
);
}
#[tokio::test]
async fn cmd_undo_refuses_when_target_env_no_longer_visible() {
let mut app = test_app();
app.undo_history.push_back(super::UndoEntry {
env_name: "vanished".into(),
to_set: vec![("ns".into(), "k".into(), "v".into())],
to_remove: vec![],
original_summary: "keypair foo".into(),
captured_at: chrono::Utc::now(),
});
app.execute_command("undo");
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("no longer in the current view"),
"expected missing-env refusal, got: {err}"
);
}
#[test]
fn expand_command_alias_pass_through_when_no_match() {
use std::collections::HashMap;
let mut aliases = HashMap::new();
aliases.insert("dp".to_string(), "deploy --auto-rollback 5m".to_string());
assert_eq!(super::expand_command_alias("rebuild", &aliases), "rebuild");
assert_eq!(
super::expand_command_alias("deploy build-x", &HashMap::new()),
"deploy build-x"
);
}
#[test]
fn expand_command_alias_swaps_first_token_and_keeps_args() {
use std::collections::HashMap;
let mut aliases = HashMap::new();
aliases.insert("dp".to_string(), "deploy --auto-rollback 5m".to_string());
assert_eq!(
super::expand_command_alias("dp build-900", &aliases),
"deploy --auto-rollback 5m build-900"
);
assert_eq!(
super::expand_command_alias("dp", &aliases),
"deploy --auto-rollback 5m"
);
}
#[test]
fn expand_command_alias_does_not_chain_transitively() {
use std::collections::HashMap;
let mut aliases = HashMap::new();
aliases.insert("a".to_string(), "b stuff".to_string());
aliases.insert("b".to_string(), "c things".to_string());
assert_eq!(super::expand_command_alias("a", &aliases), "b stuff");
let mut aliases = HashMap::new();
aliases.insert("loop".to_string(), "loop forever".to_string());
assert_eq!(
super::expand_command_alias("loop", &aliases),
"loop forever"
);
}
#[tokio::test]
async fn execute_command_uses_command_aliases() {
let mut app = test_app();
app.command_aliases
.insert("emergency".into(), "freeze-deploys incident #1234".into());
app.execute_command("emergency");
assert!(app.deploy_freeze.is_some());
let reason = app
.deploy_freeze
.as_ref()
.map(|f| f.reason.clone())
.unwrap();
assert_eq!(reason, "incident #1234");
}
#[tokio::test]
async fn freeze_deploys_blocks_writes_with_reason_surfaced() {
let mut app = test_app();
app.execute_command("freeze-deploys incident #1234");
assert!(app.deploy_freeze.is_some(), "freeze should be set");
assert!(
app.is_read_only_for("any-env"),
"freeze must block every env"
);
let reason = app.read_only_reason("any-env").unwrap_or_default();
assert!(
reason.contains("deploys frozen") && reason.contains("incident #1234"),
"expected reason to surface, got: {reason}"
);
}
#[tokio::test]
async fn freeze_deploys_with_no_reason_still_blocks() {
let mut app = test_app();
app.execute_command("freeze-deploys");
assert!(app.deploy_freeze.is_some());
let reason = app.read_only_reason("env").unwrap_or_default();
assert!(
reason.contains("deploys frozen") && !reason.contains(": "),
"no-reason wording shouldn't include `: <reason>`, got: {reason}"
);
}
#[tokio::test]
async fn thaw_deploys_clears_the_freeze() {
let mut app = test_app();
app.execute_command("freeze-deploys testing");
assert!(app.deploy_freeze.is_some());
app.execute_command("thaw-deploys");
assert!(app.deploy_freeze.is_none(), "thaw should clear freeze");
assert!(
!app.is_read_only_for("env"),
"thaw must restore writes (no other locks set in this test)"
);
}
#[tokio::test]
async fn re_freezing_updates_the_reason_in_place() {
let mut app = test_app();
app.execute_command("freeze-deploys rolling back");
app.execute_command("freeze-deploys rolling back — PROD only");
let reason = app
.deploy_freeze
.as_ref()
.map(|f| f.reason.clone())
.unwrap();
assert_eq!(reason, "rolling back — PROD only");
}
#[tokio::test]
async fn freeze_overrides_per_env_pin_in_read_only_reason() {
let mut app = test_app();
app.safety_envs.insert("prod".into(), true);
app.execute_command("freeze-deploys incident");
let reason = app.read_only_reason("prod").unwrap_or_default();
assert!(
reason.contains("deploys frozen"),
"freeze reason must win over per-env pin, got: {reason}"
);
}
#[tokio::test]
async fn handle_unavailability_estimate_silent_on_fetch_failure() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("deploy build-900");
app.handle_msg(AppMsg::UnavailabilityEstimate {
gen: app.generation,
env_name: "prod".into(),
line: None,
});
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert!(!modal.loading_unavailability);
assert!(modal.unavailability_line.is_none());
}
_ => panic!("expected confirm modal"),
}
}
#[tokio::test]
async fn handle_health_check_probe_renders_warning_on_failure() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("deploy build-900");
app.handle_msg(AppMsg::HealthCheckProbe {
gen: app.generation,
env_name: "prod".into(),
result: Err("HTTP 404".into()),
});
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert!(!modal.loading_health_check);
assert_eq!(
modal.health_check_probe.as_ref().map(|r| r.is_err()),
Some(true)
);
}
_ => panic!("expected confirm modal open"),
}
}
#[tokio::test]
async fn handle_health_check_probe_silent_on_ok() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("deploy build-900");
app.handle_msg(AppMsg::HealthCheckProbe {
gen: app.generation,
env_name: "prod".into(),
result: Ok(()),
});
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert!(!modal.loading_health_check);
assert_eq!(
modal.health_check_probe.as_ref().map(|r| r.is_ok()),
Some(true)
);
}
_ => panic!("expected confirm modal open"),
}
}
#[tokio::test]
async fn apply_refresh_drains_watching_deploy_on_green() {
let mut app = test_app();
let now = chrono::Utc::now();
app.watching_deploys.insert(
"prod".into(),
WatchingDeploy {
env_name: "prod".into(),
target_label: "build-900".into(),
armed_at: now,
deadline_at: now + chrono::Duration::seconds(300),
},
);
app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Green")]));
assert!(
app.watching_deploys.is_empty(),
"Green should drain the watcher"
);
let pinned = app.status_message.as_deref().unwrap_or("");
assert!(
pinned.contains("reached Green") && pinned.contains("prod"),
"expected pinned success status, got: {pinned:?}"
);
}
#[tokio::test]
async fn apply_refresh_drains_watching_deploy_on_timeout() {
let mut app = test_app();
let now = chrono::Utc::now();
app.watching_deploys.insert(
"prod".into(),
WatchingDeploy {
env_name: "prod".into(),
target_label: "build-900".into(),
armed_at: now - chrono::Duration::seconds(600),
deadline_at: now - chrono::Duration::seconds(60),
},
);
app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Red")]));
assert!(
app.watching_deploys.is_empty(),
"expired watcher should drain on timeout"
);
let pinned = app.error_message.as_deref().unwrap_or("");
assert!(
pinned.contains("did not reach Green") && pinned.contains("prod"),
"expected pinned timeout error, got: {pinned:?}"
);
}
#[tokio::test]
async fn rebuild_clears_watching_deploys() {
let mut app = test_app();
app.watching_deploys.insert(
"prod".into(),
WatchingDeploy {
env_name: "prod".into(),
target_label: "build-900".into(),
armed_at: chrono::Utc::now(),
deadline_at: chrono::Utc::now() + chrono::Duration::seconds(300),
},
);
app.apply_rebuild(Ok(Box::new(crate::aws::AwsClient::stub())));
assert!(
app.watching_deploys.is_empty(),
"watching_deploys must clear on context rebuild"
);
}
#[test]
fn soonest_watching_deploy_picks_earliest_deadline() {
let mut map: std::collections::HashMap<String, WatchingDeploy> =
std::collections::HashMap::new();
let now = chrono::Utc::now();
map.insert(
"later".into(),
WatchingDeploy {
env_name: "later".into(),
target_label: "v2".into(),
armed_at: now,
deadline_at: now + chrono::Duration::seconds(600),
},
);
map.insert(
"sooner".into(),
WatchingDeploy {
env_name: "sooner".into(),
target_label: "v1".into(),
armed_at: now,
deadline_at: now + chrono::Duration::seconds(120),
},
);
let (env, _remaining) = soonest_watching_deploy(&map, now).expect("not empty");
assert_eq!(env, "sooner");
}
#[tokio::test]
async fn promote_env_opens_deploy_confirm_on_target_with_sources_version() {
let mut app = test_app();
let mut staging = mk_env("staging", "shop", "Web", "Green");
staging.version_label = "build-900".into();
let mut prod = mk_env("prod", "shop", "Web", "Green");
prod.version_label = "build-820".into();
app.environments = vec![staging, prod];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("promote-env staging prod");
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert_eq!(modal.target_env, "prod");
assert_eq!(modal.deploy_version.as_deref(), Some("build-900"));
assert!(matches!(modal.action, Action::Deploy));
}
_ => panic!("expected confirm modal open on target"),
}
}
#[tokio::test]
async fn promote_env_composes_with_watchdog_flags() {
let mut app = test_app();
let mut staging = mk_env("staging", "shop", "Web", "Green");
staging.version_label = "build-900".into();
let prod = mk_env("prod", "shop", "Web", "Green");
app.environments = vec![staging, prod];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("promote-env staging prod --auto-rollback 10m --wait-for-green 5m");
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert_eq!(modal.target_env, "prod");
assert_eq!(modal.deploy_version.as_deref(), Some("build-900"));
assert_eq!(modal.auto_rollback_secs, Some(600));
assert_eq!(modal.wait_for_green_secs, Some(300));
}
_ => panic!("expected confirm modal open"),
}
}
#[tokio::test]
async fn promote_env_refuses_when_versions_match() {
let mut app = test_app();
let mut staging = mk_env("staging", "shop", "Web", "Green");
staging.version_label = "build-900".into();
let mut prod = mk_env("prod", "shop", "Web", "Green");
prod.version_label = "build-900".into();
app.environments = vec![staging, prod];
app.rebuild_view();
app.execute_command("promote-env staging prod");
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("already deployed to prod"),
"expected idempotent guard, got: {err}"
);
assert!(app.action_flow.is_none(), "no modal on no-op");
}
#[tokio::test]
async fn promote_env_refuses_when_source_has_no_version() {
let mut app = test_app();
let mut staging = mk_env("staging", "shop", "Web", "Pending");
staging.version_label = String::new();
let prod = mk_env("prod", "shop", "Web", "Green");
app.environments = vec![staging, prod];
app.rebuild_view();
app.execute_command("promote-env staging prod");
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("no version deployed"),
"expected no-version refusal, got: {err}"
);
}
#[tokio::test]
async fn promote_env_refuses_same_source_and_target() {
let mut app = test_app();
let mut staging = mk_env("staging", "shop", "Web", "Green");
staging.version_label = "build-900".into();
app.environments = vec![staging];
app.rebuild_view();
app.execute_command("promote-env staging staging");
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("must be different"),
"expected same-env refusal, got: {err}"
);
}
#[tokio::test]
async fn promote_env_refuses_unknown_env() {
let mut app = test_app();
let mut staging = mk_env("staging", "shop", "Web", "Green");
staging.version_label = "build-900".into();
app.environments = vec![staging];
app.rebuild_view();
app.execute_command("promote-env staging nope");
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("no env named 'nope'"),
"expected unknown-env refusal, got: {err}"
);
}
#[tokio::test]
async fn deploy_modal_opens_with_version_preview_loading_flag_set() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("deploy build-900");
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert!(
modal.loading_version_preview,
"Deploy modal must reserve space for the inline preview"
);
assert!(modal.version_preview.is_none());
}
_ => panic!("expected confirm modal open"),
}
}
#[tokio::test]
async fn deploy_modal_handle_version_preview_stuffs_body_in_slot() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("deploy build-900");
let body = "candidate: build-900\ncurrent: build-820\n".to_string();
app.handle_msg(AppMsg::VersionPreview {
gen: app.generation,
env_name: "prod".into(),
result: Ok(body.clone()),
});
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert!(!modal.loading_version_preview);
assert_eq!(modal.version_preview.as_deref(), Some(body.as_str()));
}
_ => panic!("expected confirm modal still open"),
}
}
#[tokio::test]
async fn deploy_modal_handle_version_preview_error_renders_inline() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("deploy build-900");
app.handle_msg(AppMsg::VersionPreview {
gen: app.generation,
env_name: "prod".into(),
result: Err("ListApplicationVersions throttled".into()),
});
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert!(!modal.loading_version_preview);
let preview = modal.version_preview.as_deref().unwrap_or("");
assert!(
preview.contains("version preview unavailable")
&& preview.contains("throttled"),
"expected inline error, got: {preview}"
);
}
_ => panic!("expected confirm modal still open"),
}
}
#[tokio::test]
async fn handle_confirm_modal_lint_stuffs_issues_into_modal() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
app.rebuild_view();
app.table_state.select(Some(0));
app.execute_command("deploy build-900");
app.handle_msg(AppMsg::ConfirmModalLint {
gen: app.generation,
env_name: "prod".into(),
issues: vec![],
});
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert!(!modal.loading_lint, "loading flag should clear");
assert_eq!(modal.lint_issues.as_ref().map(|v| v.len()), Some(0));
}
_ => panic!("expected confirm modal open"),
}
}
#[tokio::test]
async fn handle_confirm_modal_lint_drops_stale_target_results() {
let mut app = test_app();
app.environments = vec![
mk_env("prod", "shop", "Web", "Green"),
mk_env("staging", "shop", "Web", "Green"),
];
app.rebuild_view();
app.table_state.select(Some(1));
app.execute_command("deploy build-900");
app.handle_msg(AppMsg::ConfirmModalLint {
gen: app.generation,
env_name: "prod".into(),
issues: vec![crate::lint::Issue {
rule_id: "EBL001".into(),
severity: crate::lint::Severity::Warn,
env_name: Some("prod".into()),
title: "stale".into(),
detail: "stale".into(),
suggestion: None,
fields: Default::default(),
}],
});
match &app.action_flow {
Some(ActionFlow::Confirm(modal)) => {
assert!(
modal.loading_lint,
"loading flag must stay true on stale result"
);
assert!(
modal.lint_issues.is_none(),
"stale result must not populate"
);
}
_ => panic!("expected confirm modal open"),
}
}
#[tokio::test]
async fn refresh_tf_managed_envs_derives_set_from_tf_state() {
let mut app = test_app();
assert!(app.tf_managed_envs.is_empty(), "starts empty");
app.tf_state = Some(crate::terraform::TfState {
envs: vec![
crate::terraform::TfEnv {
name: "prod-api".into(),
application: "shop".into(),
version_label: "build-820".into(),
options: vec![],
tags: Default::default(),
},
crate::terraform::TfEnv {
name: "prod-web".into(),
application: "shop".into(),
version_label: "build-820".into(),
options: vec![],
tags: Default::default(),
},
],
});
app.refresh_tf_managed_envs();
assert_eq!(app.tf_managed_envs.len(), 2);
assert!(app.tf_managed_envs.contains("prod-api"));
assert!(app.tf_managed_envs.contains("prod-web"));
assert!(!app.tf_managed_envs.contains("staging-api"));
app.tf_state = None;
app.refresh_tf_managed_envs();
assert!(app.tf_managed_envs.is_empty());
}
#[tokio::test]
async fn cmd_drift_refresh_reloads_tf_state_and_pins_status() {
let mut app = test_app();
app.environments = vec![mk_env("prod-api", "shop", "Web", "Green")];
app.rebuild_view();
app.execute_command("drift refresh");
let msg = app.status_message.as_deref().unwrap_or("");
assert!(
msg.contains("tfstate"),
"expected tfstate status, got: {msg}"
);
}
#[tokio::test]
async fn cmd_drift_with_no_tfstate_loaded_hints_at_discovery() {
let mut app = test_app();
app.environments = vec![mk_env("prod-api", "shop", "Web", "Green")];
app.rebuild_view();
app.table_state.select(Some(0));
app.tf_state = None;
app.execute_command("drift");
let msg = app.status_message.as_deref().unwrap_or("");
assert!(
msg.contains("no terraform.tfstate found"),
"expected discovery hint, got: {msg}"
);
}
#[test]
fn render_lint_overlay_empty_shows_clean_stub() {
let body = crate::app::cmd_misc::render_lint_overlay("prod-api", &[]);
assert!(body.contains("prod-api"));
assert!(body.contains("✓ No issues found"));
assert!(body.contains("esc / q to close"));
}
#[test]
fn render_lint_overlay_with_issues_renders_per_severity_glyph() {
use crate::lint::{Issue, Severity};
use std::collections::BTreeMap;
let issues = vec![
Issue {
rule_id: "EBL001".into(),
severity: Severity::Warn,
env_name: Some("prod".into()),
title: "AllAtOnce on 4-instance env".into(),
detail: "Deployment policy AllAtOnce with MaxSize=4 means full unavailability."
.into(),
suggestion: Some(":deployment-policy Rolling".into()),
fields: BTreeMap::new(),
},
Issue {
rule_id: "EBL005".into(),
severity: Severity::Info,
env_name: Some("prod".into()),
title: "Single-instance env".into(),
detail: "MinSize=MaxSize=1.".into(),
suggestion: None,
fields: BTreeMap::new(),
},
];
let body = crate::app::cmd_misc::render_lint_overlay("prod-api", &issues);
assert!(body.contains("⚠ [EBL001]"));
assert!(body.contains("· [EBL005]"));
assert!(body.contains("→ :deployment-policy Rolling"));
assert!(body.contains(" Deployment policy AllAtOnce"));
assert!(body.contains("2 issues found"));
}
#[test]
fn build_audit_webhook_body_has_slack_compatible_text_plus_structured_fields() {
let body = super::build_audit_webhook_body(
Some("123456789012"),
Some("prod"),
"us-east-1",
"stage=request action=Deploy target=prod-api",
"2026-05-25T12:00:00Z",
);
assert!(body.starts_with('{') && body.ends_with('}'));
assert!(
body.contains("\"text\":\"[ebman]"),
"missing slack-shaped text field"
);
assert!(body.contains("\"at\":\"2026-05-25T12:00:00Z\""));
assert!(body.contains("\"account\":\"123456789012\""));
assert!(body.contains("\"profile\":\"prod\""));
assert!(body.contains("\"region\":\"us-east-1\""));
assert!(body.contains("\"detail\":\"stage=request action=Deploy target=prod-api\""));
}
#[test]
fn build_audit_webhook_body_dashes_missing_account_and_profile_in_text() {
let body = super::build_audit_webhook_body(
None,
None,
"eu-west-1",
"stage=event kind=red_transition env=prod-api",
"2026-05-25T12:00:00Z",
);
assert!(
body.contains("account=- profile=- region=eu-west-1"),
"missing dash placeholders in text, got: {body}"
);
assert!(body.contains("\"account\":\"\""));
assert!(body.contains("\"profile\":\"\""));
}
#[test]
fn build_audit_webhook_body_escapes_quotes_in_detail() {
let body = super::build_audit_webhook_body(
None,
None,
"us-east-1",
"stage=event message=\"deploy started\"",
"2026-05-25T12:00:00Z",
);
assert!(body.contains("\\\"deploy started\\\""));
let _: serde_yml::Value = serde_yml::from_str(&body)
.expect("webhook body must be parseable JSON / YAML-superset");
}
#[tokio::test]
async fn dispatch_auto_rollback_also_drains_watching_deploys() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Red")];
app.rebuild_view();
app.deploy_snapshots.insert(
"prod".into(),
DeploySnapshot {
env_name: "prod".into(),
previous_version_label: "build-820".into(),
taken_at: chrono::Utc::now(),
},
);
app.armed_watchdogs.insert(
"prod".into(),
ArmedWatchdog {
env_name: "prod".into(),
target_label: "build-820".into(),
armed_at: chrono::Utc::now(),
deadline_at: chrono::Utc::now() - chrono::Duration::seconds(1),
},
);
app.watching_deploys.insert(
"prod".into(),
WatchingDeploy {
env_name: "prod".into(),
target_label: "build-900".into(),
armed_at: chrono::Utc::now(),
deadline_at: chrono::Utc::now() + chrono::Duration::seconds(300),
},
);
app.dispatch_auto_rollback("prod".into(), "Red".into());
assert!(
!app.watching_deploys.contains_key("prod"),
"rollback dispatch must drain the parallel wait-for-green watcher"
);
assert!(
!app.armed_watchdogs.contains_key("prod"),
"rollback dispatch must drain its own armed watchdog"
);
}
#[test]
fn soonest_watching_deploy_empty_returns_none() {
let map: std::collections::HashMap<String, WatchingDeploy> =
std::collections::HashMap::new();
assert!(soonest_watching_deploy(&map, chrono::Utc::now()).is_none());
}
#[tokio::test]
async fn abort_rollback_named_env_disarms_just_that_one() {
let mut app = test_app();
let now = chrono::Utc::now();
for env in ["prod", "staging"] {
app.armed_watchdogs.insert(
env.into(),
ArmedWatchdog {
env_name: env.into(),
target_label: "build-820".into(),
armed_at: now,
deadline_at: now + chrono::Duration::seconds(300),
},
);
}
app.execute_command("abort-rollback staging");
assert!(
!app.armed_watchdogs.contains_key("staging"),
"named env should be drained"
);
assert!(
app.armed_watchdogs.contains_key("prod"),
"other env's watchdog must stay armed"
);
let status = app.status_message.as_deref().unwrap_or("");
assert!(
status.contains("aborted auto-rollback for staging"),
"expected confirm in status, got: {status}"
);
}
#[tokio::test]
async fn abort_rollback_named_env_not_armed_errors_clearly() {
let mut app = test_app();
app.execute_command("abort-rollback ghost");
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("no auto-rollback armed for 'ghost'") && err.contains("rollbacks-armed"),
"expected not-armed + discovery hint, got: {err}"
);
}
#[tokio::test]
async fn abort_rollback_no_args_drains_every_watchdog() {
let mut app = test_app();
let now = chrono::Utc::now();
for env in ["a", "b", "c"] {
app.armed_watchdogs.insert(
env.into(),
ArmedWatchdog {
env_name: env.into(),
target_label: "x".into(),
armed_at: now,
deadline_at: now + chrono::Duration::seconds(300),
},
);
}
app.execute_command("abort-rollback");
assert!(
app.armed_watchdogs.is_empty(),
"drain-all clears everything"
);
let status = app.status_message.as_deref().unwrap_or("");
assert!(status.contains("aborted 3 auto-rollbacks"), "got: {status}");
for env in ["a", "b", "c"] {
assert!(
status.contains(env),
"expected {env} in status, got: {status}"
);
}
}
#[tokio::test]
async fn abort_rollback_no_args_empty_is_a_noop_status() {
let mut app = test_app();
app.execute_command("abort-rollback");
let status = app.status_message.as_deref().unwrap_or("");
assert!(status.contains("no auto-rollbacks armed to abort"));
assert!(app.error_message.is_none());
}
#[test]
fn format_armed_rollbacks_empty_returns_stub() {
let armed = std::collections::HashMap::new();
let body = super::format_armed_rollbacks(&armed, chrono::Utc::now());
assert!(body.contains("no auto-rollbacks armed"));
}
#[test]
fn format_armed_rollbacks_sorts_by_deadline_ascending() {
use chrono::TimeZone;
let now = chrono::Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap();
let mut armed = std::collections::HashMap::new();
armed.insert(
"prod-api".into(),
ArmedWatchdog {
env_name: "prod-api".into(),
target_label: "build-820".into(),
armed_at: now - chrono::Duration::seconds(60),
deadline_at: now + chrono::Duration::seconds(300),
},
);
armed.insert(
"staging-api".into(),
ArmedWatchdog {
env_name: "staging-api".into(),
target_label: "build-822".into(),
armed_at: now - chrono::Duration::seconds(30),
deadline_at: now + chrono::Duration::seconds(60),
},
);
let body = super::format_armed_rollbacks(&armed, now);
let p_staging = body.find("staging-api").expect("staging-api row");
let p_prod = body.find("prod-api").expect("prod-api row");
assert!(
p_staging < p_prod,
"soonest-firing row should sort first; got body:\n{body}"
);
assert!(body.contains("build-822"));
assert!(body.contains("build-820"));
}
#[test]
fn format_armed_rollbacks_expired_deadline_reads_as_expired() {
use chrono::TimeZone;
let now = chrono::Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap();
let mut armed = std::collections::HashMap::new();
armed.insert(
"prod-api".into(),
ArmedWatchdog {
env_name: "prod-api".into(),
target_label: "build-820".into(),
armed_at: now - chrono::Duration::seconds(600),
deadline_at: now - chrono::Duration::seconds(5),
},
);
let body = super::format_armed_rollbacks(&armed, now);
assert!(
body.contains("fired / expired"),
"expected expired marker, got: {body}"
);
}
#[test]
fn soonest_armed_rollback_picks_the_earliest_deadline() {
use chrono::TimeZone;
let now = chrono::Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap();
let mut armed = std::collections::HashMap::new();
armed.insert(
"later".into(),
ArmedWatchdog {
env_name: "later".into(),
target_label: "x".into(),
armed_at: now,
deadline_at: now + chrono::Duration::seconds(600),
},
);
armed.insert(
"sooner".into(),
ArmedWatchdog {
env_name: "sooner".into(),
target_label: "x".into(),
armed_at: now,
deadline_at: now + chrono::Duration::seconds(60),
},
);
let (env, remaining) = super::soonest_armed_rollback(&armed, now).expect("one armed");
assert_eq!(env, "sooner");
assert!(remaining.contains('m') || remaining.contains('s'));
}
#[test]
fn soonest_armed_rollback_returns_none_when_empty() {
let armed = std::collections::HashMap::new();
assert!(super::soonest_armed_rollback(&armed, chrono::Utc::now()).is_none());
}
#[tokio::test]
async fn refresh_early_disarms_armed_watchdog_when_env_goes_green() {
let mut app = test_app();
let now = chrono::Utc::now();
app.armed_watchdogs.insert(
"prod".into(),
ArmedWatchdog {
env_name: "prod".into(),
target_label: "build-old".into(),
armed_at: now,
deadline_at: now + chrono::Duration::seconds(300),
},
);
app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Green")]));
assert!(
app.armed_watchdogs.is_empty(),
"Green refresh should clear the armed watchdog"
);
let status = app.status_message.as_deref().unwrap_or("");
assert!(
status.contains("watchdog disarmed"),
"expected disarm status, got: {status}"
);
}
#[tokio::test]
async fn refresh_leaves_watchdog_armed_when_env_still_non_green() {
let mut app = test_app();
let now = chrono::Utc::now();
app.armed_watchdogs.insert(
"prod".into(),
ArmedWatchdog {
env_name: "prod".into(),
target_label: "build-old".into(),
armed_at: now,
deadline_at: now + chrono::Duration::seconds(300),
},
);
app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Red")]));
assert!(
app.armed_watchdogs.contains_key("prod"),
"Red refresh must leave watchdog armed"
);
}
#[tokio::test]
async fn auto_rollback_check_is_noop_when_no_watchdog_armed() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
app.deploy_snapshots.insert(
"prod".into(),
DeploySnapshot {
env_name: "prod".into(),
previous_version_label: "build-old".into(),
taken_at: chrono::Utc::now(),
},
);
let pending_before = app.pending_actions.len();
let load_before = app.load_state;
app.handle_msg(AppMsg::AutoRollbackCheck {
gen: app.generation,
env_name: "prod".into(),
});
assert_eq!(
app.pending_actions.len(),
pending_before,
"noop check shouldn't push pending"
);
assert_eq!(
app.load_state, load_before,
"noop check shouldn't kick a refresh"
);
}
#[tokio::test]
async fn apply_refresh_disarms_armed_watchdog_when_env_reaches_green() {
let mut app = test_app();
let now = chrono::Utc::now();
app.armed_watchdogs.insert(
"prod".into(),
ArmedWatchdog {
env_name: "prod".into(),
target_label: "build-old".into(),
armed_at: now,
deadline_at: now + chrono::Duration::seconds(300),
},
);
app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Green")]));
assert!(
app.armed_watchdogs.is_empty(),
"Green refresh should disarm"
);
let status = app.status_message.as_deref().unwrap_or("");
assert!(status.contains("watchdog disarmed"));
}
#[tokio::test]
async fn apply_refresh_dispatches_rollback_when_deadline_passed_and_env_non_green() {
let mut app = test_app();
let now = chrono::Utc::now();
app.armed_watchdogs.insert(
"prod".into(),
ArmedWatchdog {
env_name: "prod".into(),
target_label: "build-old".into(),
armed_at: now - chrono::Duration::seconds(600),
deadline_at: now - chrono::Duration::seconds(1),
},
);
app.deploy_snapshots.insert(
"prod".into(),
DeploySnapshot {
env_name: "prod".into(),
previous_version_label: "build-old".into(),
taken_at: now - chrono::Duration::seconds(600),
},
);
let pending_before = app.pending_actions.len();
app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Red")]));
assert!(
app.armed_watchdogs.is_empty(),
"dispatch should drain the watchdog"
);
assert_eq!(
app.pending_actions.len(),
pending_before + 1,
"rollback dispatch should push a pending row"
);
assert!(app
.pending_actions
.iter()
.any(|p| p.label.contains("Auto-rollback") && p.target == "prod"));
let status = app.status_message.as_deref().unwrap_or("");
assert!(status.contains("redeploying build-old"));
assert!(status.contains("Red"));
}
#[tokio::test]
async fn apply_refresh_keeps_watchdog_armed_before_deadline_even_when_non_green() {
let mut app = test_app();
let now = chrono::Utc::now();
app.armed_watchdogs.insert(
"prod".into(),
ArmedWatchdog {
env_name: "prod".into(),
target_label: "build-old".into(),
armed_at: now,
deadline_at: now + chrono::Duration::seconds(300),
},
);
app.deploy_snapshots.insert(
"prod".into(),
DeploySnapshot {
env_name: "prod".into(),
previous_version_label: "build-old".into(),
taken_at: now,
},
);
let pending_before = app.pending_actions.len();
app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Yellow")]));
assert!(
app.armed_watchdogs.contains_key("prod"),
"Yellow + pre-deadline must keep watchdog armed"
);
assert_eq!(
app.pending_actions.len(),
pending_before,
"no dispatch before the deadline"
);
}
#[tokio::test]
async fn apply_refresh_errors_when_deadline_passed_but_no_snapshot() {
let mut app = test_app();
let now = chrono::Utc::now();
app.armed_watchdogs.insert(
"prod".into(),
ArmedWatchdog {
env_name: "prod".into(),
target_label: "build-old".into(),
armed_at: now - chrono::Duration::seconds(600),
deadline_at: now - chrono::Duration::seconds(1),
},
);
app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Red")]));
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("no pre-deploy snapshot"),
"expected missing-snapshot guidance, got: {err}"
);
assert!(
app.armed_watchdogs.is_empty(),
"missing-snapshot path still drains the watchdog"
);
}
#[tokio::test]
async fn diff_two_arg_form_opens_overlay_for_named_envs() {
let mut app = test_app();
app.environments = vec![
mk_env("staging", "uflexi", "Web", "Green"),
mk_env("prod", "uflexi", "Web", "Green"),
];
app.rebuild_view();
app.execute_command("diff staging prod");
assert!(
matches!(app.current_overlay, Some(Overlay::Diff(_))),
"expected Overlay::Diff, got {:?}",
app.current_overlay.is_some()
);
assert!(
app.error_message.is_none(),
"unexpected error: {:?}",
app.error_message
);
}
#[tokio::test]
async fn diff_two_arg_form_rejects_same_env_twice() {
let mut app = test_app();
app.environments = vec![mk_env("prod", "uflexi", "Web", "Green")];
app.rebuild_view();
app.execute_command("diff prod prod");
assert!(
app.current_overlay.is_none(),
"shouldn't open overlay for same-env diff"
);
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("different envs"),
"expected 'different envs' guidance, got: {err}"
);
}
#[tokio::test]
async fn diff_two_arg_form_errors_on_unknown_env() {
let mut app = test_app();
app.environments = vec![mk_env("staging", "uflexi", "Web", "Green")];
app.rebuild_view();
app.execute_command("diff staging missing-env");
assert!(app.current_overlay.is_none());
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("missing-env"),
"expected error to name the missing env, got: {err}"
);
}
#[test]
fn format_ssm_results_renders_per_instance_sections() {
let rows = vec![
crate::aws::SsmRunResult {
instance_id: "i-aaa".into(),
status: "Success".into(),
exit_code: 0,
stdout: "hello world\nline two".into(),
stderr: String::new(),
},
crate::aws::SsmRunResult {
instance_id: "i-bbb".into(),
status: "Failed".into(),
exit_code: 2,
stdout: String::new(),
stderr: "permission denied".into(),
},
];
let body = super::format_ssm_results("uptime", &rows);
assert!(body.contains("`uptime`"));
assert!(body.contains("i-aaa [Success, exit=0]"));
assert!(body.contains("i-bbb [Failed, exit=2]"));
assert!(body.contains("hello world"));
assert!(body.contains("line two"));
assert!(body.contains("permission denied"));
}
#[test]
fn format_ssm_results_truncates_long_output() {
let stdout: String = (0..100).map(|i| format!("line {i}\n")).collect();
let rows = vec![crate::aws::SsmRunResult {
instance_id: "i-aaa".into(),
status: "Success".into(),
exit_code: 0,
stdout,
stderr: String::new(),
}];
let body = super::format_ssm_results("seq 0 99", &rows);
assert!(
body.contains("50 more lines truncated"),
"expected truncation footer, got body:\n{body}"
);
assert!(body.contains("line 49"));
assert!(!body.contains("line 99"));
}
#[test]
fn format_ssm_results_empty_rows_produces_stub() {
let body = super::format_ssm_results("uptime", &[]);
assert!(body.contains("No instances targeted"));
}
#[tokio::test]
async fn ssm_run_without_args_errors_clearly() {
let mut app = test_app();
app.execute_command("ssm-run");
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("usage:") && err.contains("shell-command"),
"expected usage hint, got: {err}"
);
}
#[tokio::test]
async fn ssm_run_without_detail_errors_with_instances_guidance() {
let mut app = test_app();
app.execute_command("ssm-run \"uptime\"");
let err = app.error_message.as_deref().unwrap_or("");
assert!(
err.contains("Detail") && err.contains("Instances"),
"expected Detail/Instances guidance, got: {err}"
);
}
#[test]
fn format_alarm_history_renders_entries_and_empty_stub() {
use chrono::TimeZone;
let ts = |h, mi| chrono::Utc.with_ymd_and_hms(2026, 5, 24, h, mi, 0).unwrap();
let mk = |t, kind: &str, summary: &str| crate::aws::AlarmHistoryEntry {
at: Some(t),
kind: kind.into(),
summary: summary.into(),
};
let stub = super::format_alarm_history("high-cpu", &[]);
assert!(stub.contains("No history items"));
assert!(stub.contains("90 days"));
let entries = vec![
mk(ts(12, 5), "StateUpdate", "Alarm updated from OK to ALARM"),
mk(ts(11, 0), "ConfigurationUpdate", "Threshold changed to 80"),
];
let body = super::format_alarm_history("high-cpu", &entries);
assert!(body.contains("[StateUpdate]"));
assert!(body.contains("[ConfigurationUpdate]"));
assert!(body.contains("Alarm updated from OK to ALARM"));
assert!(body.contains("Threshold changed to 80"));
let p_state = body.find("StateUpdate").unwrap();
let p_cfg = body.find("ConfigurationUpdate").unwrap();
assert!(p_state < p_cfg);
}
#[test]
fn format_alarm_history_handles_missing_timestamp() {
let entries = vec![crate::aws::AlarmHistoryEntry {
at: None,
kind: "StateUpdate".into(),
summary: "Alarm went ALARM".into(),
}];
let body = super::format_alarm_history("high-cpu", &entries);
assert!(body.contains("—"));
assert!(body.contains("Alarm went ALARM"));
}
#[test]
fn format_alarms_handles_empty_and_error() {
let none = format_alarms(Ok(vec![]));
assert!(none.contains("no CloudWatch alarms"));
let err = format_alarms(Err("boom".into()));
assert!(err.contains("error"));
let alarms = format_alarms(Ok(vec![CwAlarm {
name: "high-cpu".into(),
state: "ALARM".into(),
state_reason: "CPU > 80%".into(),
metric_name: "CPUUtilization".into(),
namespace: "AWS/EC2".into(),
}]));
assert!(alarms.contains("ALARM"));
assert!(alarms.contains("high-cpu"));
assert!(alarms.contains("CPU > 80%"));
}
#[test]
fn view_round_trips() {
let snap = "filter=prod;sort=health:desc;grouped=true;scope=apps";
let mut got_filter = String::new();
let mut got_sort = (SortKey::App, false);
let mut got_grouped = false;
let mut got_scope = Scope::Envs;
for part in snap.split(';') {
let (k, v) = part.split_once('=').unwrap();
match k {
"filter" => got_filter = v.into(),
"sort" => got_sort = parse_sort(Some(v)),
"grouped" => got_grouped = v == "true",
"scope" => {
got_scope = if v == "apps" {
Scope::Apps
} else {
Scope::Envs
}
}
_ => {}
}
}
assert_eq!(got_filter, "prod");
assert_eq!(got_sort, (SortKey::Health, true));
assert!(got_grouped);
assert_eq!(got_scope, Scope::Apps);
}
#[test]
fn view_mode_cycle_includes_spacious() {
assert_eq!(ViewMode::Default.next(), ViewMode::Compact);
assert_eq!(ViewMode::Compact.next(), ViewMode::Spacious);
assert_eq!(ViewMode::Spacious.next(), ViewMode::Default);
assert_eq!(ViewMode::Spacious.label(), "spacious");
}
#[test]
fn md_escape_protects_pipes_and_backslashes() {
assert_eq!(md_escape("simple"), "simple");
assert_eq!(md_escape("a|b|c"), "a\\|b\\|c");
assert_eq!(md_escape("back\\slash"), "back\\\\slash");
assert_eq!(md_escape("a\\|b"), "a\\\\\\|b");
}
#[test]
fn describe_env_dumps_known_fields() {
let env = Environment {
name: "my-env".into(),
application: "my-app".into(),
status: "Ready".into(),
health: "Green".into(),
platform: "Java 17".into(),
solution_stack: String::new(),
tier: "Web".into(),
cname: "my-env.elb.amazonaws.com".into(),
version_label: "v42".into(),
arn: None,
updated: None,
id: None,
region: None,
};
let text = describe_env(&env);
assert!(text.contains("\"name\""));
assert!(text.contains("my-env"));
assert!(text.contains("\"updated\": null"));
}
#[test]
fn detail_tab_titles_are_distinct() {
use std::collections::HashSet;
let titles: HashSet<&str> = [
DetailTab::Health,
DetailTab::Events,
DetailTab::Instances,
DetailTab::Metrics,
DetailTab::Queue,
DetailTab::Config,
]
.iter()
.map(|t| t.title())
.collect();
assert_eq!(titles.len(), 6);
}
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
fn test_app() -> App {
let cfg = crate::config::Config {
theme: "dark".into(),
icons: "unicode".into(),
..crate::config::Config::default()
};
App::for_tests(crate::aws::AwsClient::stub(), cfg)
}
fn press(app: &mut App, code: KeyCode, mods: KeyModifiers) {
app.handle_event(Event::Key(KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
}));
}
fn render(app: &mut App, w: u16, h: u16) -> String {
use ratatui::backend::TestBackend;
use ratatui::Terminal;
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).expect("terminal");
terminal.draw(|f| crate::ui::draw(f, app)).expect("draw");
let buf = terminal.backend().buffer();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
fn mk_env(name: &str, app: &str, tier: &str, health: &str) -> crate::aws::Environment {
crate::aws::Environment {
name: name.into(),
application: app.into(),
status: "Ready".into(),
health: health.into(),
platform: "Java 17".into(),
solution_stack: String::new(),
tier: tier.into(),
cname: format!("{name}.example.com"),
version_label: "build-1".into(),
arn: Some(format!("arn:aws:eb:us-east-1:0:env/{name}")),
updated: None,
id: None,
region: None,
}
}
#[tokio::test]
async fn persist_state_is_a_noop_in_demo_mode() {
let mut app = test_app();
app.demo_mode = true;
app.persist_state();
}
#[tokio::test]
async fn tab_cycles_scope_envs_to_apps_and_back() {
let mut app = test_app();
assert_eq!(app.scope, Scope::Envs);
press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
assert_eq!(app.scope, Scope::Apps);
press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
assert_eq!(app.scope, Scope::Envs);
}
#[tokio::test]
async fn question_mark_opens_help_and_escape_dismisses_it() {
let mut app = test_app();
assert_eq!(app.mode, Mode::Normal);
press(&mut app, KeyCode::Char('?'), KeyModifiers::NONE);
assert_eq!(app.mode, Mode::Help);
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(app.mode, Mode::Normal);
}
#[tokio::test]
async fn colon_enters_command_mode_and_esc_cancels() {
let mut app = test_app();
press(&mut app, KeyCode::Char(':'), KeyModifiers::NONE);
assert_eq!(app.mode, Mode::Command);
press(&mut app, KeyCode::Char('q'), KeyModifiers::NONE);
assert_eq!(app.command_input, "q");
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(app.mode, Mode::Normal);
assert!(app.command_input.is_empty());
}
#[tokio::test]
async fn slash_enters_filter_mode_and_text_lands() {
let mut app = test_app();
app.environments = vec![
mk_env("prod-web", "uflexi", "Web", "Green"),
mk_env("staging-web", "uflexi", "Web", "Green"),
];
app.rebuild_view();
press(&mut app, KeyCode::Char('/'), KeyModifiers::NONE);
assert_eq!(app.mode, Mode::Filter);
for c in "prod".chars() {
press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
}
assert_eq!(app.filter, "prod");
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(app.mode, Mode::Normal);
assert!(app.filter.is_empty());
}
#[tokio::test]
async fn enter_on_red_env_opens_why_via_bang_keybind() {
let mut app = test_app();
app.environments = vec![mk_env("prod-web", "uflexi", "Web", "Red")];
app.rebuild_view();
app.table_state.select(Some(0));
press(&mut app, KeyCode::Char('!'), KeyModifiers::NONE);
assert!(
matches!(app.current_overlay, Some(Overlay::WhyRed { .. })),
"expected WhyRed overlay, got {:?}",
app.current_overlay
);
}
#[tokio::test]
async fn render_main_table_includes_seeded_env_name() {
let mut app = test_app();
app.environments = vec![mk_env("api-prod-canary", "uflexi", "Web", "Green")];
app.rebuild_view();
let frame = render(&mut app, 160, 24);
assert!(
frame.contains("api-prod-canary"),
"rendered frame should show seeded env name; got:\n{frame}"
);
}
#[tokio::test]
async fn render_main_table_includes_inst_column_header_and_data() {
let mut app = test_app();
app.environments = vec![
mk_env("api-prod", "uflexi", "Web", "Green"),
mk_env("api-staging", "uflexi", "Web", "Green"),
];
app.env_instance_counts.insert(
"api-prod".into(),
crate::aws::EnvInstanceCounts {
healthy: 3,
total: 3,
},
);
app.rebuild_view();
let frame = render(&mut app, 160, 24);
assert!(
frame.contains("INST"),
"expected INST column header in rendered frame; got:\n{frame}"
);
assert!(
frame.contains("3/3"),
"expected '3/3' for env with seeded counts; got:\n{frame}"
);
assert!(
frame.contains("—"),
"expected em-dash placeholder for env with no counts; got:\n{frame}"
);
}
#[tokio::test]
async fn is_read_only_for_layers_global_env_and_account() {
let mut app = test_app();
app.read_only = true;
assert!(app.is_read_only_for("any-env"));
assert!(app.read_only_reason("any-env").unwrap().contains("global"));
let mut app = test_app();
app.safety_envs.insert("uflexi-prod".into(), true);
app.safety_envs.insert("uflexi-staging".into(), false);
assert!(app.is_read_only_for("uflexi-prod"));
assert!(!app.is_read_only_for("uflexi-staging"));
assert!(!app.is_read_only_for("uflexi-dev"));
assert!(app
.read_only_reason("uflexi-prod")
.unwrap()
.contains("safety.envs.uflexi-prod"));
let mut app = test_app();
app.context.profile = Some("prod-acct".into());
app.safety_accounts.insert("prod-acct".into(), true);
assert!(app.is_read_only_for("any-env"));
assert!(app
.read_only_reason("any-env")
.unwrap()
.contains("safety.accounts.prod-acct"));
app.context.profile = Some("dev-acct".into());
assert!(!app.is_read_only_for("any-env"));
let app = test_app();
assert!(!app.is_read_only_for("any-env"));
assert!(app.read_only_reason("any-env").is_none());
}
#[tokio::test]
async fn ctrl_x_toggles_redact() {
let mut app = test_app();
assert!(!app.redact);
press(&mut app, KeyCode::Char('x'), KeyModifiers::CONTROL);
assert!(app.redact);
press(&mut app, KeyCode::Char('x'), KeyModifiers::CONTROL);
assert!(!app.redact);
}
#[tokio::test]
async fn space_toggles_multi_select_and_esc_clears_it() {
let mut app = test_app();
app.environments = vec![
mk_env("api-prod", "uflexi", "Web", "Green"),
mk_env("api-staging", "uflexi", "Web", "Green"),
];
app.rebuild_view();
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
assert_eq!(app.multi_selected.len(), 1);
assert!(app.multi_selected.contains("api-prod"));
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
assert!(app.multi_selected.is_empty());
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE);
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
assert_eq!(app.multi_selected.len(), 2);
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert!(app.multi_selected.is_empty());
}
#[tokio::test]
async fn filter_mode_text_input_and_backspace_round_trips() {
let mut app = test_app();
app.environments = vec![
mk_env("api-prod", "uflexi", "Web", "Green"),
mk_env("api-staging", "uflexi", "Web", "Green"),
];
app.rebuild_view();
press(&mut app, KeyCode::Char('/'), KeyModifiers::NONE);
assert_eq!(app.mode, Mode::Filter);
for c in "prod".chars() {
press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
}
assert_eq!(app.filter, "prod");
press(&mut app, KeyCode::Backspace, KeyModifiers::NONE);
assert_eq!(app.filter, "pro");
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
assert_eq!(app.mode, Mode::Normal);
assert_eq!(app.filter, "pro");
}
#[tokio::test]
async fn esc_in_filter_mode_clears_the_filter() {
let mut app = test_app();
app.environments = vec![mk_env("api-prod", "uflexi", "Web", "Green")];
app.rebuild_view();
press(&mut app, KeyCode::Char('/'), KeyModifiers::NONE);
for c in "x".chars() {
press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
}
assert_eq!(app.filter, "x");
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(app.mode, Mode::Normal);
assert!(app.filter.is_empty());
}
#[tokio::test]
async fn star_toggles_pinned_set_for_selected_env() {
let mut app = test_app();
app.environments = vec![
mk_env("api-prod", "uflexi", "Web", "Green"),
mk_env("api-staging", "uflexi", "Web", "Green"),
];
app.rebuild_view();
press(&mut app, KeyCode::Char('*'), KeyModifiers::NONE);
assert!(app.pinned.contains("api-prod"));
press(&mut app, KeyCode::Char('*'), KeyModifiers::NONE);
assert!(!app.pinned.contains("api-prod"));
}
#[tokio::test]
async fn picker_workflow_open_filter_enter_dispatches_choice() {
let mut app = test_app();
press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
assert_eq!(app.mode, Mode::Picker);
assert!(app.picker.is_some());
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(app.mode, Mode::Normal);
assert!(app.picker.is_none());
}
fn mk_modal(action: Action, env: &str) -> ConfirmModal {
ConfirmModal {
action,
target_env: env.into(),
swap_with: None,
typed: String::new(),
kind: ConfirmKind::YesNo,
dryrun: None,
loading_dryrun: false,
recent_events: None,
loading_events: false,
traffic_warning: None,
deploy_version: None,
upgrade_platform_arn: None,
upgrade_platform_label: None,
clone_target: None,
scale_min: None,
scale_max: None,
auto_rollback_secs: None,
wait_for_green_secs: None,
version_preview: None,
loading_version_preview: false,
health_check_probe: None,
loading_health_check: false,
unavailability_line: None,
loading_unavailability: false,
lint_issues: None,
loading_lint: false,
}
}
#[tokio::test]
async fn queue_action_dispatch_holds_action_for_cancel_window() {
let mut app = test_app();
let modal = mk_modal(Action::Rebuild, "uflexi-prod");
app.queue_action_dispatch(modal);
let pd = app
.pending_dispatch
.as_ref()
.expect("queue should set pending_dispatch");
assert_eq!(pd.target, "uflexi-prod");
assert!(
matches!(pd.kind, PendingDispatchKind::Single { .. }),
"queue_action_dispatch should produce a Single variant"
);
assert!(
pd.deadline > std::time::Instant::now(),
"deadline must be in the future"
);
let remaining = pd
.deadline
.saturating_duration_since(std::time::Instant::now());
assert!(
remaining <= UNDO_WINDOW && remaining >= UNDO_WINDOW - Duration::from_millis(500),
"deadline should be roughly UNDO_WINDOW from now; got {remaining:?}"
);
}
#[tokio::test]
async fn cancel_pending_dispatch_clears_field_and_emits_status() {
let mut app = test_app();
app.queue_action_dispatch(mk_modal(Action::Terminate, "uflexi-prod"));
assert!(app.pending_dispatch.is_some());
app.cancel_pending_dispatch();
assert!(app.pending_dispatch.is_none());
let msg = app.status_message.as_deref().unwrap_or("");
assert!(
msg.contains("undone") && msg.contains("uflexi-prod"),
"status should mention the undo + env; got: {msg:?}"
);
}
#[tokio::test]
async fn second_queue_attempt_errors_while_first_pending() {
let mut app = test_app();
app.queue_action_dispatch(mk_modal(Action::Rebuild, "first"));
assert!(app.pending_dispatch.is_some());
let first_deadline = app.pending_dispatch.as_ref().unwrap().deadline;
app.queue_action_dispatch(mk_modal(Action::Rebuild, "second"));
assert_eq!(
app.pending_dispatch.as_ref().unwrap().target,
"first",
"second queue must not replace the first"
);
assert_eq!(
app.pending_dispatch.as_ref().unwrap().deadline,
first_deadline,
"second queue must not bump the deadline"
);
assert!(
app.error_message
.as_deref()
.unwrap_or("")
.contains("press U to undo"),
"second queue should surface a useful error"
);
}
#[tokio::test]
async fn tick_pending_dispatch_fires_after_deadline() {
let mut app = test_app();
let modal = mk_modal(Action::Rebuild, "expired");
app.pending_dispatch = Some(PendingDispatch {
deadline: std::time::Instant::now() - Duration::from_millis(1),
label: "Rebuild env".into(),
target: "expired".into(),
kind: PendingDispatchKind::Single { modal },
});
app.tick_pending_dispatch();
assert!(
app.pending_dispatch.is_none(),
"expired tick should clear the field (dispatch handed to spawn_action)"
);
}
#[tokio::test]
async fn batch_action_routes_through_cancel_window() {
let mut app = test_app();
app.environments = vec![
mk_env("prod-web", "uflexi", "Web", "Green"),
mk_env("staging-web", "uflexi", "Web", "Green"),
];
app.multi_selected.insert("prod-web".into());
app.multi_selected.insert("staging-web".into());
app.cmd_batch_action(Action::Rebuild);
assert!(
app.multi_selected.is_empty(),
"multi-select should clear once the batch is queued"
);
let pd = app
.pending_dispatch
.as_ref()
.expect("batch action should queue a pending dispatch");
match &pd.kind {
PendingDispatchKind::BatchAction { action, env_names } => {
assert_eq!(*action, Action::Rebuild);
assert_eq!(env_names.len(), 2);
}
other => panic!(
"expected BatchAction variant; got {other:?}",
other = match other {
PendingDispatchKind::Single { .. } => "Single",
PendingDispatchKind::BatchAction { .. } => "BatchAction",
PendingDispatchKind::BatchDeploy { .. } => "BatchDeploy",
PendingDispatchKind::BatchTag { .. } => "BatchTag",
PendingDispatchKind::BatchSetOption { .. } => "BatchSetOption",
}
),
}
}
#[tokio::test]
async fn batch_action_undo_cancels_whole_fanout() {
let mut app = test_app();
app.environments = vec![
mk_env("e1", "uflexi", "Web", "Green"),
mk_env("e2", "uflexi", "Web", "Green"),
mk_env("e3", "uflexi", "Web", "Green"),
];
for name in ["e1", "e2", "e3"] {
app.multi_selected.insert(name.into());
}
app.cmd_batch_action(Action::RestartAppServer);
assert!(app.pending_dispatch.is_some());
app.cancel_pending_dispatch();
assert!(
app.pending_dispatch.is_none(),
"cancel should drop the whole batch, not just one env"
);
let msg = app.status_message.as_deref().unwrap_or("");
assert!(
msg.contains("undone") && msg.contains("3 env(s)"),
"status should call out the 3-env batch; got: {msg:?}"
);
}
#[tokio::test]
async fn apps_scope_space_toggles_apps_selected() {
let mut app = test_app();
app.applications = vec![
crate::aws::Application {
name: "billing".into(),
description: String::new(),
date_created: None,
date_updated: None,
version_count: 0,
templates: vec![],
latest_version_label: None,
latest_version_created: None,
},
crate::aws::Application {
name: "checkout".into(),
description: String::new(),
date_created: None,
date_updated: None,
version_count: 0,
templates: vec![],
latest_version_label: None,
latest_version_created: None,
},
];
app.set_scope(Scope::Apps);
app.app_table_state.select(Some(0));
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
assert!(app.apps_selected.contains("billing"));
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
assert!(!app.apps_selected.contains("billing"));
}
#[tokio::test]
async fn apps_scope_star_pins_and_unpins_app() {
let mut app = test_app();
app.applications = vec![crate::aws::Application {
name: "billing".into(),
description: String::new(),
date_created: None,
date_updated: None,
version_count: 0,
templates: vec![],
latest_version_label: None,
latest_version_created: None,
}];
app.set_scope(Scope::Apps);
app.app_table_state.select(Some(0));
assert!(!app.pinned_apps.contains("billing"));
press(&mut app, KeyCode::Char('*'), KeyModifiers::SHIFT);
assert!(app.pinned_apps.contains("billing"));
press(&mut app, KeyCode::Char('*'), KeyModifiers::SHIFT);
assert!(!app.pinned_apps.contains("billing"));
}
#[tokio::test]
async fn esc_clears_apps_selected_when_no_envs_selected() {
let mut app = test_app();
app.apps_selected.insert("billing".into());
app.apps_selected.insert("checkout".into());
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert!(app.apps_selected.is_empty());
}
#[tokio::test]
async fn capital_u_cancels_pending_dispatch_in_normal_mode() {
let mut app = test_app();
app.queue_action_dispatch(mk_modal(Action::Rebuild, "uflexi-prod"));
assert!(app.pending_dispatch.is_some());
press(&mut app, KeyCode::Char('U'), KeyModifiers::SHIFT);
assert!(
app.pending_dispatch.is_none(),
"capital U in Normal mode should cancel the pending dispatch"
);
}
}