1use std::{
2 collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque},
3 sync::Arc,
4 time::{Duration, Instant},
5};
6
7use color_eyre::eyre::{Result, WrapErr};
8use crossterm::event::{
9 Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent,
10 MouseEventKind,
11};
12use futures::StreamExt;
13use ratatui::{
14 layout::Rect,
15 widgets::{ListState, TableState},
16};
17use tokio::sync::mpsc;
18
19use crate::{
20 aws::{
21 AppVersion, Application, AwsClient, AwsContext, CwAlarm, Environment, Event as EbEvent,
22 Identity, Instance, MetricSeries, QueueMessage, WorkerQueues,
23 },
24 config::Config,
25 profiles,
26 state::{self, PersistedState},
27 theme::{IconStyle, Theme},
28 ui, Tui,
29};
30
31pub use crate::mode_action::{
35 Action, ActionFlow, ConfirmKind, ConfirmModal, DryRunInfo, ParameterisedAction, ACTIONS,
36};
37pub use crate::mode_detail::{
38 config_editable_items, health_items, ConfigEdit, ConfigEditMode, ConfigItem, ConfigItemKind,
39 DetailState, DetailTab, EventLevel, EventWindow, HealthItem, LogTail, LogTailStage,
40};
41
42mod cmd_action;
53mod cmd_alarms;
54mod cmd_config_template;
55mod cmd_misc;
56mod cmd_nav;
57mod cmd_option;
58mod cmd_overlay;
59mod cmd_settings;
60mod cmd_view;
61mod cmd_write;
62mod mode_keys;
63mod msg;
64pub use crate::mode_dlq::{DlqState, QueueView};
65
66pub fn builtin_commands() -> Vec<&'static str> {
74 crate::commands::all_names()
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum Focus {
82 Table,
83 Events,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum ViewMode {
88 Default,
89 Compact,
90 Spacious,
91}
92
93impl ViewMode {
94 pub fn next(self) -> Self {
95 match self {
96 Self::Default => Self::Compact,
97 Self::Compact => Self::Spacious,
98 Self::Spacious => Self::Default,
99 }
100 }
101 pub fn label(self) -> &'static str {
102 match self {
103 Self::Default => "default",
104 Self::Compact => "compact",
105 Self::Spacious => "spacious",
106 }
107 }
108}
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub enum Scope {
112 Envs,
113 Apps,
114}
115
116impl Scope {
117 pub fn next(self) -> Self {
118 match self {
119 Self::Envs => Self::Apps,
120 Self::Apps => Self::Envs,
121 }
122 }
123 pub fn prev(self) -> Self {
124 self.next()
127 }
128}
129
130pub const HISTORY_CAP: usize = 20;
135const MESSAGE_LOG_CAP: usize = 50;
136const TOAST_CAP: usize = 4;
137
138pub const LOADING_INDICATOR_THRESHOLD: Duration = Duration::from_millis(300);
142
143pub const LOADING_INDICATOR_LINGER: Duration = Duration::from_millis(500);
149
150#[derive(Debug, Clone)]
157pub enum Overlay {
158 Describe(String),
160 Whatsnew(String),
162 History(String),
164 Alarms { env_name: String, body: String },
168 Diff(String),
170 SavedConfigs(String),
174 TextDump { title: String, body: String },
179 SavedConfigsInteractive {
186 items: Vec<(String, String)>,
187 cursor: usize,
188 confirm_delete: bool,
189 },
190 WhyRed {
199 env_name: String,
200 tier: String,
203 events: Option<Result<Vec<crate::aws::Event>, String>>,
204 alarms: Option<Result<Vec<crate::aws::CwAlarm>, String>>,
205 instances: Option<Result<Vec<crate::aws::Instance>, String>>,
206 deploys: Option<Result<Vec<crate::aws::AppVersion>, String>>,
207 queues: Option<Result<crate::aws::WorkerQueues, String>>,
211 dlq_messages: Option<Result<Vec<crate::aws::QueueMessage>, String>>,
218 session_id: u64,
219 cursor: usize,
223 },
224 ReportBug { body: String },
232 AppsActionMenu {
238 app_name: String,
239 env_names: Vec<String>,
242 cursor: usize,
243 },
244 LogTail {
250 log_group: String,
251 env_name: String,
252 events: std::collections::VecDeque<crate::aws::LogEvent>,
253 scroll: u16,
254 following: bool,
255 since_ms: i64,
256 filter_input: String,
257 filter_active: bool,
258 filter_pattern: Option<regex::Regex>,
259 last_err: Option<String>,
260 session_id: u64,
263 },
264 About(std::time::Instant),
269}
270
271pub const LOG_TAIL_MAX_LINES: usize = 2000;
272
273#[derive(Debug, Clone)]
279pub enum WhyItem {
280 Describe(String),
283 OpenDlq,
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub enum ToastKind {
292 Info,
293 Success,
294 Error,
295}
296
297#[derive(Debug, Clone)]
298pub struct Toast {
299 pub text: String,
300 pub kind: ToastKind,
301 pub shown_at: Instant,
302}
303
304impl Toast {
305 pub fn ttl(&self) -> Duration {
306 match self.kind {
307 ToastKind::Error => Duration::from_secs(8),
308 _ => Duration::from_secs(4),
309 }
310 }
311}
312
313#[derive(Debug, Clone, Copy, PartialEq, Eq)]
314pub enum MsgKind {
315 Info,
316 Error,
317}
318
319const WHATSNEW: &str = "\
320ebman — what's new
321==================
322
323Recent additions:
324 • --version / --help / --read-only CLI flags
325 • README and GitHub Actions CI
326 • Themes: dark, light, high-contrast (set in config.toml)
327 • Detail auto-refresh (R in Detail mode)
328 • Open env in console (b)
329 • Describe overlay (D — raw env JSON)
330 • Breadcrumb top-line, FROZEN pill, quick-jump 1-9
331 • Pin / star envs (*), persisted across runs
332 • Local env aliases (:alias NAME LABEL)
333 • Exports: TSV (^Y), JSON (:json), Markdown (:report)
334 • Read-only mode (--read-only or :readonly on)
335 • Local audit log (~/.cache/ebman/audit.log)
336 • Notification bell (notify_bell = true in config.toml)
337 • Crash report writer
338
339Press esc / q / w to close.";
340
341const WELCOME_OVERLAY: &str = "\
342Welcome to ebman
343================
344
345Looks like this is your first run — no AWS credentials or persisted ebman
346state were found on this machine. Here's what you'll need:
347
3481. AWS credentials. Either:
349 aws sso login --profile my-sso-profile (recommended)
350 or set up ~/.aws/credentials with an access key, then
351 export AWS_PROFILE=my-profile
352
3532. The IAM identity needs at least these EB read permissions:
354 elasticbeanstalk:DescribeEnvironments
355 elasticbeanstalk:DescribeApplications
356 elasticbeanstalk:DescribeEvents
357 Destructive actions (rebuild / restart / swap / terminate) require their
358 matching write permission; you can stay safe with `--read-only` until then.
359
3603. Optional: drop a config at ~/.config/ebman/config.toml. See README.md for
361 the full schema (theme, refresh_interval_secs, extra_regions, …).
362
363Key bindings:
364 ? this help screen
365 p / r switch profile / region
366 : command bar
367 Ctrl-K fuzzy command palette
368 Ctrl-X redact mode (good for screenshots / streaming)
369
370Press esc / q / w to close.";
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
373pub enum SortKey {
374 App,
375 Name,
376 Status,
377 Health,
378 Age,
379 Version,
380}
381
382impl SortKey {
383 pub fn next(self) -> Self {
386 match self {
387 Self::Name => Self::App,
388 Self::App => Self::Status,
389 Self::Status => Self::Health,
390 Self::Health => Self::Version,
391 Self::Version => Self::Age,
392 Self::Age => Self::Name,
393 }
394 }
395
396 pub fn label(self) -> &'static str {
397 match self {
398 Self::App => "app",
399 Self::Name => "name",
400 Self::Status => "status",
401 Self::Health => "health",
402 Self::Age => "age",
403 Self::Version => "version",
404 }
405 }
406
407 pub fn parse(s: &str) -> Option<Self> {
408 match s {
409 "app" => Some(Self::App),
410 "name" => Some(Self::Name),
411 "status" => Some(Self::Status),
412 "health" => Some(Self::Health),
413 "age" => Some(Self::Age),
414 "version" => Some(Self::Version),
415 _ => None,
416 }
417 }
418}
419
420#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
427pub enum EventTimeFormat {
428 #[default]
429 Utc,
430 Local,
431 Age,
432}
433
434impl EventTimeFormat {
435 pub fn next(self) -> Self {
439 match self {
440 Self::Utc => Self::Local,
441 Self::Local => Self::Age,
442 Self::Age => Self::Utc,
443 }
444 }
445
446 pub fn label(self) -> &'static str {
447 match self {
448 Self::Utc => "utc",
449 Self::Local => "local",
450 Self::Age => "age",
451 }
452 }
453
454 pub fn parse(s: &str) -> Option<Self> {
455 match s.to_ascii_lowercase().as_str() {
456 "utc" => Some(Self::Utc),
457 "local" => Some(Self::Local),
458 "age" | "relative" => Some(Self::Age),
459 _ => None,
460 }
461 }
462}
463
464#[derive(Debug, Clone, Copy, PartialEq, Eq)]
465pub enum Mode {
466 Normal,
467 Filter,
468 Help,
469 Picker,
470 Command,
471 Detail,
472 Action,
473 Dlq,
474 QuickJump,
475 Palette,
476 Shell,
480 Form,
483}
484
485#[derive(Debug, Clone)]
486pub enum PaletteAction {
487 RunCommand(String),
489 PrefillCommand(String),
491 JumpEnv(String),
493 LoadView(String),
495}
496
497#[derive(Debug, Clone)]
498pub struct PaletteItem {
499 pub label: String,
500 pub detail: String,
501 pub kind_tag: &'static str, pub action: PaletteAction,
503}
504
505#[derive(Debug, Clone)]
518pub struct PendingAction {
519 pub label: String,
520 pub target: String,
521 pub started: Instant,
522 pub completed: Option<(Instant, Result<(), String>)>,
523}
524
525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
535#[allow(dead_code)]
536pub enum HelpTopic {
537 Global,
538 Detail,
539 Dlq,
540 Action,
541 Shell,
542 SavedConfigs,
545}
546
547pub const PENDING_CAP: usize = 20;
550pub const PENDING_COMPLETED_TTL: Duration = Duration::from_secs(60);
553
554#[derive(Debug, Clone)]
564#[allow(dead_code)] pub(crate) struct DeploySnapshot {
566 pub env_name: String,
569 pub previous_version_label: String,
570 pub taken_at: chrono::DateTime<chrono::Utc>,
574}
575
576#[derive(Debug, Clone)]
584#[allow(dead_code)] pub(crate) struct ArmedWatchdog {
586 pub env_name: String,
587 pub target_label: String,
594 pub armed_at: chrono::DateTime<chrono::Utc>,
595 pub deadline_at: chrono::DateTime<chrono::Utc>,
596}
597
598#[derive(Debug, Clone)]
606pub(crate) struct WatchingDeploy {
607 pub env_name: String,
608 pub target_label: String,
609 pub armed_at: chrono::DateTime<chrono::Utc>,
610 pub deadline_at: chrono::DateTime<chrono::Utc>,
611}
612
613#[derive(Debug, Clone)]
625pub(crate) struct UndoEntry {
626 pub env_name: String,
627 pub to_set: Vec<(String, String, String)>,
628 pub to_remove: Vec<(String, String)>,
629 pub original_summary: String,
633 pub captured_at: chrono::DateTime<chrono::Utc>,
634}
635
636pub(crate) const UNDO_HISTORY_CAP: usize = 10;
640
641#[derive(Debug, Clone)]
648pub(crate) struct DeployFreeze {
649 pub reason: String,
654 pub frozen_at: chrono::DateTime<chrono::Utc>,
655}
656
657impl DeploySnapshot {
658 pub fn to_persisted(&self) -> String {
663 format!(
664 "{}|{}",
665 self.previous_version_label,
666 self.taken_at.to_rfc3339()
667 )
668 }
669
670 pub fn parse_persisted(env_name: &str, raw: &str) -> Option<Self> {
674 let (label, ts_str) = raw.split_once('|')?;
675 let label = label.trim();
676 if label.is_empty() {
677 return None;
678 }
679 let taken_at = chrono::DateTime::parse_from_rfc3339(ts_str.trim())
680 .ok()?
681 .with_timezone(&chrono::Utc);
682 Some(Self {
683 env_name: env_name.to_string(),
684 previous_version_label: label.to_string(),
685 taken_at,
686 })
687 }
688}
689
690#[derive(Debug, Clone, Copy, PartialEq, Eq)]
691pub enum PickerKind {
692 Profile,
693 Region,
694 LogGroup,
698 SshInstance,
705}
706
707pub struct Picker {
708 pub kind: PickerKind,
709 pub items: Vec<String>,
710 pub filter: String,
711 pub list_state: ListState,
712}
713
714#[derive(Clone, Debug)]
718pub struct MultiSelectOptions {
719 pub options: Vec<String>,
720 pub annotations: Vec<String>,
721 pub initial: Vec<String>,
722}
723
724impl Picker {
725 pub fn new(kind: PickerKind, items: Vec<String>, current: Option<&str>) -> Self {
726 let mut list_state = ListState::default();
727 let initial = current
728 .and_then(|c| items.iter().position(|i| i == c))
729 .unwrap_or(0);
730 if !items.is_empty() {
731 list_state.select(Some(initial));
732 }
733 Self {
734 kind,
735 items,
736 filter: String::new(),
737 list_state,
738 }
739 }
740
741 pub fn title(&self) -> &'static str {
742 match self.kind {
743 PickerKind::Profile => " select profile ",
744 PickerKind::Region => " select region ",
745 PickerKind::LogGroup => " select log group ",
746 PickerKind::SshInstance => " select instance for SSM session ",
747 }
748 }
749
750 pub fn filtered(&self) -> Vec<usize> {
751 if self.filter.is_empty() {
752 return (0..self.items.len()).collect();
753 }
754 let needle = self.filter.to_lowercase();
755 self.items
756 .iter()
757 .enumerate()
758 .filter(|(_, v)| v.to_lowercase().contains(&needle))
759 .map(|(i, _)| i)
760 .collect()
761 }
762
763 pub fn move_selection(&mut self, delta: i32) {
764 let filt = self.filtered();
765 if filt.is_empty() {
766 self.list_state.select(None);
767 return;
768 }
769 let cur_visible = self
770 .list_state
771 .selected()
772 .and_then(|s| filt.iter().position(|i| *i == s))
773 .unwrap_or(0) as i32;
774 let next = (cur_visible + delta).rem_euclid(filt.len() as i32) as usize;
775 self.list_state.select(Some(filt[next]));
776 }
777
778 pub fn selected_value(&self) -> Option<String> {
779 self.list_state
780 .selected()
781 .and_then(|i| self.items.get(i).cloned())
782 }
783}
784
785#[derive(Debug, Clone, Copy, PartialEq, Eq)]
786pub enum LoadState {
787 Idle,
788 Loading,
789 Error,
790}
791
792#[derive(Default)]
794pub struct CompletionState {
795 pub origin: Option<String>,
800 pub index: usize,
804}
805
806pub struct HelpState {
808 pub scroll: u16,
809 pub max_scroll: u16,
814 pub topic: HelpTopic,
816 pub pre_mode: Option<Mode>,
820 pub pre_overlay: Option<Overlay>,
824}
825
826pub struct EventPanel {
829 pub events: Vec<EbEvent>,
830 pub visible: bool,
831 pub time_format: EventTimeFormat,
836 pub for_env: Option<String>,
840 pub scroll: u16,
841 pub area: Option<ratatui::layout::Rect>,
845 pub drag_origin: Option<u16>,
849 pub cursor: Option<usize>,
854 pub height: u16,
856}
857
858pub struct App {
859 pub context: AwsContext,
860 pub scope: Scope,
861 pub applications: Vec<Application>,
862 pub app_table_state: TableState,
863 pub environments: Vec<Environment>,
864 pub table_state: TableState,
865 pub table_area: Rect,
866 pub mode: Mode,
867 pub filter: String,
868 pub load_state: LoadState,
869 pub loading_since: Option<Instant>,
870 pub refresh_interval: Duration,
871 pub loading_visible_until: Option<Instant>,
879 pub last_refresh: Option<chrono::DateTime<chrono::Utc>>,
880 pub status_message: Option<String>,
881 pub error_message: Option<String>,
882 pub picker: Option<Picker>,
883 pub override_profile: Option<String>,
884 pub override_region: Option<String>,
885 pub history: HashMap<String, VecDeque<String>>,
886 pub redact: bool,
887 pub grouped: bool,
888 pub sort_key: SortKey,
889 pub sort_desc: bool,
890 pub command_input: String,
891 pub completion: CompletionState,
892 pub quickjump_input: String,
893 pub extra_regions: Vec<String>,
894 pub event_panel: EventPanel,
895 pub multi_selected: BTreeSet<String>,
898 pub apps_selected: BTreeSet<String>,
904 pub focus: Focus,
906 pub multi_regions: Vec<String>,
909 pub detail: Option<DetailState>,
910 pub action_flow: Option<ActionFlow>,
911 pub dlq: Option<DlqState>,
912 pub theme: Arc<Theme>,
913 pub view_mode: ViewMode,
914 pub help: HelpState,
915 pub hover_row: Option<usize>,
916 pub alerts: usize, pub worker_dlq_depths: std::collections::HashMap<String, i64>,
923 pub(crate) deploy_snapshots: std::collections::HashMap<String, DeploySnapshot>,
930 pub(crate) armed_watchdogs: std::collections::HashMap<String, ArmedWatchdog>,
939 pub(crate) watching_deploys: std::collections::HashMap<String, WatchingDeploy>,
947 pub(crate) deploy_freeze: Option<DeployFreeze>,
951 pub(crate) tf_state: Option<crate::terraform::TfState>,
959 pub(crate) tf_managed_envs: std::collections::HashSet<String>,
965 pub(crate) undo_history: std::collections::VecDeque<UndoEntry>,
974 pub demo_mode: bool,
983 pub env_instance_counts: std::collections::HashMap<String, crate::aws::EnvInstanceCounts>,
989 pub cost_enabled: bool,
994 pub costs: std::collections::HashMap<String, f64>,
1000 pub costs_fetched_at: Option<chrono::DateTime<chrono::Utc>>,
1001 pub latest_stacks: std::collections::HashMap<String, String>,
1006 pub frozen: bool, pub first_run_hint: bool,
1015 pub current_overlay: Option<Overlay>,
1017 pub message_log: VecDeque<(chrono::DateTime<chrono::Utc>, MsgKind, String)>,
1018 pub toasts: VecDeque<Toast>,
1019 pub palette_input: String,
1020 pub palette_items: Vec<PaletteItem>,
1021 pub palette_filtered: Vec<usize>,
1022 pub palette_state: ListState,
1023 pub read_only: bool,
1024 pub pinned: BTreeSet<String>,
1025 pub pinned_apps: BTreeSet<String>,
1031 pub aliases: BTreeMap<String, String>,
1032 pub saved_views: BTreeMap<String, String>,
1033 pub hidden_cols: BTreeSet<String>,
1034 pub custom_metrics: BTreeMap<String, crate::state::CustomMetricSpec>,
1038 pub log_reload: Option<crate::LogReloadHandle>,
1039 pub log_directive: String,
1040 pub plugins: BTreeMap<String, crate::plugins::Plugin>,
1041 pub status_snapshot_at_refresh: Option<(Option<String>, Option<String>)>,
1046 pub status_message_pinned: bool,
1052 pub throttle_until: Option<Instant>,
1057 pub consecutive_throttles: u32,
1060 pub sso_expiry: Option<chrono::DateTime<chrono::Utc>>,
1064 pub pending_actions: std::collections::VecDeque<PendingAction>,
1067 pub pending_dispatch: Option<PendingDispatch>,
1072 pub form: Option<crate::form::Form>,
1075 pub log_tail_task: Option<tokio::task::JoinHandle<()>>,
1079 pub log_tail_session: u64,
1082 pub why_red_session: u64,
1086 pub why_items: Vec<WhyItem>,
1090 pub update_available: Option<crate::update_check::LatestRelease>,
1093 pub reload_requested: bool,
1097 pub pending_shell_target: Option<String>,
1102 pub pending_env_edit: Option<(String, Vec<(String, String)>)>,
1109 pub current_shell: Option<Box<crate::shell::ShellSession>>,
1111 pub shell_return_mode: Mode,
1113 pub last_rendered_buffer: Option<ratatui::buffer::Buffer>,
1120 pub notify_bell: bool,
1121 pub notify_webhook: Option<String>,
1128 pub command_aliases: std::collections::HashMap<String, String>,
1137 pub lint_disable: Vec<String>,
1143 pub explain_enabled: bool,
1147 pub explain_provider: String,
1148 pub explain_model: String,
1149 pub explain_api_key_env: String,
1150 pub explain_ollama_url: String,
1151 pub explain_max_tokens: u32,
1152 pub required_tags: Vec<String>,
1153 pub cfg_icons_raw: String,
1157 pub profile_themes: std::collections::HashMap<String, String>,
1163 pub runbooks: std::collections::HashMap<String, String>,
1166 pub safety_envs: std::collections::HashMap<String, bool>,
1170 pub safety_accounts: std::collections::HashMap<String, bool>,
1174 pub accounts: std::collections::HashMap<String, crate::config::AccountSpec>,
1179 pub base_theme_name: String,
1183 pub newly_red: HashSet<String>,
1184 pub newly_added: HashSet<String>,
1190 pub health_delta: Vec<(String, i32)>,
1192 pub status_delta: Vec<(String, i32)>,
1193 prev_alerts: usize,
1194 prev_health: HashMap<String, String>,
1195 prev_status: HashMap<String, String>,
1196 cached_filtered: Vec<usize>,
1197 cached_display: Vec<DisplayRow>,
1198 pub cached_app_colors: HashMap<String, ratatui::style::Color>,
1203 pub cached_stale_platforms: HashMap<String, String>,
1209 pending_select: Option<String>,
1210 aws: Arc<AwsClient>,
1211 generation: u64,
1212 msg_tx: mpsc::UnboundedSender<AppMsg>,
1213 msg_rx: mpsc::UnboundedReceiver<AppMsg>,
1214 quit: bool,
1215}
1216
1217enum AppMsg {
1218 Refresh {
1219 gen: u64,
1220 result: Result<Vec<Environment>, String>,
1221 },
1222 Applications {
1223 gen: u64,
1224 result: Result<Vec<Application>, String>,
1225 },
1226 AppLatestVersions {
1231 gen: u64,
1232 results: Vec<(
1233 String,
1234 Option<String>,
1235 Option<chrono::DateTime<chrono::Utc>>,
1236 )>,
1237 },
1238 WorkerQueueCheck {
1243 gen: u64,
1244 results: Vec<(String, i64)>,
1245 },
1246 EnvInstanceCountsCheck {
1250 gen: u64,
1251 results: Vec<(String, crate::aws::EnvInstanceCounts)>,
1252 },
1253 Rebuild(Result<Box<AwsClient>, String>),
1254 Identity {
1255 gen: u64,
1256 result: Result<Identity, String>,
1257 },
1258 Events {
1259 gen: u64,
1260 result: Result<Vec<EbEvent>, String>,
1261 },
1262 DetailEvents {
1263 gen: u64,
1264 env_name: String,
1265 result: Result<Vec<EbEvent>, String>,
1266 },
1267 DetailInstances {
1268 gen: u64,
1269 env_name: String,
1270 result: Result<Vec<Instance>, String>,
1271 },
1272 DetailQueues {
1273 gen: u64,
1274 env_name: String,
1275 result: Result<WorkerQueues, String>,
1276 },
1277 DetailMetrics {
1278 gen: u64,
1279 env_name: String,
1280 result: Result<Vec<MetricSeries>, String>,
1281 },
1282 DetailTags {
1283 gen: u64,
1284 env_name: String,
1285 result: Result<Vec<(String, String)>, String>,
1286 },
1287 DetailEnvVars {
1291 gen: u64,
1292 env_name: String,
1293 result: Result<Vec<(String, String)>, String>,
1294 },
1295 DetailLogGroups {
1299 gen: u64,
1300 env_name: String,
1301 groups: Vec<String>,
1302 },
1303 DetailAlarms {
1308 gen: u64,
1309 env_name: String,
1310 result: Result<Vec<crate::aws::CwAlarm>, String>,
1311 },
1312 CostsFetched {
1317 gen: u64,
1318 account: Option<String>,
1319 region: String,
1320 result: Result<Vec<crate::aws::EnvCost>, String>,
1321 },
1322 SolutionStacks {
1326 gen: u64,
1327 result: Result<Vec<String>, String>,
1328 },
1329 DetailRecentVersions {
1332 gen: u64,
1333 env_name: String,
1334 result: Result<Vec<crate::aws::AppVersion>, String>,
1335 },
1336 FormPrefilled {
1341 gen: u64,
1342 env_name: String,
1343 settings: Result<Vec<(String, String, String)>, String>,
1344 },
1345 FormMultiSelectLoaded {
1352 gen: u64,
1353 env_name: String,
1354 field_key: String,
1355 result: Result<MultiSelectOptions, String>,
1356 },
1357 DeployFromLocal {
1362 gen: u64,
1363 env_name: String,
1364 label: String,
1365 summary: String,
1366 result: Result<(), String>,
1367 },
1368 LogTailOpened {
1372 gen: u64,
1373 session_id: u64,
1374 env_name: String,
1375 log_group: String,
1376 since_ms: i64,
1377 },
1378 LogTailEvents {
1382 gen: u64,
1383 session_id: u64,
1384 next_since_ms: i64,
1385 result: Result<Vec<crate::aws::LogEvent>, String>,
1386 },
1387 WhyRedEvents {
1392 gen: u64,
1393 session_id: u64,
1394 result: Result<Vec<crate::aws::Event>, String>,
1395 },
1396 WhyRedAlarms {
1397 gen: u64,
1398 session_id: u64,
1399 result: Result<Vec<crate::aws::CwAlarm>, String>,
1400 },
1401 WhyRedInstances {
1402 gen: u64,
1403 session_id: u64,
1404 result: Result<Vec<crate::aws::Instance>, String>,
1405 },
1406 WhyRedDeploys {
1407 gen: u64,
1408 session_id: u64,
1409 result: Result<Vec<crate::aws::AppVersion>, String>,
1410 },
1411 WhyRedQueues {
1413 gen: u64,
1414 session_id: u64,
1415 result: Result<crate::aws::WorkerQueues, String>,
1416 },
1417 WhyRedDlqMessages {
1420 gen: u64,
1421 session_id: u64,
1422 result: Result<Vec<crate::aws::QueueMessage>, String>,
1423 },
1424 DryRunResult {
1425 gen: u64,
1426 env_name: String,
1427 result: Result<Vec<Instance>, String>,
1428 },
1429 EnvVarsForEdit {
1435 gen: u64,
1436 env_name: String,
1437 result: Result<Vec<(String, String)>, String>,
1438 },
1439 PreflightEvents {
1440 gen: u64,
1441 env_name: String,
1442 result: Result<Vec<EbEvent>, String>,
1443 },
1444 VersionPreview {
1448 gen: u64,
1449 env_name: String,
1450 result: Result<String, String>,
1451 },
1452 HealthCheckProbe {
1458 gen: u64,
1459 env_name: String,
1460 result: Result<(), String>,
1461 },
1462 UnavailabilityEstimate {
1468 gen: u64,
1469 env_name: String,
1470 line: Option<(String, bool)>,
1471 },
1472 ConfirmModalLint {
1477 gen: u64,
1478 env_name: String,
1479 issues: Vec<crate::lint::Issue>,
1480 },
1481 RolloutPreflight {
1489 gen: u64,
1490 region: String,
1491 result: Result<String, String>,
1492 },
1493 RolloutDispatched {
1501 gen: u64,
1502 region: String,
1503 result: Result<(), String>,
1504 },
1505 UndoCaptured {
1509 gen: u64,
1510 entry: UndoEntry,
1511 },
1512 RollbackTarget {
1516 gen: u64,
1517 env_name: String,
1518 current_version: String,
1519 result: Result<Vec<EbEvent>, String>,
1520 },
1521 Alarms {
1522 gen: u64,
1523 env_name: String,
1524 result: Result<Vec<CwAlarm>, String>,
1525 },
1526 DlqMessages {
1527 gen: u64,
1528 env_name: String,
1529 result: Result<Vec<QueueMessage>, String>,
1530 },
1531 DlqActionResult {
1532 gen: u64,
1533 env_name: String,
1534 result: Result<DlqOp, String>,
1535 },
1536 ActionResult {
1537 gen: u64,
1538 action: Action,
1539 env_name: String,
1540 result: Result<(), String>,
1541 },
1542 DetailLogsProgress {
1547 gen: u64,
1548 env_name: String,
1549 stage: LogTailStage,
1550 attempt: u32,
1551 },
1552 DetailLogs {
1554 gen: u64,
1555 env_name: String,
1556 result: Result<Vec<(String, String)>, String>,
1557 },
1558 TextOverlay {
1565 gen: u64,
1566 title: String,
1567 body: String,
1568 },
1569 AppVersions {
1574 gen: u64,
1575 application: String,
1576 deployed_label: Option<String>,
1577 result: Result<Vec<AppVersion>, String>,
1578 },
1579 UpdateCheck(Option<crate::update_check::LatestRelease>),
1584 AutoRollbackCheck {
1590 gen: u64,
1591 env_name: String,
1592 },
1593 TagUpdate {
1597 gen: u64,
1598 env_name: String,
1599 summary: String,
1600 result: Result<(), String>,
1601 },
1602 OptionSettingsUpdate {
1607 gen: u64,
1608 env_name: String,
1609 summary: String,
1610 result: Result<(), String>,
1611 },
1612 AlarmOp {
1616 gen: u64,
1617 verb: &'static str,
1618 alarm_name: String,
1619 env_name: String,
1620 result: Result<(), String>,
1621 },
1622 DeleteAppVersion {
1624 gen: u64,
1625 application: String,
1626 label: String,
1627 force: bool,
1628 result: Result<(), String>,
1629 },
1630}
1631
1632#[derive(Debug, Clone)]
1633pub enum DlqOp {
1634 Resent {
1635 message_id: String,
1636 },
1637 Purged,
1638 Replayed {
1641 count: usize,
1642 failures: usize,
1643 },
1644}
1645
1646fn is_first_run() -> bool {
1651 let no_state = !crate::util::config_file("state.toml").exists();
1652 let home = std::env::var_os("HOME")
1653 .map(std::path::PathBuf::from)
1654 .unwrap_or_default();
1655 let no_creds = !home.join(".aws").join("credentials").exists()
1656 && !home.join(".aws").join("config").exists();
1657 no_state && no_creds
1658}
1659
1660async fn init_client(
1661 profile: Option<String>,
1662 region: Option<String>,
1663) -> Result<(AwsClient, Option<String>, Option<String>, Option<String>)> {
1664 let (mut client, used_profile, used_region) =
1671 match AwsClient::with(profile.clone(), region.clone()).await {
1672 Ok(c) => (c, profile, region),
1673 Err(e) if profile.is_some() || region.is_some() => {
1674 tracing::warn!(
1675 error = %e,
1676 profile = ?profile,
1677 region = ?region,
1678 "persisted profile/region failed to resolve — falling back to env defaults"
1679 );
1680 let c = AwsClient::with(None, None).await?;
1681 (c, None, None)
1682 }
1683 Err(e) => return Err(e),
1684 };
1685
1686 let warning = match client.verify_identity().await {
1687 Ok(id) => {
1688 client.context.account_id = id.account_id;
1689 client.context.caller_arn = id.caller_arn;
1690 None
1691 }
1692 Err(e) => {
1693 tracing::warn!(
1694 error = %e,
1695 "sts:GetCallerIdentity failed — proceeding without identity. EB describe perms may still be available."
1696 );
1697 Some(format!("identity unknown ({e}); EB calls may still work"))
1698 }
1699 };
1700 Ok((client, used_profile, used_region, warning))
1701}
1702
1703impl App {
1704 pub async fn new(config: Config) -> Result<Self> {
1705 let _ = NOTIFY_WEBHOOK_URL.set(config.notify_webhook.clone());
1712 let persisted = state::load();
1713 let project = crate::project::load_from_cwd();
1719 let project_profile = project.as_ref().and_then(|p| p.profile.clone());
1720 let project_region = project.as_ref().and_then(|p| p.region.clone());
1721 let eb_cli = crate::eb_cli::load_from_cwd();
1727 let eb_cli_profile = eb_cli.as_ref().and_then(|c| c.profile.clone());
1728 let eb_cli_region = eb_cli.as_ref().and_then(|c| c.region.clone());
1729 tracing::info!(
1730 target: "ebman::state",
1731 persisted_profile = ?persisted.profile,
1732 persisted_region = ?persisted.region,
1733 project_profile = ?project_profile,
1734 project_region = ?project_region,
1735 eb_cli_profile = ?eb_cli_profile,
1736 eb_cli_region = ?eb_cli_region,
1737 "state::load"
1738 );
1739 let effective_profile = project_profile
1740 .or(eb_cli_profile)
1741 .or_else(|| persisted.profile.clone());
1742 let effective_region = project_region
1743 .or(eb_cli_region)
1744 .or_else(|| persisted.region.clone());
1745 let (aws, override_profile, override_region, identity_warning) =
1746 init_client(effective_profile, effective_region).await?;
1747 let aws = Arc::new(aws);
1748 let context = aws.context.clone();
1749 tracing::info!(
1750 target: "ebman::state",
1751 override_profile = ?override_profile,
1752 override_region = ?override_region,
1753 context_region = %context.region,
1754 context_profile = ?context.profile,
1755 "init_client returned"
1756 );
1757 let (msg_tx, msg_rx) = mpsc::unbounded_channel();
1758 let mut table_state = TableState::default();
1759 table_state.select(Some(0));
1760
1761 let (sort_key, sort_desc) = parse_sort(persisted.sort.as_deref());
1762 let redact = persisted.redact.or(config.redact_default).unwrap_or(false);
1763 let grouped = persisted
1764 .grouped
1765 .or(config.grouped_default)
1766 .unwrap_or(false);
1767 let events_visible = persisted.events_visible.unwrap_or(false);
1768 let event_time_format = persisted.event_time_format.unwrap_or_default();
1769 let refresh_interval = config.refresh_interval;
1770
1771 let mut app_table_state = TableState::default();
1772 app_table_state.select(Some(0));
1773
1774 let names = builtin_commands();
1775 let plugins_loaded = crate::plugins::load(&names);
1776 for w in &plugins_loaded.warnings {
1777 tracing::warn!(target: "ebman::plugins", "{}", w);
1778 }
1779 let plugin_startup_warning = if plugins_loaded.warnings.is_empty() {
1780 None
1781 } else {
1782 Some(format!("plugins: {}", plugins_loaded.warnings.join("; ")))
1783 };
1784
1785 let mut app = Self {
1786 context,
1787 scope: Scope::Envs,
1788 applications: Vec::new(),
1789 app_table_state,
1790 environments: Vec::new(),
1791 table_state,
1792 table_area: Rect::default(),
1793 mode: Mode::Normal,
1794 filter: persisted.filter.unwrap_or_default(),
1795 load_state: LoadState::Idle,
1796 loading_since: None,
1797 refresh_interval,
1798 loading_visible_until: None,
1799 last_refresh: None,
1800 status_message: None,
1801 error_message: None,
1802 picker: None,
1803 override_profile,
1804 override_region,
1805 history: HashMap::new(),
1806 redact,
1807 grouped,
1808 sort_key,
1809 sort_desc,
1810 command_input: String::new(),
1811 completion: CompletionState::default(),
1812 quickjump_input: String::new(),
1813 extra_regions: config.extra_regions,
1814 event_panel: EventPanel {
1815 events: Vec::new(),
1816 visible: events_visible,
1817 time_format: event_time_format,
1818 for_env: None,
1819 scroll: 0,
1820 area: None,
1821 drag_origin: None,
1822 cursor: None,
1823 height: 10,
1824 },
1825 multi_selected: BTreeSet::new(),
1826 apps_selected: BTreeSet::new(),
1827 focus: Focus::Table,
1828 multi_regions: Vec::new(),
1829 detail: None,
1830 action_flow: None,
1831 dlq: None,
1832 theme: {
1833 let (mut t, warning) = Theme::resolve(&config.theme);
1834 if let Some(w) = warning {
1835 tracing::warn!("{w}");
1836 }
1837 match config.icons.trim().to_ascii_lowercase().as_str() {
1838 "ascii" => t.icons = IconStyle::Ascii,
1839 "powerline" | "nerd" | "nerdfont" => t.icons = IconStyle::Powerline,
1840 _ => {}
1841 }
1842 Arc::new(t)
1843 },
1844 view_mode: ViewMode::Default,
1845 help: HelpState {
1846 scroll: 0,
1847 max_scroll: 0,
1848 topic: HelpTopic::Global,
1849 pre_mode: None,
1850 pre_overlay: None,
1851 },
1852 hover_row: None,
1853 alerts: 0,
1854 worker_dlq_depths: std::collections::HashMap::new(),
1855 deploy_snapshots: persisted
1860 .deploy_snapshots
1861 .iter()
1862 .filter_map(
1863 |(env, raw)| match DeploySnapshot::parse_persisted(env, raw) {
1864 Some(snap) => Some((env.clone(), snap)),
1865 None => {
1866 tracing::warn!(
1871 target: "ebman::state",
1872 env = %env,
1873 raw = %raw,
1874 "malformed deploy_snapshot entry in state.toml — skipping"
1875 );
1876 None
1877 }
1878 },
1879 )
1880 .collect(),
1881 armed_watchdogs: std::collections::HashMap::new(),
1882 watching_deploys: std::collections::HashMap::new(),
1883 deploy_freeze: None,
1884 tf_state: crate::terraform::load_from_cwd(),
1891 tf_managed_envs: std::collections::HashSet::new(),
1892 undo_history: std::collections::VecDeque::new(),
1893 demo_mode: false,
1894 env_instance_counts: std::collections::HashMap::new(),
1895 cost_enabled: persisted.cost_enabled.unwrap_or(false),
1896 costs: std::collections::HashMap::new(),
1897 costs_fetched_at: None,
1898 latest_stacks: std::collections::HashMap::new(),
1899 frozen: false,
1900 first_run_hint: !crate::state::file_exists(),
1901 current_overlay: None,
1902 message_log: VecDeque::with_capacity(MESSAGE_LOG_CAP),
1903 toasts: VecDeque::with_capacity(TOAST_CAP),
1904 palette_input: String::new(),
1905 palette_items: Vec::new(),
1906 palette_filtered: Vec::new(),
1907 palette_state: ListState::default(),
1908 read_only: false,
1909 pinned: persisted.pinned,
1910 pinned_apps: persisted.pinned_apps,
1911 aliases: persisted.aliases,
1912 saved_views: persisted.saved_views,
1913 hidden_cols: persisted.hidden_cols,
1914 custom_metrics: persisted.custom_metrics,
1915 log_reload: None,
1916 log_directive: std::env::var("RUST_LOG")
1917 .unwrap_or_else(|_| "info,aws=warn,hyper=warn".to_string()),
1918 plugins: plugins_loaded.plugins,
1919 status_snapshot_at_refresh: None,
1920 status_message_pinned: false,
1921 throttle_until: None,
1922 consecutive_throttles: 0,
1923 sso_expiry: crate::sso::latest_session_expiry(),
1924 pending_actions: std::collections::VecDeque::with_capacity(PENDING_CAP),
1925 pending_dispatch: None,
1926 form: None,
1927 log_tail_task: None,
1928 log_tail_session: 0,
1929 why_red_session: 0,
1930 why_items: Vec::new(),
1931 update_available: None,
1932 reload_requested: false,
1933 pending_shell_target: None,
1934 pending_env_edit: None,
1935 current_shell: None,
1936 shell_return_mode: Mode::Normal,
1937 last_rendered_buffer: None,
1938 notify_bell: config.notify_bell,
1939 notify_webhook: config.notify_webhook.clone(),
1940 command_aliases: config.command_aliases.clone(),
1941 lint_disable: config.lint_disable.clone(),
1942 explain_enabled: config.explain_enabled,
1943 explain_provider: config.explain_provider.clone(),
1944 explain_model: config.explain_model.clone(),
1945 explain_api_key_env: config.explain_api_key_env.clone(),
1946 explain_ollama_url: config.explain_ollama_url.clone(),
1947 explain_max_tokens: config.explain_max_tokens,
1948 required_tags: config.required_tags,
1949 cfg_icons_raw: config.icons.clone(),
1950 profile_themes: config.profile_themes.clone(),
1951 runbooks: config.runbooks.clone(),
1952 safety_envs: config.safety_envs.clone(),
1953 safety_accounts: config.safety_accounts.clone(),
1954 accounts: config.accounts.clone(),
1955 base_theme_name: config.theme.clone(),
1956 newly_red: HashSet::new(),
1957 newly_added: HashSet::new(),
1958 health_delta: Vec::new(),
1959 status_delta: Vec::new(),
1960 prev_alerts: 0,
1961 prev_health: HashMap::new(),
1962 prev_status: HashMap::new(),
1963 cached_filtered: Vec::new(),
1964 cached_display: Vec::new(),
1965 cached_app_colors: HashMap::new(),
1966 cached_stale_platforms: HashMap::new(),
1967 pending_select: persisted.selected_env,
1968 aws,
1969 generation: 0,
1970 msg_tx,
1971 msg_rx,
1972 quit: false,
1973 };
1974 app.rebuild_view();
1975 if let Some(w) = plugin_startup_warning {
1978 app.error_message = Some(w);
1979 } else if let Some(w) = identity_warning {
1980 app.error_message = Some(w);
1981 }
1982 if is_first_run() {
1983 app.current_overlay = Some(Overlay::Whatsnew(WELCOME_OVERLAY.into()));
1984 }
1985 app.maybe_apply_profile_theme();
1989 if let Some(proj) = project {
1994 if let Some(filter) = proj.filter {
1995 app.filter = filter;
1996 } else if let Some(app_name) = proj.application {
1997 app.filter = app_name;
2001 }
2002 app.runbooks.extend(proj.runbooks);
2003 }
2004 if app.filter.is_empty() {
2009 if let Some(eb) = eb_cli {
2010 if let Some(app_name) = eb.application {
2011 app.filter = app_name;
2012 }
2013 }
2014 }
2015 app.refresh_tf_managed_envs();
2018 Ok(app)
2019 }
2020
2021 pub fn new_demo(config: Config) -> Self {
2029 let mut app = Self::for_tests(crate::aws::AwsClient::stub(), config);
2030 app.demo_mode = true;
2031 crate::demo_fixture::install(&mut app);
2032 app
2033 }
2034
2035 pub(crate) fn for_tests(aws: crate::aws::AwsClient, config: Config) -> Self {
2049 let aws = Arc::new(aws);
2050 let context = aws.context.clone();
2051 let (msg_tx, msg_rx) = mpsc::unbounded_channel();
2052 let mut table_state = TableState::default();
2053 table_state.select(Some(0));
2054 let mut app_table_state = TableState::default();
2055 app_table_state.select(Some(0));
2056 let mut app = Self {
2057 context,
2058 scope: Scope::Envs,
2059 applications: Vec::new(),
2060 app_table_state,
2061 environments: Vec::new(),
2062 table_state,
2063 table_area: Rect::default(),
2064 mode: Mode::Normal,
2065 filter: String::new(),
2066 load_state: LoadState::Idle,
2067 loading_since: None,
2068 refresh_interval: config.refresh_interval,
2069 loading_visible_until: None,
2070 last_refresh: None,
2071 status_message: None,
2072 error_message: None,
2073 picker: None,
2074 override_profile: None,
2075 override_region: None,
2076 history: HashMap::new(),
2077 redact: config.redact_default.unwrap_or(false),
2078 grouped: config.grouped_default.unwrap_or(false),
2079 sort_key: SortKey::App,
2080 sort_desc: false,
2081 command_input: String::new(),
2082 completion: CompletionState::default(),
2083 quickjump_input: String::new(),
2084 extra_regions: config.extra_regions.clone(),
2085 event_panel: EventPanel {
2086 events: Vec::new(),
2087 visible: false,
2088 time_format: EventTimeFormat::default(),
2089 for_env: None,
2090 scroll: 0,
2091 area: None,
2092 drag_origin: None,
2093 cursor: None,
2094 height: 10,
2095 },
2096 multi_selected: BTreeSet::new(),
2097 apps_selected: BTreeSet::new(),
2098 focus: Focus::Table,
2099 multi_regions: Vec::new(),
2100 detail: None,
2101 action_flow: None,
2102 dlq: None,
2103 theme: {
2104 let (mut t, _w) = Theme::resolve(&config.theme);
2105 match config.icons.trim().to_ascii_lowercase().as_str() {
2106 "ascii" => t.icons = IconStyle::Ascii,
2107 "powerline" | "nerd" | "nerdfont" => t.icons = IconStyle::Powerline,
2108 _ => {}
2109 }
2110 Arc::new(t)
2111 },
2112 view_mode: ViewMode::Default,
2113 help: HelpState {
2114 scroll: 0,
2115 max_scroll: 0,
2116 topic: HelpTopic::Global,
2117 pre_mode: None,
2118 pre_overlay: None,
2119 },
2120 hover_row: None,
2121 alerts: 0,
2122 worker_dlq_depths: std::collections::HashMap::new(),
2123 deploy_snapshots: std::collections::HashMap::new(),
2124 armed_watchdogs: std::collections::HashMap::new(),
2125 watching_deploys: std::collections::HashMap::new(),
2126 deploy_freeze: None,
2127 tf_state: None,
2133 tf_managed_envs: std::collections::HashSet::new(),
2134 undo_history: std::collections::VecDeque::new(),
2135 demo_mode: false,
2136 env_instance_counts: std::collections::HashMap::new(),
2137 cost_enabled: false,
2138 costs: std::collections::HashMap::new(),
2139 costs_fetched_at: None,
2140 latest_stacks: std::collections::HashMap::new(),
2141 frozen: false,
2142 first_run_hint: false,
2143 current_overlay: None,
2144 message_log: VecDeque::with_capacity(MESSAGE_LOG_CAP),
2145 toasts: VecDeque::with_capacity(TOAST_CAP),
2146 palette_input: String::new(),
2147 palette_items: Vec::new(),
2148 palette_filtered: Vec::new(),
2149 palette_state: ListState::default(),
2150 read_only: false,
2151 pinned: BTreeSet::new(),
2152 pinned_apps: BTreeSet::new(),
2153 aliases: std::collections::BTreeMap::new(),
2154 saved_views: std::collections::BTreeMap::new(),
2155 hidden_cols: BTreeSet::new(),
2156 custom_metrics: std::collections::BTreeMap::new(),
2157 log_reload: None,
2158 log_directive: "info".to_string(),
2159 plugins: std::collections::BTreeMap::new(),
2160 status_snapshot_at_refresh: None,
2161 status_message_pinned: false,
2162 throttle_until: None,
2163 consecutive_throttles: 0,
2164 sso_expiry: None,
2165 pending_actions: std::collections::VecDeque::with_capacity(PENDING_CAP),
2166 pending_dispatch: None,
2167 form: None,
2168 log_tail_task: None,
2169 log_tail_session: 0,
2170 why_red_session: 0,
2171 why_items: Vec::new(),
2172 update_available: None,
2173 reload_requested: false,
2174 pending_shell_target: None,
2175 pending_env_edit: None,
2176 current_shell: None,
2177 shell_return_mode: Mode::Normal,
2178 last_rendered_buffer: None,
2179 notify_bell: config.notify_bell,
2180 notify_webhook: config.notify_webhook.clone(),
2181 command_aliases: config.command_aliases.clone(),
2182 lint_disable: config.lint_disable.clone(),
2183 explain_enabled: config.explain_enabled,
2184 explain_provider: config.explain_provider.clone(),
2185 explain_model: config.explain_model.clone(),
2186 explain_api_key_env: config.explain_api_key_env.clone(),
2187 explain_ollama_url: config.explain_ollama_url.clone(),
2188 explain_max_tokens: config.explain_max_tokens,
2189 required_tags: config.required_tags.clone(),
2190 cfg_icons_raw: config.icons.clone(),
2191 profile_themes: config.profile_themes.clone(),
2192 runbooks: config.runbooks.clone(),
2193 safety_envs: config.safety_envs.clone(),
2194 safety_accounts: config.safety_accounts.clone(),
2195 accounts: config.accounts.clone(),
2196 base_theme_name: config.theme.clone(),
2197 newly_red: HashSet::new(),
2198 newly_added: HashSet::new(),
2199 health_delta: Vec::new(),
2200 status_delta: Vec::new(),
2201 prev_alerts: 0,
2202 prev_health: HashMap::new(),
2203 prev_status: HashMap::new(),
2204 cached_filtered: Vec::new(),
2205 cached_display: Vec::new(),
2206 cached_app_colors: HashMap::new(),
2207 cached_stale_platforms: HashMap::new(),
2208 pending_select: None,
2209 aws,
2210 generation: 0,
2211 msg_tx,
2212 msg_rx,
2213 quit: false,
2214 };
2215 app.rebuild_view();
2216 app
2217 }
2218
2219 pub async fn run(
2220 &mut self,
2221 terminal: &mut Tui,
2222 mut control_rx: Option<mpsc::UnboundedReceiver<crate::control::ControlOp>>,
2223 ) -> Result<()> {
2224 let mut events = EventStream::new();
2225 let mut ticker = tokio::time::interval(self.refresh_interval);
2226 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2227 let mut anim = tokio::time::interval(Duration::from_millis(100));
2228 anim.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2229 let mut shell_tick = tokio::time::interval(Duration::from_millis(30));
2232 shell_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
2233 let mut sigint = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())
2240 .map_err(|e| color_eyre::eyre::eyre!("install SIGINT handler: {e}"))?;
2241 let mut sigterm = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
2242 .map_err(|e| color_eyre::eyre::eyre!("install SIGTERM handler: {e}"))?;
2243 let mut sighup = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup())
2244 .map_err(|e| color_eyre::eyre::eyre!("install SIGHUP handler: {e}"))?;
2245 let mut prev_mode = self.mode;
2249 self.spawn_refresh();
2250 self.spawn_update_check();
2251
2252 loop {
2253 self.refresh_events_if_selection_changed();
2260
2261 if (self.mode == Mode::Shell) != (prev_mode == Mode::Shell) {
2265 let _ = terminal.clear();
2266 }
2267 prev_mode = self.mode;
2268
2269 let mut snapshot: Option<ratatui::buffer::Buffer> = None;
2270 terminal.draw(|f| {
2271 ui::draw(f, self);
2272 snapshot = Some(f.buffer_mut().clone());
2273 })?;
2274 self.last_rendered_buffer = snapshot;
2275 if self.quit {
2276 break;
2277 }
2278
2279 let prev_status = self.status_message.clone();
2280 let prev_error = self.error_message.clone();
2281
2282 tokio::select! {
2283 _ = sigint.recv() => {
2289 tracing::info!(target: "ebman", "received SIGINT, shutting down gracefully");
2290 self.quit = true;
2291 }
2292 _ = sigterm.recv() => {
2293 tracing::info!(target: "ebman", "received SIGTERM, shutting down gracefully");
2294 self.quit = true;
2295 }
2296 _ = sighup.recv() => {
2297 tracing::info!(target: "ebman", "received SIGHUP, shutting down gracefully");
2298 self.quit = true;
2299 }
2300 maybe_event = events.next() => {
2301 match maybe_event {
2302 Some(Ok(event)) => self.handle_event(event),
2303 Some(Err(e)) => {
2304 self.error_message = Some(format!("input error: {e}"));
2305 }
2306 None => break,
2307 }
2308 }
2309 _ = ticker.tick() => {
2310 self.sso_expiry = crate::sso::latest_session_expiry();
2314 let now = Instant::now();
2315 let backed_off = self
2316 .throttle_until
2317 .map(|t| now < t)
2318 .unwrap_or(false);
2319 if !self.frozen && !backed_off {
2320 self.spawn_refresh();
2321 if matches!(self.mode, Mode::Detail) {
2322 if let Some(d) = self.detail.as_ref() {
2323 if d.auto_refresh {
2324 self.detail_refresh_active_tab();
2325 }
2326 }
2327 }
2328 } else if backed_off && self.throttle_until.is_some_and(|t| now >= t) {
2329 self.throttle_until = None;
2332 }
2333 }
2334 _ = shell_tick.tick(), if self.current_shell.is_some() => {
2335 if let Some(shell) = self.current_shell.as_ref() {
2340 shell.tick_demo_typer();
2341 }
2342 }
2343 _ = anim.tick(), if self.loading_since.is_some()
2344 || !self.toasts.is_empty()
2345 || self.pending_dispatch.is_some()
2346 || !self.armed_watchdogs.is_empty()
2347 || !self.watching_deploys.is_empty()
2348 || matches!(self.current_overlay, Some(Overlay::About(_)))
2349 || self.loading_visible_until.map(|t| Instant::now() < t).unwrap_or(false) => {
2350 }
2356 Some(msg) = self.msg_rx.recv() => {
2357 self.handle_msg(msg);
2358 }
2359 Some(op) = async {
2360 match control_rx.as_mut() {
2361 Some(rx) => rx.recv().await,
2362 None => std::future::pending().await,
2363 }
2364 } => {
2365 self.handle_control_op(op, terminal);
2366 }
2367 }
2368
2369 if self.status_message != prev_status {
2370 if let Some(s) = self.status_message.clone() {
2371 self.log_message(MsgKind::Info, s.clone());
2372 self.push_toast(ToastKind::Info, s);
2373 }
2374 }
2375 if self.error_message != prev_error {
2376 if let Some(s) = self.error_message.clone() {
2377 self.log_message(MsgKind::Error, s.clone());
2378 self.push_toast(ToastKind::Error, s);
2379 }
2380 }
2381 let now = Instant::now();
2383 while self
2384 .toasts
2385 .front()
2386 .map(|t| now.duration_since(t.shown_at) > t.ttl())
2387 .unwrap_or(false)
2388 {
2389 self.toasts.pop_front();
2390 }
2391 self.expire_pending();
2393 self.tick_pending_dispatch();
2398 if let Some(target) = self.pending_shell_target.take() {
2400 self.open_embedded_shell(terminal, &target)?;
2401 }
2402 if let Some((env_name, vars)) = self.pending_env_edit.take() {
2407 if let Err(e) = self.run_env_editor(terminal, &env_name, &vars) {
2408 self.error_message = Some(format!("env-edit: {e}"));
2409 }
2410 }
2411
2412 if matches!(self.mode, Mode::Shell)
2414 && self.current_shell.as_ref().is_some_and(|s| s.is_dead())
2415 {
2416 self.close_shell_session();
2417 }
2418 }
2419 self.persist_state();
2424 Ok(())
2425 }
2426
2427 fn open_embedded_shell(&mut self, terminal: &mut Tui, instance_id: &str) -> Result<()> {
2435 if self.demo_mode {
2445 let size = terminal.size()?;
2446 let rows = size.height.saturating_sub(2).max(4);
2447 let cols = size.width.max(20);
2448 let content = crate::demo_fixture::canned_ssm_session(instance_id);
2449 let session =
2450 crate::shell::ShellSession::demo(instance_id.to_string(), &content, rows, cols);
2451 self.shell_return_mode = self.mode;
2452 self.current_shell = Some(Box::new(session));
2453 self.mode = Mode::Shell;
2454 return Ok(());
2455 }
2456 let region = self.context.region.clone();
2457 let profile = self
2458 .override_profile
2459 .clone()
2460 .or_else(|| self.context.profile.clone());
2461 write_audit_line(
2462 self.context.account_id.as_deref(),
2463 profile.as_deref(),
2464 ®ion,
2465 &format!("stage=dispatched action=SsmSession target={instance_id}"),
2466 );
2467
2468 let size = terminal.size()?;
2469 let rows = size.height.saturating_sub(2).max(4);
2472 let cols = size.width.max(20);
2473
2474 let mut args = vec![
2475 "ssm",
2476 "start-session",
2477 "--target",
2478 instance_id,
2479 "--region",
2480 ®ion,
2481 ];
2482 let prof = profile.clone();
2483 if let Some(p) = prof.as_deref() {
2484 args.push("--profile");
2485 args.push(p);
2486 }
2487 match crate::shell::ShellSession::spawn(
2488 "aws",
2489 &args,
2490 rows,
2491 cols,
2492 format!("ssm: {instance_id}"),
2493 ) {
2494 Ok(session) => {
2495 self.current_shell = Some(Box::new(session));
2496 self.shell_return_mode = self.mode;
2497 self.mode = Mode::Shell;
2498 self.status_message = Some(format!(
2499 "ssm session into {instance_id} — F12 detaches, ^D / exit closes"
2500 ));
2501 }
2502 Err(e) => {
2503 self.error_message = Some(format!(
2504 "could not start SSM session ({e}). Install the AWS CLI + session-manager-plugin and check ssm:StartSession IAM"
2505 ));
2506 }
2507 }
2508 Ok(())
2509 }
2510
2511 pub fn handle_shell_key(&mut self, key: KeyEvent) {
2514 let is_demo_session = self
2520 .current_shell
2521 .as_ref()
2522 .is_some_and(|s| s.writer.is_none());
2523 let detach = matches!(key.code, KeyCode::F(12))
2524 || (is_demo_session && matches!(key.code, KeyCode::Esc));
2525 if detach {
2526 self.mode = self.shell_return_mode;
2527 self.status_message = Some(
2528 "detached from shell — F12 reattaches, or open shell again from Instances tab"
2529 .into(),
2530 );
2531 return;
2532 }
2533 if let Some(shell) = self.current_shell.as_mut() {
2534 if let Some(bytes) = crate::shell::key_event_to_bytes(&key) {
2535 let _ = shell.send(&bytes);
2536 }
2537 }
2538 }
2539
2540 pub fn close_shell_session(&mut self) {
2544 if let Some(mut s) = self.current_shell.take() {
2545 s.kill();
2546 self.status_message = Some(format!("{} ended", s.label));
2547 }
2548 self.mode = self.shell_return_mode;
2549 }
2550
2551 fn run_env_editor(
2563 &mut self,
2564 terminal: &mut Tui,
2565 env_name: &str,
2566 original: &[(String, String)],
2567 ) -> Result<()> {
2568 use crossterm::{
2569 event::{DisableMouseCapture, EnableMouseCapture},
2570 execute,
2571 terminal::{
2572 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
2573 },
2574 };
2575
2576 let editor = std::env::var("VISUAL")
2577 .or_else(|_| std::env::var("EDITOR"))
2578 .unwrap_or_else(|_| "vi".to_string());
2579
2580 let now_ns = std::time::SystemTime::now()
2585 .duration_since(std::time::UNIX_EPOCH)
2586 .map(|d| d.as_nanos())
2587 .unwrap_or(0);
2588 let safe = env_name
2589 .chars()
2590 .map(|c| {
2591 if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
2592 c
2593 } else {
2594 '_'
2595 }
2596 })
2597 .collect::<String>();
2598 let path = std::env::temp_dir().join(format!("ebman-env-{safe}-{now_ns}.env"));
2599
2600 let body = build_env_edit_body(env_name, original);
2601 std::fs::write(&path, body.as_bytes()).wrap_err("writing env-edit temp file")?;
2602
2603 disable_raw_mode()?;
2605 execute!(
2606 terminal.backend_mut(),
2607 LeaveAlternateScreen,
2608 DisableMouseCapture
2609 )?;
2610 terminal.show_cursor()?;
2611
2612 let status = std::process::Command::new(&editor).arg(&path).status();
2613
2614 enable_raw_mode()?;
2616 execute!(
2617 terminal.backend_mut(),
2618 EnterAlternateScreen,
2619 EnableMouseCapture
2620 )?;
2621 terminal.hide_cursor()?;
2622 terminal.clear()?;
2623
2624 match status {
2625 Ok(s) if !s.success() => {
2626 self.error_message = Some(format!(
2627 "$EDITOR ({editor}) exited {} — no changes dispatched",
2628 s.code().unwrap_or(-1)
2629 ));
2630 let _ = std::fs::remove_file(&path);
2631 return Ok(());
2632 }
2633 Err(e) => {
2634 self.error_message = Some(format!(
2635 "couldn't launch editor ({editor}): {e} — set $EDITOR / $VISUAL"
2636 ));
2637 let _ = std::fs::remove_file(&path);
2638 return Ok(());
2639 }
2640 _ => {}
2641 }
2642
2643 let edited = match std::fs::read_to_string(&path) {
2644 Ok(t) => t,
2645 Err(e) => {
2646 self.error_message = Some(format!(
2647 "couldn't re-read temp file at {} — no changes dispatched ({e})",
2648 path.display()
2649 ));
2650 return Ok(());
2651 }
2652 };
2653 let _ = std::fs::remove_file(&path);
2654
2655 let edited_map = parse_env_edit_body(&edited);
2656 let original_map: std::collections::BTreeMap<String, String> = original
2657 .iter()
2658 .map(|(k, v)| (k.clone(), v.clone()))
2659 .collect();
2660 let (to_set, to_remove) = diff_env_vars(
2661 "aws:elasticbeanstalk:application:environment",
2662 &original_map,
2663 &edited_map,
2664 );
2665
2666 if to_set.is_empty() && to_remove.is_empty() {
2667 self.status_message = Some("env-edit: no changes — nothing dispatched".into());
2668 return Ok(());
2669 }
2670
2671 let label = format!(
2672 "env-edit ({} set, {} removed)",
2673 to_set.len(),
2674 to_remove.len()
2675 );
2676 self.spawn_option_settings_update(label, to_set, to_remove);
2677 Ok(())
2678 }
2679
2680 #[allow(dead_code)]
2689 fn run_inline_ssm(&mut self, terminal: &mut Tui, instance_id: &str) -> Result<()> {
2690 use crossterm::{
2691 event::{DisableMouseCapture, EnableMouseCapture},
2692 execute,
2693 terminal::{
2694 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
2695 },
2696 };
2697 disable_raw_mode()?;
2699 execute!(
2700 terminal.backend_mut(),
2701 LeaveAlternateScreen,
2702 DisableMouseCapture
2703 )?;
2704 terminal.show_cursor()?;
2705
2706 let region = self.context.region.clone();
2707 let profile = self
2708 .override_profile
2709 .clone()
2710 .or_else(|| self.context.profile.clone());
2711 write_audit_line(
2712 self.context.account_id.as_deref(),
2713 profile.as_deref(),
2714 ®ion,
2715 &format!("stage=dispatched action=SsmSession target={instance_id}"),
2716 );
2717
2718 println!("→ aws ssm start-session --target {instance_id}");
2719 println!(
2720 " region={region}{}",
2721 match &profile {
2722 Some(p) => format!(" profile={p}"),
2723 None => String::new(),
2724 }
2725 );
2726 println!(" ^D or `exit` to return to ebman");
2727 println!();
2728
2729 let mut cmd = std::process::Command::new("aws");
2730 cmd.arg("ssm")
2731 .arg("start-session")
2732 .arg("--target")
2733 .arg(instance_id)
2734 .arg("--region")
2735 .arg(®ion);
2736 if let Some(p) = &profile {
2737 cmd.arg("--profile").arg(p);
2738 }
2739 let status = cmd.status();
2740
2741 enable_raw_mode()?;
2743 execute!(
2744 terminal.backend_mut(),
2745 EnterAlternateScreen,
2746 EnableMouseCapture
2747 )?;
2748 terminal.hide_cursor()?;
2749 terminal.clear()?;
2750
2751 match status {
2752 Ok(s) if s.success() => {
2753 self.status_message = Some(format!("ssm session to {instance_id} ended"));
2754 }
2755 Ok(s) => {
2756 self.error_message = Some(format!(
2757 "aws ssm start-session exited {} — check that the AWS CLI + session-manager-plugin are installed and you have ssm:StartSession",
2758 s.code().unwrap_or(-1)
2759 ));
2760 }
2761 Err(e) => {
2762 self.error_message = Some(format!(
2763 "could not invoke `aws`: {e} — install the AWS CLI + session-manager-plugin"
2764 ));
2765 }
2766 }
2767 Ok(())
2768 }
2769
2770 pub fn pin_status(&mut self, msg: impl Into<String>) {
2776 self.status_message = Some(msg.into());
2777 self.status_message_pinned = true;
2778 }
2779
2780 pub fn pin_error(&mut self, msg: impl Into<String>) {
2789 self.error_message = Some(msg.into());
2790 self.status_message_pinned = true;
2791 }
2792
2793 fn push_toast(&mut self, kind: ToastKind, text: String) {
2794 if let Some(existing) = self
2800 .toasts
2801 .iter_mut()
2802 .find(|t| t.text == text && t.kind == kind)
2803 {
2804 existing.shown_at = Instant::now();
2805 return;
2806 }
2807 if let Some(new_key) = delta_toast_key(&text) {
2812 if let Some(existing) = self.toasts.iter_mut().find(|t| {
2813 t.kind == kind
2814 && delta_toast_key(&t.text)
2815 .map(|k| k == new_key)
2816 .unwrap_or(false)
2817 }) {
2818 existing.text = text;
2819 existing.shown_at = Instant::now();
2820 return;
2821 }
2822 }
2823 while self.toasts.len() >= TOAST_CAP {
2824 self.toasts.pop_front();
2825 }
2826 self.toasts.push_back(Toast {
2827 text,
2828 kind,
2829 shown_at: Instant::now(),
2830 });
2831 }
2832
2833 fn log_message(&mut self, kind: MsgKind, text: String) {
2834 if self.message_log.len() >= MESSAGE_LOG_CAP {
2835 self.message_log.pop_front();
2836 }
2837 self.message_log.push_back((chrono::Utc::now(), kind, text));
2838 }
2839
2840 fn format_message_log(&self) -> String {
2841 let mut out = String::new();
2842 let account = self
2849 .context
2850 .account_id
2851 .as_deref()
2852 .map(|a| redact_for_log(a, self.redact))
2853 .unwrap_or_else(|| "—".into());
2854 let profile = self.context.profile.as_deref().unwrap_or("default");
2855 out.push_str(&format!(
2856 "context: account={account} · profile={profile} · region={}\n",
2857 self.context.region
2858 ));
2859 if self.message_log.is_empty() {
2860 out.push_str("─────────────────────────────────\n\n");
2861 out.push_str("no messages yet\n");
2862 return out;
2863 }
2864 out.push_str("recent messages (most recent last)\n");
2865 out.push_str("─────────────────────────────────\n\n");
2866 for (when, kind, text) in &self.message_log {
2867 let when = when.with_timezone(&chrono::Local).format("%H:%M:%S");
2868 let tag = match kind {
2869 MsgKind::Info => "INFO",
2870 MsgKind::Error => "ERR ",
2871 };
2872 out.push_str(&format!("{when} {tag} {text}\n"));
2873 }
2874 out
2875 }
2876
2877 fn handle_event(&mut self, event: Event) {
2878 if self.first_run_hint && matches!(event, Event::Key(_) | Event::Mouse(_) | Event::Paste(_))
2883 {
2884 self.first_run_hint = false;
2885 }
2886 match event {
2887 Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => {
2892 self.handle_key(key)
2893 }
2894 Event::Mouse(m) => self.handle_mouse(m),
2895 _ => {}
2896 }
2897 }
2898
2899 fn handle_mouse(&mut self, m: MouseEvent) {
2900 if self.event_panel.visible {
2904 if let Some(area) = self.event_panel.area {
2905 let divider_row = area.y;
2906 let in_drag = self.event_panel.drag_origin.is_some();
2907 match m.kind {
2908 MouseEventKind::Down(MouseButton::Left)
2909 if (m.row as i32 - divider_row as i32).abs() <= 0 =>
2910 {
2911 self.event_panel.drag_origin = Some(self.event_panel.height);
2912 return;
2913 }
2914 MouseEventKind::Drag(MouseButton::Left) if in_drag => {
2915 let footer_bottom = area.y.saturating_add(area.height).saturating_add(2);
2918 let new_height = footer_bottom.saturating_sub(m.row);
2919 self.event_panel.height = new_height.clamp(4, 30);
2920 return;
2921 }
2922 MouseEventKind::Up(MouseButton::Left) if in_drag => {
2923 self.event_panel.drag_origin = None;
2924 return;
2925 }
2926 _ => {}
2927 }
2928 }
2929 }
2930
2931 if matches!(self.mode, Mode::Detail) {
2935 if let Some(d) = self.detail.as_mut() {
2936 if d.tab() == DetailTab::Metrics {
2937 if let MouseEventKind::Moved = m.kind {
2938 let in_body = d
2939 .metrics_body_rect
2940 .map(|r| {
2941 m.column >= r.x
2942 && m.column < r.x.saturating_add(r.width)
2943 && m.row >= r.y
2944 && m.row < r.y.saturating_add(r.height)
2945 })
2946 .unwrap_or(false);
2947 d.metrics_hover_col = if in_body { Some(m.column) } else { None };
2948 }
2949 }
2950 }
2951 return;
2952 }
2953
2954 let mouse_active = matches!(self.mode, Mode::Normal)
2965 && self.scope == Scope::Envs
2966 && self.current_overlay.is_none();
2967 if !mouse_active {
2968 self.hover_row = None;
2969 return;
2970 }
2971 match m.kind {
2972 MouseEventKind::ScrollDown => self.move_selection(1),
2973 MouseEventKind::ScrollUp => self.move_selection(-1),
2974 MouseEventKind::Down(MouseButton::Left) => self.select_row_at(m.column, m.row),
2975 MouseEventKind::Moved => self.update_hover(m.row),
2976 _ => {}
2977 }
2978 }
2979
2980 fn update_hover(&mut self, row: u16) {
2981 let area = self.table_area;
2982 if area.width == 0 || area.height == 0 {
2983 self.hover_row = None;
2984 return;
2985 }
2986 let data_top = area.y.saturating_add(2);
2987 let data_bottom = area.y.saturating_add(area.height).saturating_sub(1);
2988 if row < data_top || row >= data_bottom {
2989 self.hover_row = None;
2990 return;
2991 }
2992 let offset = self.table_state.offset();
2993 let target = offset + (row - data_top) as usize;
2994 self.hover_row = Some(target);
2995 }
2996
2997 fn select_row_at(&mut self, _col: u16, row: u16) {
2998 let area = self.table_area;
2999 if area.width == 0 || area.height == 0 {
3000 return;
3001 }
3002 let data_top = area.y.saturating_add(2);
3004 let data_bottom = area.y.saturating_add(area.height).saturating_sub(1);
3005 if row < data_top || row >= data_bottom {
3006 return;
3007 }
3008 let rows = self.display_rows();
3009 if rows.is_empty() {
3010 return;
3011 }
3012 let offset = self.table_state.offset();
3013 let target = offset + (row - data_top) as usize;
3014 if target < rows.len() && matches!(rows[target], DisplayRow::Env(_)) {
3015 self.table_state.select(Some(target));
3016 }
3017 }
3018
3019 fn handle_key(&mut self, key: KeyEvent) {
3020 if matches!(key.code, KeyCode::Char('c')) && key.modifiers.contains(KeyModifiers::CONTROL) {
3021 self.quit = true;
3022 return;
3023 }
3024
3025 if !matches!(self.mode, Mode::Picker) {
3036 if matches!(
3037 self.current_overlay.as_ref(),
3038 Some(Overlay::SavedConfigsInteractive { .. })
3039 ) {
3040 self.handle_saved_configs_interactive_key(key);
3041 return;
3042 }
3043 if matches!(self.current_overlay.as_ref(), Some(Overlay::LogTail { .. })) {
3044 self.handle_log_tail_key(key);
3045 return;
3046 }
3047 if matches!(
3048 self.current_overlay.as_ref(),
3049 Some(Overlay::AppsActionMenu { .. })
3050 ) {
3051 self.handle_apps_action_menu_key(key);
3052 return;
3053 }
3054 if matches!(
3055 self.current_overlay.as_ref(),
3056 Some(Overlay::ReportBug { .. })
3057 ) {
3058 self.handle_report_bug_key(key);
3059 return;
3060 }
3061 if let Some(Overlay::WhyRed { cursor, .. }) = self.current_overlay.as_mut() {
3066 let item_count = self.why_items.len();
3067 let moved = match key.code {
3068 KeyCode::Char('j') | KeyCode::Down if item_count > 0 => {
3069 *cursor = cursor.saturating_add(1).min(item_count - 1);
3070 true
3071 }
3072 KeyCode::Char('k') | KeyCode::Up if *cursor > 0 => {
3073 *cursor -= 1;
3074 true
3075 }
3076 _ => false,
3077 };
3078 if moved {
3079 return;
3080 }
3081 }
3082 if matches!(key.code, KeyCode::Enter) {
3085 let drill: Option<(WhyItem, String, Option<String>, Option<String>)> =
3086 if let Some(Overlay::WhyRed {
3087 cursor,
3088 queues,
3089 env_name,
3090 ..
3091 }) = self.current_overlay.as_ref()
3092 {
3093 self.why_items.get(*cursor).cloned().map(|item| {
3094 let qs = queues.as_ref().and_then(|r| r.as_ref().ok());
3095 (
3096 item,
3097 env_name.clone(),
3098 qs.and_then(|q| q.main_url.clone()),
3099 qs.and_then(|q| q.dlq_url.clone()),
3100 )
3101 })
3102 } else {
3103 None
3104 };
3105 if let Some((item, env_name, main_url_opt, dlq_url_opt)) = drill {
3106 match item {
3107 WhyItem::Describe(text) => {
3108 self.current_overlay = Some(Overlay::Describe(text));
3109 }
3110 WhyItem::OpenDlq => {
3111 if let Some(dlq_url) = dlq_url_opt {
3112 self.current_overlay = None;
3113 self.open_dlq_from_why(
3114 env_name,
3115 main_url_opt.unwrap_or_default(),
3116 dlq_url,
3117 );
3118 }
3119 }
3120 }
3121 return;
3122 }
3123 }
3124 if let Some(overlay) = self.current_overlay.as_ref() {
3125 let drill_dlq: Option<(String, String, String)> = match overlay {
3129 Overlay::WhyRed {
3130 env_name,
3131 tier,
3132 queues,
3133 ..
3134 } if matches!(key.code, KeyCode::Char('d'))
3135 && tier.eq_ignore_ascii_case("Worker") =>
3136 {
3137 queues
3138 .as_ref()
3139 .and_then(|r| r.as_ref().ok())
3140 .and_then(|qs| {
3141 qs.dlq_url.clone().map(|du| {
3142 (
3143 env_name.clone(),
3144 qs.main_url.clone().unwrap_or_default(),
3145 du,
3146 )
3147 })
3148 })
3149 }
3150 _ => None,
3151 };
3152 if let Some((env_name, main_url, dlq_url)) = drill_dlq {
3153 self.current_overlay = None;
3154 self.open_dlq_from_why(env_name, main_url, dlq_url);
3155 return;
3156 }
3157 let universal = matches!(key.code, KeyCode::Esc | KeyCode::Char('q'));
3158 let variant_extra = match overlay {
3159 Overlay::Describe(_) => {
3160 matches!(key.code, KeyCode::Char('d') | KeyCode::Char('D'))
3161 }
3162 Overlay::Whatsnew(_) => matches!(key.code, KeyCode::Char('w')),
3163 _ => false,
3164 };
3165 if universal || variant_extra {
3166 self.current_overlay = None;
3167 }
3168 return;
3169 }
3170 }
3171
3172 match self.mode {
3173 Mode::Filter => self.handle_filter_key(key),
3174 Mode::Help => self.handle_help_key(key),
3175 Mode::Command => self.handle_command_key(key),
3176 Mode::Shell => self.handle_shell_key(key),
3177 Mode::Palette => self.handle_palette_key(key),
3178 Mode::QuickJump => self.handle_quickjump_key(key),
3179 Mode::Picker => self.handle_picker_key(key),
3180 Mode::Detail => {
3181 if self
3183 .detail
3184 .as_ref()
3185 .is_some_and(|d| d.search_active || d.log_tail.search_active)
3186 {
3187 self.handle_detail_search_key(key);
3188 return;
3189 }
3190 if self
3193 .detail
3194 .as_ref()
3195 .is_some_and(|d| d.config_edit.is_some())
3196 {
3197 self.handle_config_edit_key(key);
3198 return;
3199 }
3200 if let Some(idx) = self
3202 .detail
3203 .as_ref()
3204 .and_then(|d| d.instance_terminate_confirm)
3205 {
3206 match key.code {
3207 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
3208 if let Some(d) = self.detail.as_mut() {
3209 d.instance_terminate_confirm = None;
3210 }
3211 self.spawn_terminate_instance(idx);
3212 }
3213 _ => {
3214 if let Some(d) = self.detail.as_mut() {
3215 d.instance_terminate_confirm = None;
3216 }
3217 self.status_message = Some("terminate cancelled".into());
3218 }
3219 }
3220 return;
3221 }
3222 if self
3224 .detail
3225 .as_ref()
3226 .and_then(|d| d.config_delete_confirm)
3227 .is_some()
3228 {
3229 match key.code {
3230 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
3231 self.commit_config_delete();
3232 }
3233 _ => {
3234 if let Some(d) = self.detail.as_mut() {
3235 d.config_delete_confirm = None;
3236 }
3237 self.status_message = Some("delete cancelled".into());
3238 }
3239 }
3240 return;
3241 }
3242 match key.code {
3243 KeyCode::Esc | KeyCode::Char('q') => {
3244 self.detail = None;
3245 self.mode = Mode::Normal;
3246 }
3247 KeyCode::Tab | KeyCode::Char('l') => self.detail_cycle_tab(1),
3248 KeyCode::BackTab | KeyCode::Char('h') => self.detail_cycle_tab(-1),
3249 KeyCode::Char('j') | KeyCode::Down => self.detail_scroll(1),
3250 KeyCode::Char('k') | KeyCode::Up => self.detail_scroll(-1),
3251 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3252 self.detail_refresh_active_tab();
3253 }
3254 KeyCode::Char('R') => {
3255 if let Some(d) = self.detail.as_mut() {
3256 d.auto_refresh = !d.auto_refresh;
3257 let msg = if d.auto_refresh {
3258 "detail auto-refresh ON"
3259 } else {
3260 "detail auto-refresh off"
3261 };
3262 self.status_message = Some(msg.into());
3263 }
3264 }
3265 KeyCode::Char('T') => {
3266 self.cmd_event_time(&[]);
3267 }
3268 KeyCode::Char('L')
3271 if matches!(
3272 self.detail.as_ref().map(|d| d.tab()),
3273 Some(DetailTab::Events)
3274 ) =>
3275 {
3276 if let Some(d) = self.detail.as_mut() {
3277 d.events_level = d.events_level.next();
3278 d.events_scroll = 0;
3279 let label = d.events_level.label();
3280 self.status_message = Some(format!("events: severity ≥ {label}"));
3281 }
3282 }
3283 KeyCode::Char('w')
3284 if matches!(
3285 self.detail.as_ref().map(|d| d.tab()),
3286 Some(DetailTab::Events)
3287 ) =>
3288 {
3289 if let Some(d) = self.detail.as_mut() {
3290 d.events_window = d.events_window.next();
3291 d.events_scroll = 0;
3292 let label = d.events_window.label();
3293 self.status_message = Some(format!("events: window {label}"));
3294 }
3295 }
3296 KeyCode::Char('?') => {
3297 self.help.topic = HelpTopic::Detail;
3298 self.help.pre_mode = Some(Mode::Detail);
3299 self.mode = Mode::Help;
3300 }
3301 KeyCode::Char('a') => self.open_action_menu(),
3302 KeyCode::Char('b')
3307 if matches!(
3308 self.detail.as_ref().map(|d| d.tab()),
3309 Some(DetailTab::Instances)
3310 ) =>
3311 {
3312 self.open_instance_in_console();
3313 }
3314 KeyCode::Char('b') => self.open_in_console(),
3315 KeyCode::Char('*') => self.toggle_pin_selected(),
3316 KeyCode::Enter
3317 if matches!(
3318 self.detail.as_ref().map(|d| d.tab()),
3319 Some(DetailTab::Health)
3320 ) =>
3321 {
3322 self.drill_health_item();
3323 }
3324 KeyCode::Enter
3325 if matches!(
3326 self.detail.as_ref().map(|d| d.tab()),
3327 Some(DetailTab::Queue)
3328 ) =>
3329 {
3330 let want_main = self
3333 .detail
3334 .as_ref()
3335 .map(|d| d.queue_cursor == 0)
3336 .unwrap_or(false);
3337 if want_main {
3338 self.open_queue_viewer(crate::app::QueueView::Main);
3339 } else {
3340 self.open_queue_viewer(crate::app::QueueView::Dlq);
3341 }
3342 }
3343 KeyCode::Enter
3344 if matches!(
3345 self.detail.as_ref().map(|d| d.tab()),
3346 Some(DetailTab::Instances)
3347 ) =>
3348 {
3349 self.open_instance_info_overlay();
3353 }
3354 KeyCode::Char('i')
3355 if matches!(
3356 self.detail.as_ref().map(|d| d.tab()),
3357 Some(DetailTab::Instances)
3358 ) =>
3359 {
3360 self.open_instance_info_overlay();
3363 }
3364 KeyCode::Enter
3365 if matches!(
3366 self.detail.as_ref().map(|d| d.tab()),
3367 Some(DetailTab::Config)
3368 ) =>
3369 {
3370 self.start_config_edit();
3373 }
3374 KeyCode::Char('n')
3375 if matches!(
3376 self.detail.as_ref().map(|d| d.tab()),
3377 Some(DetailTab::Config)
3378 ) =>
3379 {
3380 self.start_config_add();
3383 }
3384 KeyCode::Char('x')
3385 if matches!(
3386 self.detail.as_ref().map(|d| d.tab()),
3387 Some(DetailTab::Config)
3388 ) =>
3389 {
3390 self.arm_config_delete();
3393 }
3394 KeyCode::Char('r')
3395 if matches!(
3396 self.detail.as_ref().map(|d| d.tab()),
3397 Some(DetailTab::Config)
3398 ) =>
3399 {
3400 self.start_config_rename();
3403 }
3404 KeyCode::Char('y')
3405 if matches!(
3406 self.detail.as_ref().map(|d| d.tab()),
3407 Some(DetailTab::Instances)
3408 ) =>
3409 {
3410 self.yank_instance_id();
3411 }
3412 KeyCode::Char('s')
3413 if matches!(
3414 self.detail.as_ref().map(|d| d.tab()),
3415 Some(DetailTab::Instances)
3416 ) =>
3417 {
3418 if let Some(d) = self.detail.as_ref() {
3421 if let Some(inst) = d.instances.get(d.instances_cursor) {
3422 self.pending_shell_target = Some(inst.id.clone());
3423 }
3424 }
3425 }
3426 KeyCode::Char('s')
3427 if matches!(
3428 self.detail.as_ref().map(|d| d.tab()),
3429 Some(DetailTab::Logs)
3430 ) =>
3431 {
3432 if let Some(d) = self.detail.as_ref() {
3437 let env_name = d.env_name.clone();
3438 self.spawn_logs_tail(env_name, None);
3439 }
3440 }
3441 KeyCode::Char('x')
3442 if matches!(
3443 self.detail.as_ref().map(|d| d.tab()),
3444 Some(DetailTab::Instances)
3445 ) =>
3446 {
3447 if let Some(d) = self.detail.as_mut() {
3450 if d.instances.get(d.instances_cursor).is_some() {
3451 d.instance_terminate_confirm = Some(d.instances_cursor);
3452 }
3453 }
3454 }
3455 KeyCode::Char('d') => self.open_dlq(),
3456 KeyCode::Char('D') => self.open_describe_overlay(),
3457 KeyCode::Char(']')
3458 if matches!(
3459 self.detail.as_ref().map(|d| d.tab()),
3460 Some(DetailTab::Metrics)
3461 ) =>
3462 {
3463 self.cycle_metrics_range(1);
3464 }
3465 KeyCode::Char('[')
3466 if matches!(
3467 self.detail.as_ref().map(|d| d.tab()),
3468 Some(DetailTab::Metrics)
3469 ) =>
3470 {
3471 self.cycle_metrics_range(-1);
3472 }
3473 KeyCode::Char(']') if self.detail.is_none() && !self.saved_views.is_empty() => {
3481 self.cycle_saved_view(1);
3482 }
3483 KeyCode::Char('[') if self.detail.is_none() && !self.saved_views.is_empty() => {
3484 self.cycle_saved_view(-1);
3485 }
3486 KeyCode::Char('/')
3487 if matches!(
3488 self.detail.as_ref().map(|d| d.tab()),
3489 Some(DetailTab::Events)
3490 ) =>
3491 {
3492 if let Some(d) = self.detail.as_mut() {
3493 d.search_active = true;
3494 d.search_input.clear();
3495 d.search_error = None;
3496 }
3497 }
3498 KeyCode::Char('/')
3499 if matches!(
3500 self.detail.as_ref().map(|d| d.tab()),
3501 Some(DetailTab::Logs)
3502 ) =>
3503 {
3504 if let Some(d) = self.detail.as_mut() {
3505 d.log_tail.search_active = true;
3506 d.log_tail.search_input.clear();
3507 d.log_tail.search_error = None;
3508 }
3509 }
3510 KeyCode::Char('n')
3511 if matches!(
3512 self.detail.as_ref().map(|d| d.tab()),
3513 Some(DetailTab::Events)
3514 ) =>
3515 {
3516 self.detail_search_jump(1);
3517 }
3518 KeyCode::Char('N')
3519 if matches!(
3520 self.detail.as_ref().map(|d| d.tab()),
3521 Some(DetailTab::Events)
3522 ) =>
3523 {
3524 self.detail_search_jump(-1);
3525 }
3526 _ => {}
3527 }
3528 }
3529 Mode::Action => {
3530 if key.code == KeyCode::Char('?') {
3531 self.help.topic = HelpTopic::Action;
3532 self.help.pre_mode = Some(Mode::Action);
3533 self.mode = Mode::Help;
3534 } else {
3535 self.handle_action_key(key);
3536 }
3537 }
3538 Mode::Dlq => {
3539 if key.code == KeyCode::Char('?') {
3540 self.help.topic = HelpTopic::Dlq;
3541 self.help.pre_mode = Some(Mode::Dlq);
3542 self.mode = Mode::Help;
3543 } else {
3544 self.handle_dlq_key(key);
3545 }
3546 }
3547 Mode::Form => self.handle_form_key(key),
3548 Mode::Normal => {
3549 match key.code {
3550 KeyCode::Char('q') => self.quit = true,
3551 KeyCode::Char('U') if self.pending_dispatch.is_some() => {
3556 self.cancel_pending_dispatch();
3557 }
3558 KeyCode::Esc if !self.multi_selected.is_empty() => {
3562 let n = self.multi_selected.len();
3563 self.multi_selected.clear();
3564 self.status_message = Some(format!("multi-select cleared ({n} env(s))"));
3565 }
3566 KeyCode::Esc if !self.apps_selected.is_empty() => {
3567 let n = self.apps_selected.len();
3568 self.apps_selected.clear();
3569 self.status_message =
3570 Some(format!("apps multi-select cleared ({n} app(s))"));
3571 }
3572 KeyCode::Tab => self.set_scope(self.scope.next()),
3573 KeyCode::BackTab => self.set_scope(self.scope.prev()),
3574 KeyCode::Enter if self.scope == Scope::Apps => self.drill_into_app(),
3575 KeyCode::Enter => self.open_detail(),
3576 KeyCode::Char('a') if self.scope == Scope::Apps => {
3577 self.open_apps_action_menu();
3578 }
3579 KeyCode::Char('a') if self.scope == Scope::Envs => self.open_action_menu(),
3580 KeyCode::Char('b') if self.scope == Scope::Apps => {
3581 self.open_app_in_console();
3582 }
3583 KeyCode::F(5) => self.manual_refresh(),
3584 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3585 self.manual_refresh();
3586 }
3587 KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3588 self.redact = !self.redact;
3589 self.status_message = Some(if self.redact {
3590 "redact mode ON".into()
3591 } else {
3592 "redact mode off".into()
3593 });
3594 }
3595 KeyCode::Char('g') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3596 self.grouped = !self.grouped;
3597 self.rebuild_view();
3598 self.status_message = Some(if self.grouped {
3599 "grouped by application".into()
3600 } else {
3601 "ungrouped".into()
3602 });
3603 }
3604 KeyCode::Char('e') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3605 self.event_panel.visible = !self.event_panel.visible;
3606 if self.event_panel.visible {
3607 self.event_panel.scroll = 0;
3608 if self.event_panel.events.is_empty() {
3610 self.spawn_events();
3611 }
3612 }
3613 }
3614 KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3615 self.view_mode = self.view_mode.next();
3616 self.status_message = Some(format!("view: {}", self.view_mode.label()));
3617 }
3618 KeyCode::Up
3619 if key.modifiers.contains(KeyModifiers::CONTROL)
3620 && self.event_panel.visible =>
3621 {
3622 self.event_panel.height = (self.event_panel.height + 1).min(30);
3623 }
3624 KeyCode::Down
3625 if key.modifiers.contains(KeyModifiers::CONTROL)
3626 && self.event_panel.visible =>
3627 {
3628 self.event_panel.height = self.event_panel.height.saturating_sub(1).max(4);
3629 }
3630 KeyCode::Char('s') => {
3631 self.sort_key = self.sort_key.next();
3632 self.resort_envs();
3633 self.status_message = Some(format!(
3634 "sort: {} ({})",
3635 self.sort_key.label(),
3636 if self.sort_desc { "desc" } else { "asc" }
3637 ));
3638 }
3639 KeyCode::Char('S') => {
3640 self.sort_desc = !self.sort_desc;
3641 self.resort_envs();
3642 self.status_message = Some(format!(
3643 "sort: {} ({})",
3644 self.sort_key.label(),
3645 if self.sort_desc { "desc" } else { "asc" }
3646 ));
3647 }
3648 KeyCode::Char('T') => {
3649 self.cmd_event_time(&[]);
3650 }
3651 KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3652 self.export_tsv();
3653 }
3654 KeyCode::Char('w') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3655 self.yank_cli();
3656 }
3657 KeyCode::Char(']') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3658 self.focus = match self.focus {
3659 Focus::Table => {
3660 if self.event_panel.visible {
3661 Focus::Events
3662 } else {
3663 Focus::Table
3664 }
3665 }
3666 Focus::Events => Focus::Table,
3667 };
3668 if matches!(self.focus, Focus::Events) && self.event_panel.cursor.is_none()
3669 {
3670 self.event_panel.cursor = Some(0);
3671 }
3672 if matches!(self.focus, Focus::Table) {
3673 self.event_panel.cursor = None;
3674 }
3675 self.status_message = Some(format!(
3676 "focus: {}",
3677 if matches!(self.focus, Focus::Table) {
3678 "table"
3679 } else {
3680 "events"
3681 }
3682 ));
3683 }
3684 KeyCode::Char('[') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3685 self.focus = match self.focus {
3686 Focus::Events => Focus::Table,
3687 Focus::Table => {
3688 if self.event_panel.visible {
3689 Focus::Events
3690 } else {
3691 Focus::Table
3692 }
3693 }
3694 };
3695 }
3696 KeyCode::Char(' ') if self.scope == Scope::Envs => {
3697 if let Some(env) = self.selected_env().cloned() {
3698 if !self.multi_selected.remove(&env.name) {
3699 self.multi_selected.insert(env.name);
3700 }
3701 let n = self.multi_selected.len();
3702 self.status_message = if n == 0 {
3703 Some("multi-select cleared".into())
3704 } else {
3705 Some(format!(
3706 "{n} env(s) selected (a = batch action, esc = clear)"
3707 ))
3708 };
3709 }
3710 }
3711 KeyCode::Char(' ') if self.scope == Scope::Apps => {
3712 if let Some(idx) = self.app_table_state.selected() {
3718 if let Some(name) = self.applications.get(idx).map(|a| a.name.clone()) {
3719 if !self.apps_selected.remove(&name) {
3720 self.apps_selected.insert(name);
3721 }
3722 let n = self.apps_selected.len();
3723 self.status_message = if n == 0 {
3724 Some("apps multi-select cleared".into())
3725 } else {
3726 Some(format!("{n} app(s) selected (esc = clear)"))
3727 };
3728 }
3729 }
3730 }
3731 KeyCode::Char('y') => {
3732 if let Some(i) = self.event_panel.cursor {
3733 self.yank_event_at(i);
3734 } else {
3735 self.yank_selected(YankKind::Cname);
3736 }
3737 }
3738 KeyCode::Char('Y') => self.yank_selected(YankKind::Name),
3739 KeyCode::Char('J')
3740 if self.event_panel.visible && !self.event_panel.events.is_empty() =>
3741 {
3742 let next = self
3743 .event_panel
3744 .cursor
3745 .map(|c| (c + 1).min(self.event_panel.events.len().saturating_sub(1)))
3746 .unwrap_or(0);
3747 self.event_panel.cursor = Some(next);
3748 }
3749 KeyCode::Char('K')
3750 if self.event_panel.visible && !self.event_panel.events.is_empty() =>
3751 {
3752 self.event_panel.cursor =
3753 self.event_panel.cursor.and_then(|c| c.checked_sub(1));
3754 }
3755 KeyCode::Char('b') if self.scope == Scope::Envs => self.open_in_console(),
3756 KeyCode::Char('D') if self.scope == Scope::Envs => self.open_describe_overlay(),
3757 KeyCode::Char('*') if self.scope == Scope::Envs => self.toggle_pin_selected(),
3758 KeyCode::Char('*') if self.scope == Scope::Apps => {
3759 self.toggle_pin_selected_app()
3760 }
3761 KeyCode::Char('!') if self.scope == Scope::Envs => {
3762 if let Some(env) = self.selected_env() {
3768 let env_name = env.name.clone();
3769 let app_name = env.application.clone();
3770 self.open_why_red(env_name, app_name);
3771 } else {
3772 self.error_message = Some("no env selected".into());
3773 }
3774 }
3775 KeyCode::Char('f') if self.scope == Scope::Envs => {
3776 self.frozen = !self.frozen;
3777 self.status_message = Some(if self.frozen {
3778 "frozen — auto-refresh paused".into()
3779 } else {
3780 "unfrozen".into()
3781 });
3782 }
3783 KeyCode::Char(c @ '1'..='9') => self.quick_jump((c as u8 - b'0') as usize),
3784 KeyCode::Char('?') => {
3785 self.help.topic = HelpTopic::Global;
3786 self.help.pre_mode = Some(Mode::Normal);
3787 self.mode = Mode::Help;
3788 }
3789 KeyCode::Char(':') => {
3790 self.command_input.clear();
3791 self.mode = Mode::Command;
3792 }
3793 KeyCode::Char('k') if key.modifiers.contains(KeyModifiers::CONTROL) => {
3794 self.open_palette();
3795 }
3796 KeyCode::Char('\'') => {
3797 self.quickjump_input.clear();
3798 self.mode = Mode::QuickJump;
3799 }
3800 KeyCode::Char('/') => {
3801 self.filter.clear();
3802 self.mode = Mode::Filter;
3803 }
3804 KeyCode::Char('p') => self.open_profile_picker(),
3805 KeyCode::Char('r') => self.open_region_picker(),
3806 KeyCode::Char('j') | KeyCode::Down => match self.focus {
3807 Focus::Events if self.event_panel.visible => {
3808 let next = self
3809 .event_panel
3810 .cursor
3811 .map(|c| {
3812 (c + 1).min(self.event_panel.events.len().saturating_sub(1))
3813 })
3814 .unwrap_or(0);
3815 self.event_panel.cursor = Some(next);
3816 }
3817 _ => self.move_scope_selection(1),
3818 },
3819 KeyCode::Char('k') | KeyCode::Up => match self.focus {
3820 Focus::Events if self.event_panel.visible => {
3821 self.event_panel.cursor =
3822 self.event_panel.cursor.and_then(|c| c.checked_sub(1));
3823 }
3824 _ => self.move_scope_selection(-1),
3825 },
3826 KeyCode::Char('g') | KeyCode::Home => self.scope_select_first(),
3827 KeyCode::Char('G') | KeyCode::End => self.scope_select_last(),
3828 _ => {}
3829 }
3830 }
3831 }
3832 }
3833
3834 fn handle_control_op(&mut self, op: crate::control::ControlOp, _terminal: &mut Tui) {
3838 use crate::control::ControlOp;
3839 match op {
3840 ControlOp::Screen(reply) => {
3841 let text = self
3842 .last_rendered_buffer
3843 .as_ref()
3844 .map(crate::control::render_buffer_as_text)
3845 .unwrap_or_else(|| "(no frame rendered yet)".to_string());
3846 let _ = reply.send(text);
3847 }
3848 ControlOp::Key(ke) => {
3849 self.handle_event(Event::Key(ke));
3850 }
3851 ControlOp::Command(text) => {
3852 self.execute_command(&text);
3853 }
3854 ControlOp::Reload => {
3855 self.reload_requested = true;
3856 self.quit = true;
3857 self.status_message = Some("reloading (exec self)…".into());
3858 }
3859 ControlOp::State(reply) => {
3860 let selected = self
3861 .selected_env()
3862 .map(|e| e.name.clone())
3863 .unwrap_or_default();
3864 let env_count = self.environments.len();
3865 let load = match self.load_state {
3866 LoadState::Idle => "idle",
3867 LoadState::Loading => "loading",
3868 LoadState::Error => "error",
3869 };
3870 let esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
3871 let json = format!(
3872 "{{\"mode\":\"{:?}\",\"profile\":\"{}\",\"region\":\"{}\",\"account\":\"{}\",\"envs\":{},\"selected\":\"{}\",\"filter\":\"{}\",\"load\":\"{}\",\"sort\":\"{}\",\"grouped\":{},\"redact\":{},\"focus\":\"{:?}\"}}",
3873 self.mode,
3874 esc(self.context.profile.as_deref().unwrap_or("")),
3875 esc(&self.context.region),
3876 esc(self.context.account_id.as_deref().unwrap_or("")),
3877 env_count,
3878 esc(&selected),
3879 esc(&self.filter),
3880 load,
3881 self.sort_key.label(),
3882 self.grouped,
3883 self.redact,
3884 self.focus,
3885 );
3886 let _ = reply.send(json);
3887 }
3888 }
3889 }
3890
3891 fn manual_refresh(&mut self) {
3892 self.spawn_refresh();
3893 self.status_message = Some("refresh requested".into());
3894 }
3895
3896 pub(crate) fn cmd_cost(&mut self, rest: &[&str]) {
3903 let next = match rest.first().copied() {
3904 Some("on") | Some("true") | Some("enable") => true,
3905 Some("off") | Some("false") | Some("disable") => false,
3906 Some("status") | None => {
3907 let pretty = match (self.cost_enabled, self.costs_fetched_at) {
3908 (false, _) => "off".to_string(),
3909 (true, None) => "on (no data yet)".into(),
3910 (true, Some(t)) => {
3911 let age = chrono::Utc::now()
3912 .signed_duration_since(t)
3913 .to_std()
3914 .unwrap_or_default();
3915 format!(
3916 "on (refreshed {} ago, {} env(s) cached)",
3917 humanize_short_age(age),
3918 self.costs.len()
3919 )
3920 }
3921 };
3922 self.status_message = Some(format!("cost: {pretty}"));
3923 return;
3924 }
3925 Some(other) => {
3926 self.error_message =
3927 Some(format!("usage: :cost on | off | status (got '{other}')"));
3928 return;
3929 }
3930 };
3931 if next == self.cost_enabled {
3932 self.status_message =
3933 Some(format!("cost: already {}", if next { "on" } else { "off" }));
3934 return;
3935 }
3936 self.cost_enabled = next;
3937 if next {
3938 let account = self
3943 .context
3944 .account_id
3945 .clone()
3946 .unwrap_or_else(|| "unknown".into());
3947 let cache = crate::cost_cache::load(&account, &self.context.region);
3948 let now = chrono::Utc::now();
3949 let stale = cache.is_stale(now);
3950 self.costs = cache.costs;
3951 self.costs_fetched_at = cache.fetched_at;
3952 if stale {
3953 self.spawn_cost_fetch();
3957 self.status_message =
3958 Some("cost: on — fetching latest from Cost Explorer (1-3s; cached 24h)".into());
3959 } else {
3960 let age = now
3965 .signed_duration_since(cache.fetched_at.unwrap_or(now))
3966 .to_std()
3967 .unwrap_or_default();
3968 self.status_message = Some(format!(
3969 "cost: on — cached ({} ago; AWS refreshes ~24h)",
3970 humanize_short_age(age)
3971 ));
3972 }
3973 } else {
3974 self.costs.clear();
3975 self.costs_fetched_at = None;
3976 self.status_message = Some("cost: off — column hidden, cache preserved".into());
3977 }
3978 self.persist_state();
3979 }
3980
3981 fn spawn_aws<T, Fut, Op, Build>(&self, op_name: &'static str, op: Op, into_msg: Build)
3996 where
3997 T: Send + 'static,
3998 Fut: std::future::Future<Output = Result<T, color_eyre::eyre::Report>> + Send + 'static,
3999 Op: FnOnce(Arc<AwsClient>) -> Fut + Send + 'static,
4000 Build: FnOnce(u64, Result<T, String>) -> AppMsg + Send + 'static,
4001 {
4002 let aws = self.aws.clone();
4003 let tx = self.msg_tx.clone();
4004 let gen = self.generation;
4005 tokio::spawn(async move {
4006 let result = op(aws).await.map_err(|e| flatten_err(op_name, e));
4007 let _ = tx.send(into_msg(gen, result));
4008 });
4009 }
4010
4011 fn spawn_cost_fetch(&mut self) {
4012 let account = self.context.account_id.clone();
4013 let region = self.context.region.clone();
4014 self.spawn_aws(
4015 "fetch_env_costs",
4016 move |aws| async move { aws.fetch_env_costs().await },
4017 move |gen, result| AppMsg::CostsFetched {
4018 gen,
4019 account,
4020 region,
4021 result,
4022 },
4023 );
4024 }
4025
4026 fn spawn_alarms_fetch(&mut self, env_name: String) {
4027 self.current_overlay = Some(Overlay::Alarms {
4031 env_name: env_name.clone(),
4032 body: format!("fetching alarms for {env_name}…"),
4033 });
4034 let name_for_msg = env_name.clone();
4035 self.spawn_aws(
4036 "list_alarms_for_env",
4037 move |aws| async move { aws.list_alarms_for_env(&env_name).await },
4038 move |gen, result| AppMsg::Alarms {
4039 gen,
4040 env_name: name_for_msg,
4041 result,
4042 },
4043 );
4044 }
4045
4046 fn open_why_red(&mut self, env_name: String, app_name: String) {
4052 self.why_red_session = self.why_red_session.wrapping_add(1);
4053 let session_id = self.why_red_session;
4054 let tier = self
4059 .environments
4060 .iter()
4061 .find(|e| e.name == env_name)
4062 .map(|e| e.tier.clone())
4063 .unwrap_or_default();
4064 let is_worker = tier.eq_ignore_ascii_case("Worker");
4065 self.current_overlay = Some(Overlay::WhyRed {
4066 env_name: env_name.clone(),
4067 tier,
4068 events: None,
4069 alarms: None,
4070 instances: None,
4071 deploys: None,
4072 queues: None,
4076 dlq_messages: None,
4077 session_id,
4078 cursor: 0,
4079 });
4080 self.spawn_why_red_events(env_name.clone(), session_id);
4081 self.spawn_why_red_alarms(env_name.clone(), session_id);
4082 self.spawn_why_red_instances(env_name.clone(), session_id);
4083 self.spawn_why_red_deploys(app_name.clone(), session_id);
4084 if is_worker {
4085 self.spawn_why_red_queues(app_name, env_name, session_id);
4086 }
4087 }
4088
4089 fn spawn_why_red_queues(&self, app_name: String, env_name: String, session_id: u64) {
4090 if self.demo_mode {
4091 let result = Ok(crate::demo_fixture::worker_queues_for_env(&env_name));
4092 let gen = self.generation;
4093 let _ = self.msg_tx.send(AppMsg::WhyRedQueues {
4094 gen,
4095 session_id,
4096 result,
4097 });
4098 return;
4099 }
4100 self.spawn_aws(
4101 "describe_worker_queues",
4102 move |aws| async move { aws.describe_worker_queues(&app_name, &env_name).await },
4103 move |gen, result| AppMsg::WhyRedQueues {
4104 gen,
4105 session_id,
4106 result,
4107 },
4108 );
4109 }
4110
4111 fn spawn_why_red_dlq_peek(&self, dlq_url: String, session_id: u64) {
4118 self.spawn_aws(
4119 "peek_messages",
4120 move |aws| async move { aws.peek_messages(&dlq_url, 3).await },
4121 move |gen, result| AppMsg::WhyRedDlqMessages {
4122 gen,
4123 session_id,
4124 result,
4125 },
4126 );
4127 }
4128
4129 fn spawn_why_red_events(&self, env_name: String, session_id: u64) {
4130 if self.demo_mode {
4131 let result = Ok(crate::demo_fixture::events_for_env(&env_name));
4132 let gen = self.generation;
4133 let _ = self.msg_tx.send(AppMsg::WhyRedEvents {
4134 gen,
4135 session_id,
4136 result,
4137 });
4138 return;
4139 }
4140 self.spawn_aws(
4141 "list_events_for_env",
4142 move |aws| async move { aws.list_events_for_env(&env_name, 50).await },
4143 move |gen, result| AppMsg::WhyRedEvents {
4144 gen,
4145 session_id,
4146 result,
4147 },
4148 );
4149 }
4150
4151 fn spawn_why_red_alarms(&self, env_name: String, session_id: u64) {
4152 if self.demo_mode {
4153 let result = Ok(crate::demo_fixture::alarms_for_env(&env_name));
4154 let gen = self.generation;
4155 let _ = self.msg_tx.send(AppMsg::WhyRedAlarms {
4156 gen,
4157 session_id,
4158 result,
4159 });
4160 return;
4161 }
4162 self.spawn_aws(
4163 "list_alarms_for_env",
4164 move |aws| async move { aws.list_alarms_for_env(&env_name).await },
4165 move |gen, result| AppMsg::WhyRedAlarms {
4166 gen,
4167 session_id,
4168 result,
4169 },
4170 );
4171 }
4172
4173 fn spawn_why_red_instances(&self, env_name: String, session_id: u64) {
4174 if self.demo_mode {
4175 let result = Ok(crate::demo_fixture::instances_for(&env_name));
4176 let gen = self.generation;
4177 let _ = self.msg_tx.send(AppMsg::WhyRedInstances {
4178 gen,
4179 session_id,
4180 result,
4181 });
4182 return;
4183 }
4184 self.spawn_aws(
4185 "list_instances",
4186 move |aws| async move { aws.list_instances(&env_name).await },
4187 move |gen, result| AppMsg::WhyRedInstances {
4188 gen,
4189 session_id,
4190 result,
4191 },
4192 );
4193 }
4194
4195 fn spawn_why_red_deploys(&self, app_name: String, session_id: u64) {
4196 if self.demo_mode {
4197 let result = Ok(crate::demo_fixture::deploys_for_app(&app_name));
4198 let gen = self.generation;
4199 let _ = self.msg_tx.send(AppMsg::WhyRedDeploys {
4200 gen,
4201 session_id,
4202 result,
4203 });
4204 return;
4205 }
4206 self.spawn_aws(
4207 "list_application_versions",
4208 move |aws| async move { aws.list_application_versions(&app_name).await },
4209 move |gen, result| AppMsg::WhyRedDeploys {
4210 gen,
4211 session_id,
4212 result,
4213 },
4214 );
4215 }
4216
4217 fn spawn_detail_alarms(&mut self, env_name: String) {
4224 if let Some(d) = self.detail.as_mut() {
4225 d.loading_cw_alarms = true;
4226 }
4227 if self.demo_mode {
4232 let result = Ok(crate::demo_fixture::alarms_for_env(&env_name));
4233 let gen = self.generation;
4234 let _ = self.msg_tx.send(AppMsg::DetailAlarms {
4235 gen,
4236 env_name,
4237 result,
4238 });
4239 return;
4240 }
4241 let env_for_msg = env_name.clone();
4242 self.spawn_aws(
4243 "list_alarms_for_env",
4244 move |aws| async move { aws.list_alarms_for_env(&env_name).await },
4245 move |gen, result| AppMsg::DetailAlarms {
4246 gen,
4247 env_name: env_for_msg,
4248 result,
4249 },
4250 );
4251 }
4252
4253 fn spawn_detail_recent_versions(&mut self, app_name: String, env_name: String) {
4256 if let Some(d) = self.detail.as_mut() {
4257 d.loading_recent_versions = true;
4258 }
4259 if self.demo_mode {
4260 let result = Ok(crate::demo_fixture::deploys_for_app(&app_name));
4261 let gen = self.generation;
4262 let _ = self.msg_tx.send(AppMsg::DetailRecentVersions {
4263 gen,
4264 env_name,
4265 result,
4266 });
4267 return;
4268 }
4269 self.spawn_aws(
4270 "list_application_versions",
4271 move |aws| async move { aws.list_application_versions(&app_name).await },
4272 move |gen, result| AppMsg::DetailRecentVersions {
4273 gen,
4274 env_name,
4275 result,
4276 },
4277 );
4278 }
4279
4280 fn set_log_level(&mut self, level: &str) {
4281 let directive = match level.to_lowercase().as_str() {
4284 "trace" | "debug" | "info" | "warn" | "error" => {
4285 format!("{level},aws=warn,hyper=warn")
4286 }
4287 other => other.to_string(),
4288 };
4289 let new_filter = match tracing_subscriber::EnvFilter::try_new(&directive) {
4290 Ok(f) => f,
4291 Err(e) => {
4292 self.error_message = Some(format!("invalid log directive '{level}': {e}"));
4293 return;
4294 }
4295 };
4296 let Some(handle) = self.log_reload.as_ref() else {
4297 self.error_message = Some("log reload handle missing".into());
4298 return;
4299 };
4300 match handle.modify(|f| *f = new_filter) {
4301 Ok(()) => {
4302 self.log_directive = directive.clone();
4303 self.status_message = Some(format!("log level → {directive}"));
4304 }
4305 Err(e) => self.error_message = Some(format!("log reload failed: {e}")),
4306 }
4307 }
4308
4309 fn open_whatsnew(&mut self) {
4310 self.current_overlay = Some(Overlay::Whatsnew(WHATSNEW.into()));
4313 }
4314
4315 pub(crate) fn open_report_bug_overlay(&mut self) {
4326 let cnames: std::collections::BTreeSet<String> = self
4327 .environments
4328 .iter()
4329 .filter(|e| !e.cname.is_empty())
4330 .map(|e| e.cname.clone())
4331 .collect();
4332 let env_names: std::collections::BTreeSet<String> =
4333 self.environments.iter().map(|e| e.name.clone()).collect();
4334 let app_names: std::collections::BTreeSet<String> =
4335 self.applications.iter().map(|a| a.name.clone()).collect();
4336 let recent_messages: Vec<String> = self
4341 .message_log
4342 .iter()
4343 .rev()
4344 .take(10)
4345 .map(|(ts, kind, text)| {
4346 let sev = match kind {
4347 MsgKind::Info => "[i]",
4348 MsgKind::Error => "[!]",
4349 };
4350 let when = ts.format("%H:%M:%S");
4351 format!("{when} {sev} {text}")
4352 })
4353 .collect::<Vec<_>>()
4354 .into_iter()
4355 .rev()
4356 .collect();
4357 let icons = format!("{:?}", self.theme.icons).to_lowercase();
4358 let input = crate::report_bug::ReportInput {
4359 ebman_version: env!("CARGO_PKG_VERSION"),
4360 os: std::env::consts::OS,
4361 os_release: std::env::consts::ARCH,
4362 icons: &icons,
4363 theme: self.theme.name,
4364 refresh_interval_secs: self.refresh_interval.as_secs(),
4365 recent_log_lines: crate::report_bug::tail_ebman_log(30),
4366 recent_messages,
4367 recent_crash: crate::report_bug::latest_crash_log(),
4368 env_count: self.environments.len(),
4369 app_count: self.applications.len(),
4370 multi_regions_count: self.multi_regions.len(),
4371 multi_account_enabled: !self.accounts.is_empty(),
4372 };
4373 let ctx = crate::report_bug::ScrubContext {
4374 account_id: self.context.account_id.clone(),
4375 profile: self.context.profile.clone(),
4376 region: Some(self.context.region.clone()),
4377 env_names,
4378 app_names,
4379 cnames,
4380 };
4381 let body = crate::report_bug::build_report(&input, &ctx);
4382 self.current_overlay = Some(Overlay::ReportBug { body });
4383 }
4384
4385 fn handle_report_bug_key(&mut self, key: KeyEvent) {
4390 let body = match self.current_overlay.as_ref() {
4391 Some(Overlay::ReportBug { body }) => body.clone(),
4392 _ => return,
4393 };
4394 match key.code {
4395 KeyCode::Esc | KeyCode::Char('q') => {
4396 self.current_overlay = None;
4397 }
4398 KeyCode::Char('y') | KeyCode::Char('Y') => {
4399 match yank(&body) {
4400 Ok(()) => {
4401 self.status_message = Some(format!(
4402 "bug report copied to clipboard ({} chars) — paste at https://github.com/tombaldwin/ebman/issues/new",
4403 body.chars().count()
4404 ));
4405 }
4406 Err(e) => {
4407 self.error_message = Some(format!("clipboard error: {e}"));
4408 }
4409 }
4410 self.current_overlay = None;
4411 }
4412 KeyCode::Char('b') | KeyCode::Char('B') => {
4413 let url = crate::report_bug::github_issue_url(
4414 "tombaldwin/ebman",
4415 "Bug report from ebman",
4416 &body,
4417 );
4418 match open_url(&url) {
4419 Ok(()) => {
4420 self.status_message = Some("opened GitHub issue draft in browser".into());
4421 }
4422 Err(e) => {
4423 self.error_message = Some(format!("couldn't open browser: {e}"));
4424 }
4425 }
4426 self.current_overlay = None;
4427 }
4428 _ => {}
4429 }
4430 }
4431
4432 fn command_completion_step(&mut self, delta: i32) {
4444 if self.completion.origin.is_none() {
4447 self.completion.origin = Some(self.command_input.clone());
4448 self.completion.index = 0;
4449 }
4450 let origin = self.completion.origin.clone().unwrap_or_default();
4451 let (prefix, rest): (String, String) = match origin.find(char::is_whitespace) {
4457 Some(i) => (origin[..i].to_string(), origin[i..].to_string()),
4458 None => (origin.clone(), String::new()),
4459 };
4460 let candidates = completion_candidates(&prefix);
4461 if candidates.is_empty() {
4462 self.command_input = origin;
4465 self.status_message = Some(format!(
4466 "no command matches '{prefix}' (Tab cycles command names)"
4467 ));
4468 return;
4469 }
4470 let n = candidates.len() as i32;
4471 let cur = self.completion.index as i32;
4472 let next = (cur + delta).rem_euclid(n) as usize;
4473 self.completion.index = next;
4474 self.command_input = format!("{}{rest}", candidates[next]);
4475 self.status_message = Some(format!(
4476 "completion {}/{} — Tab cycles, Esc cancels",
4477 next + 1,
4478 n
4479 ));
4480 }
4481
4482 pub(crate) fn cmd_secrets(&mut self, rest: &[&str]) {
4493 let filter = rest.first().map(|s| s.to_string());
4494 let aws = self.aws.clone();
4495 let tx = self.msg_tx.clone();
4496 let gen = self.generation;
4497 let title_filter = filter.clone();
4498 self.status_message = Some(match filter.as_deref() {
4499 Some(f) => format!("listing secrets matching '{f}'…"),
4500 None => "listing secrets…".into(),
4501 });
4502 tokio::spawn(async move {
4503 let result = aws
4504 .list_secrets(filter.as_deref())
4505 .await
4506 .map_err(|e| flatten_err("list_secrets", e));
4507 let body = match result {
4508 Ok(rows) => render_secrets_overlay(&rows, title_filter.as_deref()),
4509 Err(e) => format!("secrets: {e}\n\nesc / q to close"),
4510 };
4511 let _ = tx.send(AppMsg::TextOverlay {
4512 gen,
4513 title: "secrets".into(),
4514 body,
4515 });
4516 });
4517 }
4518
4519 pub(crate) fn cmd_secret_view(&mut self, rest: &[&str]) {
4529 let Some(name) = rest.first().map(|s| s.to_string()) else {
4530 self.error_message =
4531 Some("usage: :secret NAME (NAME or full ARN; see :secrets to list)".into());
4532 return;
4533 };
4534 let aws = self.aws.clone();
4535 let tx = self.msg_tx.clone();
4536 let gen = self.generation;
4537 let redact = self.redact;
4538 write_audit_line(
4539 self.context.account_id.as_deref(),
4540 self.context.profile.as_deref(),
4541 &self.context.region,
4542 &format!("stage=dispatched action=GetSecretValue target={name}"),
4543 );
4544 let account = self.context.account_id.clone();
4546 let profile = self.context.profile.clone();
4547 let region = self.context.region.clone();
4548 self.status_message = Some(format!("fetching secret '{name}'…"));
4549 tokio::spawn(async move {
4550 let result = aws
4551 .fetch_secret_value(&name)
4552 .await
4553 .map_err(|e| flatten_err("fetch_secret_value", e));
4554 let outcome = match &result {
4559 Ok(_) => {
4560 format!("stage=completed action=GetSecretValue target={name} outcome=ok")
4561 }
4562 Err(e) => format!(
4563 "stage=completed action=GetSecretValue target={name} outcome=err err=\"{}\"",
4564 crate::audit::escape_value(e)
4565 ),
4566 };
4567 write_audit_line(account.as_deref(), profile.as_deref(), ®ion, &outcome);
4568 let body = match result {
4569 Ok(value) => render_secret_value_overlay(&name, &value, redact),
4570 Err(e) => format!("secret: {e}\n\nesc / q to close"),
4571 };
4572 let _ = tx.send(AppMsg::TextOverlay {
4573 gen,
4574 title: format!("secret — {name}"),
4575 body,
4576 });
4577 });
4578 }
4579
4580 pub(crate) fn cmd_rollback(&mut self, rest: &[&str]) {
4587 let Some(env) = self.selected_env().cloned() else {
4588 self.error_message = Some("no env selected".into());
4589 return;
4590 };
4591 if self.deny_write(&env.name, "rollback") {
4592 return;
4593 }
4594 let auto_rollback_secs = parse_named_arg::<String>(rest, "--auto-rollback").and_then(|s| {
4600 let ms = crate::aws::parse_window_ms(&s)?;
4601 Some((ms / 1000) as u64)
4602 });
4603 if rest.contains(&"--auto-rollback") && auto_rollback_secs.is_none() {
4604 self.error_message =
4605 Some("--auto-rollback expects a duration like `5m` / `30m` / `1h`".into());
4606 return;
4607 }
4608
4609 if let Some(target) = parse_named_arg::<String>(rest, "--to") {
4615 if target.is_empty() {
4616 self.error_message = Some("--to expects a version label".into());
4617 return;
4618 }
4619 if target == env.version_label {
4620 self.error_message = Some(format!("{target} is already the deployed version"));
4621 return;
4622 }
4623 self.open_parameterised_action(
4624 Action::Deploy,
4625 ParameterisedAction {
4626 deploy_version: Some(target.clone()),
4627 auto_rollback_secs,
4628 ..Default::default()
4629 },
4630 );
4631 self.status_message = Some(format!("rollback target: {target} (operator-specified)"));
4632 return;
4633 }
4634
4635 let env_name = env.name.clone();
4636 let current_version = env.version_label.clone();
4637 if let Some(snapshot) = self.deploy_snapshots.get(&env_name).cloned() {
4644 if snapshot.previous_version_label != current_version {
4645 self.open_parameterised_action(
4646 Action::Deploy,
4647 ParameterisedAction {
4648 deploy_version: Some(snapshot.previous_version_label.clone()),
4649 auto_rollback_secs,
4650 ..Default::default()
4651 },
4652 );
4653 let age = (chrono::Utc::now() - snapshot.taken_at).num_seconds();
4654 self.status_message = Some(format!(
4655 "rollback target: {} (from snapshot taken {}s ago)",
4656 snapshot.previous_version_label, age
4657 ));
4658 return;
4659 }
4660 }
4661 if auto_rollback_secs.is_some() {
4670 self.error_message = Some(format!(
4671 "--auto-rollback needs an in-memory snapshot for {env_name} — none captured. \
4672 Try `:rollback --to LABEL --auto-rollback Nm` to name the target explicitly."
4673 ));
4674 return;
4675 }
4676 let aws = self.aws.clone();
4677 let tx = self.msg_tx.clone();
4678 let gen = self.generation;
4679 self.status_message = Some(format!("rollback: finding {env_name}'s previous version…"));
4680 tokio::spawn(async move {
4681 let result = aws
4682 .list_events_for_env(&env_name, 100)
4683 .await
4684 .map_err(|e| flatten_err("list_events_for_env", e));
4685 let _ = tx.send(AppMsg::RollbackTarget {
4686 gen,
4687 env_name,
4688 current_version,
4689 result,
4690 });
4691 });
4692 }
4693
4694 pub(crate) fn cmd_ssm_run(&mut self, rest: &[&str]) {
4709 if rest.is_empty() {
4714 self.error_message = Some(
4715 "usage: :ssm-run \"<shell-command>\" (fans the command out across the env's instances; quotes preserve whitespace)".into(),
4716 );
4717 return;
4718 }
4719 let command_str = rest.join(" ");
4720 let trimmed = command_str
4721 .trim_matches(|c: char| c == '"' || c == '\'')
4722 .to_string();
4723 if trimmed.is_empty() {
4724 self.error_message = Some("empty command — nothing to run".into());
4725 return;
4726 }
4727 let instances: Vec<String> = self
4728 .detail
4729 .as_ref()
4730 .map(|d| d.instances.iter().map(|i| i.id.clone()).collect())
4731 .unwrap_or_default();
4732 if instances.is_empty() {
4733 self.error_message = Some(
4734 "no cached instances — open the env's Detail/Instances tab first so :ssm-run knows what to target".into(),
4735 );
4736 return;
4737 }
4738 let env_name = self
4741 .detail
4742 .as_ref()
4743 .map(|d| d.env_name.clone())
4744 .unwrap_or_default();
4745 if self.deny_write(&env_name, "ssm-run") {
4746 return;
4747 }
4748 let audit_cmd = trimmed.replace('"', "'");
4755 write_audit_line(
4756 self.context.account_id.as_deref(),
4757 self.context.profile.as_deref(),
4758 &self.context.region,
4759 &format!(
4760 "stage=dispatched action=SsmRunCommand target={env_name} instances={n} cmd=\"{cmd}\"",
4761 n = instances.len(),
4762 cmd = audit_cmd,
4763 ),
4764 );
4765 let aws = self.aws.clone();
4766 let tx = self.msg_tx.clone();
4767 let gen = self.generation;
4768 let command_for_render = trimmed.clone();
4769 let n = instances.len();
4770 let audit_account = self.context.account_id.clone();
4773 let audit_profile = self.context.profile.clone();
4774 let audit_region = self.context.region.clone();
4775 let audit_env = env_name.clone();
4776 let audit_cmd_for_outcome = audit_cmd.clone();
4777 self.status_message = Some(format!("running `{trimmed}` on {n} instance(s)…"));
4778 tokio::spawn(async move {
4779 let result = aws
4780 .run_shell_command(&instances, &trimmed, 60)
4781 .await
4782 .map_err(|e| flatten_err("run_shell_command", e));
4783 let outcome = match &result {
4784 Ok(rows) => {
4785 let oks = rows.iter().filter(|r| r.status == "Success").count();
4786 format!(
4787 "stage=completed action=SsmRunCommand target={audit_env} outcome=ok ok_count={oks}/{n} cmd=\"{audit_cmd_for_outcome}\""
4788 )
4789 }
4790 Err(e) => format!(
4791 "stage=completed action=SsmRunCommand target={audit_env} outcome=err err=\"{}\" cmd=\"{audit_cmd_for_outcome}\"",
4792 crate::audit::escape_value(e)
4793 ),
4794 };
4795 write_audit_line(
4796 audit_account.as_deref(),
4797 audit_profile.as_deref(),
4798 &audit_region,
4799 &outcome,
4800 );
4801 let body = match result {
4802 Ok(rows) => format_ssm_results(&command_for_render, &rows),
4803 Err(e) => format!("ssm-run: {e}\n\nesc / q to close"),
4804 };
4805 let _ = tx.send(AppMsg::TextOverlay {
4806 gen,
4807 title: "ssm-run".into(),
4808 body,
4809 });
4810 });
4811 }
4812
4813 pub(crate) fn cmd_ssh(&mut self, rest: &[&str]) {
4825 match rest.first().copied() {
4826 Some(id) => {
4827 if !id.starts_with("i-") {
4828 self.error_message =
4829 Some(format!("expected an EC2 instance ID (`i-…`), got '{id}'"));
4830 return;
4831 }
4832 write_audit_line(
4838 self.context.account_id.as_deref(),
4839 self.context.profile.as_deref(),
4840 &self.context.region,
4841 &format!("stage=dispatched action=SsmSession target={id} via=cmd_ssh"),
4842 );
4843 self.pending_shell_target = Some(id.to_string());
4844 self.status_message = Some(format!("opening SSM session to {id}…"));
4845 }
4846 None => {
4847 let instances: Vec<String> = self
4853 .detail
4854 .as_ref()
4855 .map(|d| d.instances.iter().map(|i| i.id.clone()).collect())
4856 .unwrap_or_default();
4857 if instances.is_empty() {
4858 self.error_message = Some(
4859 "no cached instances — open the env's Detail/Instances tab first, or pass an ID (`:ssh i-abc`)".into(),
4860 );
4861 return;
4862 }
4863 self.picker = Some(Picker::new(PickerKind::SshInstance, instances, None));
4864 self.mode = Mode::Picker;
4865 }
4866 }
4867 }
4868
4869 pub(crate) fn cmd_lineage(&mut self) {
4878 let env_opt = if let Some(d) = self.detail.as_ref() {
4879 Some(d.env_name.clone())
4880 } else {
4881 self.selected_env().map(|e| e.name.clone())
4882 };
4883 let Some(env_name) = env_opt else {
4884 self.error_message = Some("no env selected".into());
4885 return;
4886 };
4887 let aws = self.aws.clone();
4888 let tx = self.msg_tx.clone();
4889 let gen = self.generation;
4890 self.status_message = Some(format!("fetching deploy lineage for {env_name}…"));
4891 tokio::spawn(async move {
4892 let result = aws
4893 .list_events_for_env(&env_name, 100)
4894 .await
4895 .map_err(|e| flatten_err("list_events_for_env", e));
4896 let body = match result {
4897 Ok(events) => format_lineage(&env_name, &events),
4898 Err(e) => format!("lineage: {e}\n\nesc / q to close"),
4899 };
4900 let _ = tx.send(AppMsg::TextOverlay {
4901 gen,
4902 title: format!("lineage — {env_name}"),
4903 body,
4904 });
4905 });
4906 }
4907
4908 pub(crate) fn cmd_changes(&mut self) {
4909 let env_opt = if let Some(d) = self.detail.as_ref() {
4910 Some(d.env_name.clone())
4911 } else {
4912 self.selected_env().map(|e| e.name.clone())
4913 };
4914 let Some(env_name) = env_opt else {
4915 self.error_message = Some("no env selected".into());
4916 return;
4917 };
4918 let aws = self.aws.clone();
4919 let tx = self.msg_tx.clone();
4920 let gen = self.generation;
4921 self.status_message = Some(format!("fetching change history for {env_name}…"));
4922 tokio::spawn(async move {
4923 let result = aws
4924 .list_events_for_env(&env_name, 100)
4925 .await
4926 .map_err(|e| flatten_err("list_events_for_env", e));
4927 let body = match result {
4928 Ok(events) => render_changes_overlay(&env_name, &events),
4929 Err(e) => format!("changes: {e}\n\nesc / q to close"),
4930 };
4931 let _ = tx.send(AppMsg::TextOverlay {
4932 gen,
4933 title: format!("changes — {env_name}"),
4934 body,
4935 });
4936 });
4937 }
4938
4939 pub(crate) fn cmd_event_time(&mut self, rest: &[&str]) {
4945 let next = match rest.first().copied() {
4946 None => self.event_panel.time_format.next(),
4947 Some(arg) => match EventTimeFormat::parse(arg) {
4948 Some(f) => f,
4949 None => {
4950 self.error_message = Some(format!(
4951 "unknown event-time format '{arg}' (use: utc | local | age)"
4952 ));
4953 return;
4954 }
4955 },
4956 };
4957 self.event_panel.time_format = next;
4958 self.persist_state();
4959 self.status_message = Some(match next {
4960 EventTimeFormat::Utc => "event timestamps: UTC (YYYY-MM-DD HH:MM:SSZ)".into(),
4961 EventTimeFormat::Local => "event timestamps: local time".into(),
4962 EventTimeFormat::Age => "event timestamps: relative age".into(),
4963 });
4964 }
4965
4966 pub(crate) fn cmd_env_edit(&mut self) {
4979 let Some(env) = self.selected_env().cloned() else {
4980 self.error_message = Some("no env selected".into());
4981 return;
4982 };
4983 if self.deny_write(&env.name, ":env-edit") {
4984 return;
4985 }
4986 if self.pending_env_edit.is_some() {
4987 self.error_message =
4988 Some("another :env-edit is mid-flight — wait for the editor to close".into());
4989 return;
4990 }
4991 let aws = self.aws.clone();
4992 let tx = self.msg_tx.clone();
4993 let gen = self.generation;
4994 let app_name = env.application.clone();
4995 let env_name = env.name.clone();
4996 let env_name_for_msg = env_name.clone();
4997 self.status_message = Some(format!("fetching env vars for {env_name}…"));
4998 tokio::spawn(async move {
4999 let result = aws
5000 .fetch_env_vars(&app_name, &env_name)
5001 .await
5002 .map_err(|e| flatten_err("fetch_env_vars", e));
5003 let _ = tx.send(AppMsg::EnvVarsForEdit {
5004 gen,
5005 env_name: env_name_for_msg,
5006 result,
5007 });
5008 });
5009 }
5010
5011 pub(crate) fn cmd_explain(&mut self, rest: &[&str]) {
5030 if let Some(first) = rest.first().copied() {
5035 if first.starts_with("EBL") {
5036 self.cmd_explain_issue(first);
5037 return;
5038 }
5039 }
5040 let (principal, actions): (String, Vec<String>) = match rest.first().copied() {
5041 Some(arn) if arn.starts_with("arn:aws:") && rest.len() >= 2 => {
5043 let actions: Vec<String> = rest[1..].iter().map(|s| s.to_string()).collect();
5044 (arn.to_string(), actions)
5045 }
5046 Some(_) => {
5047 self.error_message = Some(
5048 "usage: :explain (IAM AccessDenied) | :explain ARN ACTION [...] | :explain EBL###"
5049 .into(),
5050 );
5051 return;
5052 }
5053 None => {
5054 let latest = self.message_log.iter().rev().find(|(_, kind, text)| {
5059 matches!(kind, MsgKind::Error) && text.contains("is not authorized to perform")
5060 });
5061 let Some((_, _, text)) = latest else {
5062 self.error_message = Some(
5063 "no recent AccessDenied to explain — :explain ARN ACTION to evaluate explicitly".into(),
5064 );
5065 return;
5066 };
5067 match parse_access_denied(text) {
5068 Some((arn, action)) => (arn, vec![action]),
5069 None => {
5070 self.error_message = Some(format!(
5071 "couldn't parse principal + action from last error: {text}"
5072 ));
5073 return;
5074 }
5075 }
5076 }
5077 };
5078 let aws = self.aws.clone();
5079 let tx = self.msg_tx.clone();
5080 let gen = self.generation;
5081 let principal_for_title = principal.clone();
5082 self.status_message = Some(format!(
5083 "diagnosing IAM perms for {} action(s) on {principal}…",
5084 actions.len()
5085 ));
5086 tokio::spawn(async move {
5087 let result = aws
5088 .simulate_principal_policy(&principal, &actions, &[])
5089 .await
5090 .map_err(|e| flatten_err("simulate_principal_policy", e));
5091 let body = match result {
5092 Ok(rows) => render_explain_overlay(&principal, &rows),
5093 Err(e) => format!(
5094 "explain: {e}\n\n\
5095 This usually means the caller lacks `iam:SimulatePrincipalPolicy`\n\
5096 on the target role — common with assumed-role sessions that don't\n\
5097 have IAM perms. Try from a profile with IAM access.\n\n\
5098 esc / q to close"
5099 ),
5100 };
5101 let _ = tx.send(AppMsg::TextOverlay {
5102 gen,
5103 title: format!("explain — {principal_for_title}"),
5104 body,
5105 });
5106 });
5107 }
5108
5109 fn cmd_explain_issue(&mut self, issue_id: &str) {
5120 let Some(env) = self.selected_env().cloned() else {
5121 self.error_message = Some("no env selected".into());
5122 return;
5123 };
5124 let aws = self.aws.clone();
5125 let tx = self.msg_tx.clone();
5126 let gen = self.generation;
5127 let mut disabled = self.lint_disable.clone();
5128 disabled.extend(crate::project::load_lint_disables_from_cwd());
5129 let app_name = env.application.clone();
5130 let env_name_for_fetch = env.name.clone();
5131 let settings = crate::llm::Settings {
5136 enabled: self.explain_enabled,
5137 provider: if self.explain_provider.is_empty() {
5138 "anthropic".into()
5139 } else {
5140 self.explain_provider.clone()
5141 },
5142 model: if self.explain_model.is_empty() {
5143 "claude-haiku-4-5".into()
5144 } else {
5145 self.explain_model.clone()
5146 },
5147 api_key_env: if self.explain_api_key_env.is_empty() {
5148 "ANTHROPIC_API_KEY".into()
5149 } else {
5150 self.explain_api_key_env.clone()
5151 },
5152 ollama_url: if self.explain_ollama_url.is_empty() {
5153 "http://localhost:11434".into()
5154 } else {
5155 self.explain_ollama_url.clone()
5156 },
5157 max_tokens: if self.explain_max_tokens == 0 {
5158 1024
5159 } else {
5160 self.explain_max_tokens
5161 },
5162 };
5163 let issue_id_owned = issue_id.to_string();
5164 let issue_id_title = issue_id.to_string();
5165 self.status_message = Some(format!("explain: building prompt for {issue_id}…"));
5166 tokio::spawn(async move {
5167 let body = match aws
5168 .fetch_env_option_settings(&app_name, &env_name_for_fetch)
5169 .await
5170 {
5171 Ok(opts) => {
5172 let ctx = crate::lint::LintContext {
5173 env: &env,
5174 options: &opts,
5175 events: &[],
5176 cost_usd_per_month: None,
5177 latest_stack_version: None,
5178 };
5179 let rules = crate::lint::default_rules(&disabled);
5180 let issues = crate::lint::run_rules(&rules, &ctx);
5181 match issues.iter().find(|i| i.rule_id == issue_id_owned) {
5182 None => format!(
5183 "explain: rule {issue_id_owned} doesn't fire on env {} — nothing to explain.\n\
5184 Run :lint to see which issues do fire here.\n\nesc / q to close",
5185 env.name
5186 ),
5187 Some(issue) => {
5188 let prompt = crate::llm::build_prompt(issue);
5189 match crate::llm::read_cache(issue) {
5193 Some(cached) => cached,
5194 None => match crate::llm::dispatch(&settings, &prompt).await {
5195 Ok(r) => {
5196 crate::llm::write_cache(issue, &r);
5197 r
5198 }
5199 Err(e) => format!(
5200 "explain: {e}\n\n\
5201 Configure [explain] in {} or run from CLI with `ebman explain {issue_id_owned} --env {}`.\n\n\
5202 esc / q to close",
5203 crate::util::config_file("config.toml").display(),
5204 env.name,
5205 ),
5206 },
5207 }
5208 }
5209 }
5210 }
5211 Err(e) => format!("explain: fetch_env_option_settings: {e}\n\nesc / q to close"),
5212 };
5213 let _ = tx.send(AppMsg::TextOverlay {
5214 gen,
5215 title: format!("explain — {issue_id_title}"),
5216 body,
5217 });
5218 });
5219 }
5220
5221 pub(crate) fn cmd_options(&mut self, rest: &[&str]) {
5232 let Some(env) = self.selected_env().cloned() else {
5233 self.error_message = Some("no env selected".into());
5234 return;
5235 };
5236 let filter_ns = rest.first().map(|s| s.to_string());
5237 let aws = self.aws.clone();
5238 let tx = self.msg_tx.clone();
5239 let gen = self.generation;
5240 let app_name = env.application.clone();
5241 let env_name = env.name.clone();
5242 self.status_message = Some(format!(
5243 "fetching config vocabulary for {env_name}… (this can take a few seconds)"
5244 ));
5245 tokio::spawn(async move {
5246 let result = aws
5247 .fetch_env_configuration_options(&app_name, &env_name)
5248 .await
5249 .map_err(|e| flatten_err("fetch_env_configuration_options", e));
5250 let body = match result {
5251 Ok(rows) => render_options_overlay(&rows, filter_ns.as_deref(), &env_name),
5252 Err(e) => format!("options: {e}\n\nesc / q to close"),
5253 };
5254 let _ = tx.send(AppMsg::TextOverlay {
5255 gen,
5256 title: format!("options — {env_name}"),
5257 body,
5258 });
5259 });
5260 }
5261
5262 pub(crate) fn cmd_config_diff_local(&mut self, rest: &[&str]) {
5271 let Some(env) = self.selected_env().cloned() else {
5272 self.error_message = Some("no env selected".into());
5273 return;
5274 };
5275 let cwd = match std::env::current_dir() {
5276 Ok(p) => p,
5277 Err(e) => {
5278 self.error_message = Some(format!("can't read cwd: {e}"));
5279 return;
5280 }
5281 };
5282 let path = match rest.first().copied() {
5283 Some(name) => match crate::saved_config::resolve_saved_config(&cwd, name) {
5284 Ok(p) => p,
5285 Err(e) => {
5286 self.error_message = Some(format!("config-diff-local: {e}"));
5287 return;
5288 }
5289 },
5290 None => {
5291 let configs = match crate::saved_config::discover_saved_configs(&cwd) {
5292 Ok(c) => c,
5293 Err(e) => {
5294 self.error_message = Some(format!("config-diff-local: {e}"));
5295 return;
5296 }
5297 };
5298 match configs.len() {
5299 0 => {
5300 self.error_message = Some(format!(
5301 "no .elasticbeanstalk/saved_configs/*.cfg.yml under {}",
5302 cwd.display()
5303 ));
5304 return;
5305 }
5306 1 => configs.into_iter().next().unwrap(),
5307 _ => {
5308 let names: Vec<String> = configs
5309 .iter()
5310 .map(|p| crate::saved_config::saved_config_name(p))
5311 .collect();
5312 self.error_message = Some(format!(
5313 "multiple saved configs — pick one: :config-diff-local <{}>",
5314 names.join(" | ")
5315 ));
5316 return;
5317 }
5318 }
5319 }
5320 };
5321 let yaml = match std::fs::read_to_string(&path) {
5322 Ok(s) => s,
5323 Err(e) => {
5324 self.error_message = Some(format!("reading {}: {e}", path.display()));
5325 return;
5326 }
5327 };
5328 let local_opts = match crate::saved_config::parse_saved_config(&yaml) {
5329 Ok(o) => o,
5330 Err(e) => {
5331 self.error_message = Some(format!("parsing {}: {e}", path.display()));
5332 return;
5333 }
5334 };
5335 let local_name = crate::saved_config::saved_config_name(&path);
5336 let aws = self.aws.clone();
5337 let tx = self.msg_tx.clone();
5338 let gen = self.generation;
5339 let (app_name, env_name) = (env.application.clone(), env.name.clone());
5340 let env_name_for_title = env_name.clone();
5341 let local_name_for_title = local_name.clone();
5342 self.status_message = Some(format!(
5343 "comparing {env_name} ↔ saved config '{local_name}'…"
5344 ));
5345 tokio::spawn(async move {
5346 let result = aws
5347 .fetch_env_configuration_options(&app_name, &env_name)
5348 .await
5349 .map_err(|e| flatten_err("fetch_env_configuration_options", e));
5350 let body = match result {
5351 Ok(deployed) => {
5352 let diffs = diff_config_options(&local_opts, &deployed);
5353 let left_label = format!("local:{local_name}");
5354 render_config_diff_overlay(&left_label, &env_name, &diffs)
5355 }
5356 Err(e) => format!("config-diff-local: {e}\n\nesc / q to close"),
5357 };
5358 let _ = tx.send(AppMsg::TextOverlay {
5359 gen,
5360 title: format!("config-diff-local — {env_name_for_title} ↔ {local_name_for_title}"),
5361 body,
5362 });
5363 });
5364 }
5365
5366 pub(crate) fn cmd_config_diff(&mut self, rest: &[&str]) {
5371 let Some(target) = rest.first().map(|s| s.to_string()) else {
5372 self.error_message = Some(
5373 "usage: :config-diff ENV (compare the selected env's option-settings against ENV)"
5374 .into(),
5375 );
5376 return;
5377 };
5378 let left = if let Some(d) = self.detail.as_ref() {
5379 Some(d.env_snapshot.clone())
5380 } else {
5381 self.selected_env().cloned()
5382 };
5383 let Some(left) = left else {
5384 self.error_message = Some("no env selected".into());
5385 return;
5386 };
5387 let Some(right) = self.environments.iter().find(|e| e.name == target).cloned() else {
5388 self.error_message = Some(format!("no env named '{target}' in the current view"));
5389 return;
5390 };
5391 if left.name == right.name {
5392 self.error_message = Some("pick a different env to compare against".into());
5393 return;
5394 }
5395 let aws = self.aws.clone();
5396 let tx = self.msg_tx.clone();
5397 let gen = self.generation;
5398 let (la, ln) = (left.application.clone(), left.name.clone());
5399 let (ra, rn) = (right.application.clone(), right.name.clone());
5400 self.status_message = Some(format!("comparing config: {ln} ↔ {rn}…"));
5401 tokio::spawn(async move {
5402 let body = match tokio::try_join!(
5403 aws.fetch_env_configuration_options(&la, &ln),
5404 aws.fetch_env_configuration_options(&ra, &rn),
5405 ) {
5406 Ok((lopts, ropts)) => {
5407 let diffs = diff_config_options(&lopts, &ropts);
5408 render_config_diff_overlay(&ln, &rn, &diffs)
5409 }
5410 Err(e) => format!(
5411 "config-diff: {}\n\nesc / q to close",
5412 flatten_err("fetch_env_configuration_options", e)
5413 ),
5414 };
5415 let _ = tx.send(AppMsg::TextOverlay {
5416 gen,
5417 title: format!("config diff — {ln} ↔ {rn}"),
5418 body,
5419 });
5420 });
5421 }
5422
5423 pub(crate) fn cmd_rds(&mut self) {
5434 let Some(env) = self.selected_env().cloned() else {
5435 self.error_message = Some("no env selected".into());
5436 return;
5437 };
5438 let aws = self.aws.clone();
5439 let tx = self.msg_tx.clone();
5440 let gen = self.generation;
5441 let app_name = env.application.clone();
5442 let env_name = env.name.clone();
5443 self.status_message = Some(format!("fetching RDS config for {env_name}…"));
5444 tokio::spawn(async move {
5445 let result = aws
5446 .fetch_env_rds_config(&app_name, &env_name)
5447 .await
5448 .map_err(|e| flatten_err("fetch_env_rds_config", e));
5449 let body = match result {
5450 Ok(rows) if rows.is_empty() => "No RDS instance attached to this env.\n\n\
5451 EB-managed RDS is configured via `aws:rds:dbinstance.*`\n\
5452 option settings. To attach a new one:\n\n \
5453 :set-option aws:rds:dbinstance DBEngine postgres\n \
5454 :set-option aws:rds:dbinstance DBInstanceClass db.t3.micro\n \
5455 :set-option aws:rds:dbinstance DBPassword <secret>\n\n\
5456 (See the EB docs — there are 10+ required fields. A\n\
5457 dedicated `:rds-attach` form is a planned follow-up.)\n\n\
5458 esc / q to close"
5459 .to_string(),
5460 Ok(rows) => {
5461 let mut body = String::from("RDS dbinstance configuration:\n\n");
5462 for (opt, value) in &rows {
5463 let safe_value = if opt.eq_ignore_ascii_case("DBPassword") {
5468 "(redacted)".to_string()
5469 } else {
5470 value.clone()
5471 };
5472 body.push_str(&format!(" {opt:<28} {safe_value}\n"));
5473 }
5474 body.push_str(
5475 "\nUse `:set-option aws:rds:dbinstance <KEY> <VALUE>` to change a setting.\n\
5476 Note: most RDS option changes trigger instance modification (downtime risk).\n\
5477 esc / q to close",
5478 );
5479 body
5480 }
5481 Err(e) => format!("rds: {e}\n\nesc / q to close"),
5482 };
5483 let _ = tx.send(AppMsg::TextOverlay {
5484 gen,
5485 title: format!("rds — {env_name}"),
5486 body,
5487 });
5488 });
5489 }
5490
5491 pub(crate) fn cmd_listeners(&mut self) {
5499 let Some(env) = self.selected_env().cloned() else {
5500 self.error_message = Some("no env selected".into());
5501 return;
5502 };
5503 if env.tier.eq_ignore_ascii_case("Worker") {
5504 self.error_message = Some(format!(
5505 "env '{}' is Worker tier — no ALB to configure",
5506 env.name
5507 ));
5508 return;
5509 }
5510 let aws = self.aws.clone();
5511 let tx = self.msg_tx.clone();
5512 let gen = self.generation;
5513 let app_name = env.application.clone();
5514 let env_name = env.name.clone();
5515 self.status_message = Some(format!("fetching listeners for {env_name}…"));
5516 tokio::spawn(async move {
5517 let result = aws
5518 .fetch_env_listeners(&app_name, &env_name)
5519 .await
5520 .map_err(|e| flatten_err("fetch_env_listeners", e));
5521 let body = match result {
5522 Ok(rows) if rows.is_empty() => "No listener config found.\n\n\
5523 The env may use a Classic ELB instead of an ALB, or no\n\
5524 listener overrides have been set (EB uses account defaults).\n\
5525 `:set-option aws:elbv2:listener:443 SSLCertificateArns ARN`\n\
5526 to configure a listener from scratch.\n\nesc / q to close"
5527 .to_string(),
5528 Ok(rows) => {
5529 let mut body = String::from("Listener configuration:\n");
5530 body.push_str("(one block per port; `default` = HTTP/80)\n\n");
5531 let mut current_port: Option<String> = None;
5532 for (port, opt, value) in &rows {
5533 if current_port.as_deref() != Some(port.as_str()) {
5534 if current_port.is_some() {
5535 body.push('\n');
5536 }
5537 body.push_str(&format!("── aws:elbv2:listener:{port} ──\n"));
5538 current_port = Some(port.clone());
5539 }
5540 body.push_str(&format!(" {opt:<32} {value}\n"));
5541 }
5542 body.push_str(
5543 "\n`:set-option aws:elbv2:listener:<PORT> <KEY> <VALUE>` to change a setting.\n\
5544 esc / q to close",
5545 );
5546 body
5547 }
5548 Err(e) => format!("listeners: {e}\n\nesc / q to close"),
5549 };
5550 let _ = tx.send(AppMsg::TextOverlay {
5551 gen,
5552 title: format!("listeners — {env_name}"),
5553 body,
5554 });
5555 });
5556 }
5557
5558 pub(crate) fn cmd_listener_edit(&mut self, rest: &[&str]) {
5565 use crate::form::{Form, FormField, FormSubmit};
5566 let Some(env) = self.selected_env().cloned() else {
5567 self.error_message = Some("no env selected".into());
5568 return;
5569 };
5570 if env.tier.eq_ignore_ascii_case("Worker") {
5571 self.error_message = Some(format!(
5572 "env '{}' is Worker tier — no ALB to configure",
5573 env.name
5574 ));
5575 return;
5576 }
5577 let Some(port) = rest.first().copied() else {
5578 self.error_message = Some(
5579 "usage: :listener-edit PORT (e.g. :listener-edit 443; `default` = HTTP/80)".into(),
5580 );
5581 return;
5582 };
5583 let port = port.to_string();
5584 let ns = format!("aws:elbv2:listener:{port}");
5585 let placeholder = FormField::multi_select(
5586 "cert",
5587 "SSL certificate(s)",
5588 Vec::new(),
5589 Vec::new(),
5590 Some::<String>("space toggle · ↑↓ option cursor · loaded from ACM".into()),
5591 );
5592 let form = Form::loading(
5593 format!("listener {port} — {}", env.name),
5594 env.name.clone(),
5595 format!("listener {port} cert update"),
5596 vec![placeholder],
5597 FormSubmit::OptionSettings {
5598 mappings: vec![("cert".into(), ns, "SSLCertificateArns".into())],
5599 },
5600 );
5601 self.form = Some(form);
5605 self.mode = Mode::Form;
5606 let aws = self.aws.clone();
5607 let tx = self.msg_tx.clone();
5608 let gen = self.generation;
5609 let env_for_msg = env.name.clone();
5610 let app_name = env.application.clone();
5611 tokio::spawn(async move {
5612 let result = load_listener_certs(aws, &app_name, &env_for_msg, &port).await;
5613 let _ = tx.send(AppMsg::FormMultiSelectLoaded {
5614 gen,
5615 env_name: env_for_msg,
5616 field_key: "cert".to_string(),
5617 result,
5618 });
5619 });
5620 }
5621
5622 pub(crate) fn open_apps_info_overlay(&mut self) {
5629 let app_name_opt = match self.scope {
5630 Scope::Apps => self
5631 .app_table_state
5632 .selected()
5633 .and_then(|i| self.applications.get(i).map(|a| a.name.clone())),
5634 Scope::Envs => self.selected_env().map(|e| e.application.clone()),
5635 };
5636 let Some(app_name) = app_name_opt else {
5637 self.error_message = Some("no application selected".into());
5638 return;
5639 };
5640 let Some(app) = self.applications.iter().find(|a| a.name == app_name) else {
5641 self.error_message = Some(format!(
5642 "application '{app_name}' not in cache yet — refresh and retry"
5643 ));
5644 return;
5645 };
5646 let rollup = app_rollup(&self.environments, &app.name, &self.worker_dlq_depths);
5649 let env_names: Vec<&str> = self
5650 .environments
5651 .iter()
5652 .filter(|e| e.application == app.name)
5653 .map(|e| e.name.as_str())
5654 .collect();
5655 let date_fmt = |dt: Option<chrono::DateTime<chrono::Utc>>| -> String {
5656 dt.map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
5657 .unwrap_or_else(|| "—".into())
5658 };
5659 let templates_block = if app.templates.is_empty() {
5660 " (none)".to_string()
5661 } else {
5662 app.templates
5663 .iter()
5664 .map(|t| format!(" ▸ {t}"))
5665 .collect::<Vec<_>>()
5666 .join("\n")
5667 };
5668 let envs_block = if env_names.is_empty() {
5669 " (none)".to_string()
5670 } else {
5671 env_names
5672 .iter()
5673 .map(|n| format!(" ▸ {n}"))
5674 .collect::<Vec<_>>()
5675 .join("\n")
5676 };
5677 let description = if app.description.is_empty() {
5678 "(no description)".to_string()
5679 } else {
5680 app.description.clone()
5681 };
5682 let latest_line = match (
5683 app.latest_version_label.as_deref(),
5684 app.latest_version_created,
5685 ) {
5686 (Some(label), Some(created)) => format!("{label} ({})", date_fmt(Some(created))),
5687 (Some(label), None) => label.to_string(),
5688 _ => "—".into(),
5689 };
5690 let body = format!(
5691 "Application: {}\n\
5692 Description: {description}\n\n\
5693 Created: {created}\n\
5694 Updated: {updated}\n\n\
5695 Versions: {version_count} registered · latest: {latest_line}\n\
5696 Envs: {env_count} total · {red_count} alerting · {updating_count} updating\n\n\
5697 Environments:\n{envs_block}\n\n\
5698 Saved configuration templates:\n{templates_block}\n\n\
5699 esc / q to close",
5700 app.name,
5701 created = date_fmt(app.date_created),
5702 updated = date_fmt(app.date_updated),
5703 version_count = app.version_count,
5704 env_count = rollup.env_count,
5705 red_count = rollup.red_count + rollup.worker_dlq_alerts,
5706 updating_count = rollup.updating_count,
5707 );
5708 self.current_overlay = Some(Overlay::TextDump {
5709 title: format!("info — {}", app.name),
5710 body,
5711 });
5712 }
5713
5714 fn open_about_overlay(&mut self) {
5715 self.current_overlay = Some(Overlay::About(Instant::now()));
5718 }
5719
5720 fn toggle_pin_selected(&mut self) {
5721 let name_opt = if let Some(d) = self.detail.as_ref() {
5722 Some(d.env_name.clone())
5723 } else {
5724 self.selected_env().map(|e| e.name.clone())
5725 };
5726 let Some(name) = name_opt else {
5727 self.status_message = Some("no env selected".into());
5728 return;
5729 };
5730 if self.pinned.remove(&name) {
5731 self.status_message = Some(format!("unpinned {name}"));
5732 } else {
5733 self.pinned.insert(name.clone());
5734 self.status_message = Some(format!("pinned {name}"));
5735 }
5736 self.resort_envs();
5737 self.persist_state();
5738 }
5739
5740 fn toggle_pin_selected_app(&mut self) {
5746 let Some(idx) = self.app_table_state.selected() else {
5747 self.status_message = Some("no app selected".into());
5748 return;
5749 };
5750 let Some(name) = self.applications.get(idx).map(|a| a.name.clone()) else {
5751 return;
5752 };
5753 if self.pinned_apps.remove(&name) {
5754 self.status_message = Some(format!("unpinned app {name}"));
5755 } else {
5756 self.pinned_apps.insert(name.clone());
5757 self.status_message = Some(format!("pinned app {name}"));
5758 }
5759 self.resort_applications();
5760 self.persist_state();
5761 }
5762
5763 fn resort_applications(&mut self) {
5767 let pinned = self.pinned_apps.clone();
5768 self.applications.sort_by(|a, b| {
5769 let a_pin = pinned.contains(&a.name);
5770 let b_pin = pinned.contains(&b.name);
5771 if a_pin != b_pin {
5772 return if a_pin {
5773 std::cmp::Ordering::Less
5774 } else {
5775 std::cmp::Ordering::Greater
5776 };
5777 }
5778 a.name.cmp(&b.name)
5779 });
5780 }
5781
5782 fn yank_cli(&mut self) {
5783 let env_opt = if let Some(d) = self.detail.as_ref() {
5784 Some(d.env_snapshot.clone())
5785 } else {
5786 self.selected_env().cloned()
5787 };
5788 let Some(env) = env_opt else {
5789 self.status_message = Some("no env selected".into());
5790 return;
5791 };
5792 let cmd = build_describe_cli(
5793 &env.name,
5794 &self.context.region,
5795 self.override_profile
5796 .as_deref()
5797 .or(self.context.profile.as_deref()),
5798 );
5799 match yank(&cmd) {
5800 Ok(()) => {
5801 self.status_message = Some("equivalent AWS CLI command copied".into());
5802 }
5803 Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
5804 }
5805 }
5806
5807 fn export_json(&mut self) {
5808 let count = self.cached_filtered.len();
5809 let mut out = String::from("[\n");
5810 for (idx, &i) in self.cached_filtered.iter().enumerate() {
5811 let e = &self.environments[i];
5812 let cname = if self.redact {
5813 redact_block(&e.cname)
5814 } else {
5815 e.cname.clone()
5816 };
5817 let updated = e
5818 .updated
5819 .map(|u| format!("\"{}\"", u.to_rfc3339()))
5820 .unwrap_or_else(|| "null".into());
5821 out.push_str(&format!(
5822 " {{\"name\":\"{}\",\"application\":\"{}\",\"tier\":\"{}\",\"status\":\"{}\",\"health\":\"{}\",\"platform\":\"{}\",\"version\":\"{}\",\"cname\":\"{}\",\"updated\":{}}}",
5823 json_escape(&e.name),
5824 json_escape(&e.application),
5825 json_escape(&e.tier),
5826 json_escape(&e.status),
5827 json_escape(&e.health),
5828 json_escape(&e.platform),
5829 json_escape(&e.version_label),
5830 json_escape(&cname),
5831 updated,
5832 ));
5833 if idx + 1 < count {
5834 out.push(',');
5835 }
5836 out.push('\n');
5837 }
5838 out.push(']');
5839 match yank(&out) {
5840 Ok(()) => {
5841 self.status_message = Some(format!("exported {count} rows (JSON) to clipboard"));
5842 }
5843 Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
5844 }
5845 }
5846
5847 fn export_markdown(&mut self) {
5848 let count = self.cached_filtered.len();
5849 let mut out = String::new();
5850 out.push_str("| NAME | APPLICATION | TIER | STATUS | HEALTH | PLATFORM | VERSION | CNAME | UPDATED |\n");
5851 out.push_str("| ---- | ----------- | ---- | ------ | ------ | -------- | ------- | ----- | ------- |\n");
5852 for &i in &self.cached_filtered {
5853 let e = &self.environments[i];
5854 let cname = if self.redact {
5855 redact_block(&e.cname)
5856 } else {
5857 e.cname.clone()
5858 };
5859 let updated = e.updated.map(|u| u.to_rfc3339()).unwrap_or_default();
5860 out.push_str(&format!(
5861 "| {} | {} | {} | {} | {} | {} | {} | {} | {} |\n",
5862 md_escape(&e.name),
5863 md_escape(&e.application),
5864 e.tier,
5865 e.status,
5866 e.health,
5867 md_escape(&e.platform),
5868 md_escape(&e.version_label),
5869 md_escape(&cname),
5870 updated,
5871 ));
5872 }
5873 match yank(&out) {
5874 Ok(()) => {
5875 self.status_message =
5876 Some(format!("exported {count} rows (Markdown) to clipboard"));
5877 }
5878 Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
5879 }
5880 }
5881
5882 fn open_describe_overlay(&mut self) {
5883 let env = if let Some(d) = self.detail.as_ref() {
5884 Some(d.env_snapshot.clone())
5885 } else {
5886 self.selected_env().cloned()
5887 };
5888 let Some(env) = env else {
5889 self.status_message = Some("no env selected".into());
5890 return;
5891 };
5892 self.current_overlay = Some(Overlay::Describe(describe_env(&env)));
5893 }
5894
5895 fn open_in_console(&mut self) {
5896 let env_opt = if let Some(d) = self.detail.as_ref() {
5897 Some(d.env_snapshot.clone())
5898 } else {
5899 self.selected_env().cloned()
5900 };
5901 let Some(env) = env_opt else {
5902 self.status_message = Some("no env selected".into());
5903 return;
5904 };
5905 let url = console_url(&self.context.region, &env.application, &env.name);
5906 match open_url(&url) {
5907 Ok(()) => {
5908 self.status_message = Some(format!("opened {} in browser", env.name));
5909 }
5910 Err(e) => {
5911 self.error_message = Some(format!("couldn't open browser: {e}"));
5912 }
5913 }
5914 }
5915
5916 fn open_palette(&mut self) {
5917 self.palette_input.clear();
5918 self.palette_items = build_palette_items(self);
5919 self.palette_refilter();
5920 self.mode = Mode::Palette;
5921 }
5922
5923 fn palette_refilter(&mut self) {
5924 let needle = self.palette_input.to_lowercase();
5925 let mut scored: Vec<(usize, isize)> = self
5926 .palette_items
5927 .iter()
5928 .enumerate()
5929 .filter_map(|(i, it)| {
5930 let s = palette_score(&needle, &it.label, &it.detail)?;
5931 Some((i, s))
5932 })
5933 .collect();
5934 scored.sort_by(|a, b| a.1.cmp(&b.1).then(a.0.cmp(&b.0)));
5935 self.palette_filtered = scored.into_iter().map(|(i, _)| i).collect();
5936 self.palette_state
5937 .select(if self.palette_filtered.is_empty() {
5938 None
5939 } else {
5940 Some(0)
5941 });
5942 }
5943
5944 fn palette_move(&mut self, delta: i32) {
5945 let n = self.palette_filtered.len();
5946 if n == 0 {
5947 self.palette_state.select(None);
5948 return;
5949 }
5950 let cur = self.palette_state.selected().unwrap_or(0) as i32;
5951 let next = (cur + delta).rem_euclid(n as i32) as usize;
5952 self.palette_state.select(Some(next));
5953 }
5954
5955 fn palette_execute(&mut self) {
5956 let Some(pos) = self.palette_state.selected() else {
5957 return;
5958 };
5959 let Some(&idx) = self.palette_filtered.get(pos) else {
5960 return;
5961 };
5962 let Some(item) = self.palette_items.get(idx).cloned() else {
5963 return;
5964 };
5965 self.mode = Mode::Normal;
5966 self.palette_input.clear();
5967 match item.action {
5968 PaletteAction::RunCommand(cmd) => self.execute_command(&cmd),
5969 PaletteAction::PrefillCommand(prefix) => {
5970 self.command_input = prefix;
5971 self.mode = Mode::Command;
5972 }
5973 PaletteAction::JumpEnv(name) => {
5974 if let Some(pos) = self.cached_display.iter().position(|r| match r {
5975 DisplayRow::Env(i) => self.environments[*i].name == name,
5976 DisplayRow::Separator => false,
5977 }) {
5978 self.table_state.select(Some(pos));
5979 self.status_message = Some(format!("jumped to {name}"));
5980 }
5981 }
5982 PaletteAction::LoadView(name) => {
5983 self.execute_command(&format!("view {name}"));
5984 }
5985 }
5986 }
5987
5988 fn quickjump_apply(&mut self) {
5989 if self.quickjump_input.is_empty() {
5990 return;
5991 }
5992 let needle = self.quickjump_input.to_lowercase();
5993 for (pos, row) in self.cached_display.iter().enumerate() {
5994 if let DisplayRow::Env(i) = row {
5995 let e = &self.environments[*i];
5996 let alias = self
5997 .aliases
5998 .get(&e.name)
5999 .map(|a| a.to_lowercase())
6000 .unwrap_or_default();
6001 if e.name.to_lowercase().starts_with(&needle) || alias.starts_with(&needle) {
6002 self.table_state.select(Some(pos));
6003 return;
6004 }
6005 }
6006 }
6007 }
6008
6009 fn quick_jump(&mut self, n: usize) {
6010 let Some(target_env) = self
6012 .cached_display
6013 .iter()
6014 .filter(|r| matches!(r, DisplayRow::Env(_)))
6015 .nth(n.saturating_sub(1))
6016 else {
6017 return;
6018 };
6019 if let Some(pos) = self
6020 .cached_display
6021 .iter()
6022 .position(|r| std::ptr::eq(r, target_env))
6023 {
6024 self.table_state.select(Some(pos));
6025 }
6026 }
6027
6028 fn open_detail(&mut self) {
6029 let Some(env) = self.selected_env().cloned() else {
6030 self.status_message = Some("no env selected".into());
6031 return;
6032 };
6033 let mut tabs = vec![
6034 DetailTab::Health,
6035 DetailTab::Events,
6036 DetailTab::Instances,
6037 DetailTab::Metrics,
6038 ];
6039 if env.tier == "Worker" {
6040 tabs.push(DetailTab::Queue);
6041 }
6042 tabs.push(DetailTab::Logs);
6043 tabs.push(DetailTab::Config);
6044 let detail = DetailState {
6045 env_name: env.name.clone(),
6046 env_snapshot: env,
6047 tabs,
6048 tab_idx: 0,
6049 events: Vec::new(),
6050 instances: Vec::new(),
6051 queues: WorkerQueues::default(),
6052 metrics: Vec::new(),
6053 metrics_range_secs: 3600, auto_refresh: false,
6055 search_input: String::new(),
6056 search_active: false,
6057 search_pattern: None,
6058 search_error: None,
6059 events_scroll: 0,
6060 events_max_scroll: 0,
6061 events_level: EventLevel::default(),
6062 events_window: EventWindow::default(),
6063 instances_scroll: 0,
6064 tags: Vec::new(),
6065 env_vars: Vec::new(),
6066 cw_log_groups: None,
6067 loading_events: false,
6068 loading_instances: false,
6069 loading_queues: false,
6070 loading_metrics: false,
6071 loading_tags: false,
6072 loading_env_vars: false,
6073 error: None,
6074 log_tail: LogTail::default(),
6075 queue_cursor: 0,
6076 instances_cursor: 0,
6077 instance_terminate_confirm: None,
6078 health_cursor: 0,
6079 metrics_hover_col: None,
6080 metrics_body_rect: None,
6081 cw_alarms: None,
6082 loading_cw_alarms: false,
6083 recent_versions: None,
6084 loading_recent_versions: false,
6085 config_cursor: 0,
6086 config_edit: None,
6087 config_scroll: 0,
6088 config_delete_confirm: None,
6089 };
6090 self.detail = Some(detail);
6091 self.mode = Mode::Detail;
6092 self.detail_refresh_active_tab();
6093 self.spawn_detail_tags();
6096 self.spawn_detail_env_vars();
6097 self.spawn_detail_log_groups();
6098 if let Some(d) = self.detail.as_ref() {
6099 let env_name = d.env_name.clone();
6100 self.spawn_detail_instances(env_name);
6101 }
6102 }
6103
6104 fn spawn_detail_log_groups(&mut self) {
6105 let Some(d) = self.detail.as_ref() else {
6106 return;
6107 };
6108 let env_name = d.env_name.clone();
6109 let aws = self.aws.clone();
6110 let tx = self.msg_tx.clone();
6111 let gen = self.generation;
6112 tokio::spawn(async move {
6113 let groups = aws
6117 .discover_env_log_groups(&env_name)
6118 .await
6119 .unwrap_or_default();
6120 let _ = tx.send(AppMsg::DetailLogGroups {
6121 gen,
6122 env_name,
6123 groups,
6124 });
6125 });
6126 }
6127
6128 fn spawn_detail_env_vars(&mut self) {
6129 let Some(d) = self.detail.as_ref() else {
6130 return;
6131 };
6132 let app_name = d.env_snapshot.application.clone();
6133 let env_name = d.env_name.clone();
6134 if let Some(d) = self.detail.as_mut() {
6135 d.loading_env_vars = true;
6136 }
6137 let env_for_msg = env_name.clone();
6138 self.spawn_aws(
6139 "fetch_env_vars",
6140 move |aws| async move { aws.fetch_env_vars(&app_name, &env_name).await },
6141 move |gen, result| AppMsg::DetailEnvVars {
6142 gen,
6143 env_name: env_for_msg,
6144 result,
6145 },
6146 );
6147 }
6148
6149 fn spawn_detail_tags(&mut self) {
6150 let Some(d) = self.detail.as_ref() else {
6151 return;
6152 };
6153 let Some(arn) = d.env_snapshot.arn.clone() else {
6154 return;
6155 };
6156 let env_name = d.env_name.clone();
6157 if let Some(d) = self.detail.as_mut() {
6158 d.loading_tags = true;
6159 }
6160 self.spawn_aws(
6161 "list_tags",
6162 move |aws| async move { aws.list_tags(&arn).await },
6163 move |gen, result| AppMsg::DetailTags {
6164 gen,
6165 env_name,
6166 result,
6167 },
6168 );
6169 }
6170
6171 fn drill_health_item(&mut self) {
6180 let Some(detail) = self.detail.as_ref() else {
6181 return;
6182 };
6183 let now = chrono::Utc::now();
6184 let items = crate::app::health_items(detail, now);
6185 let Some(item) = items.get(detail.health_cursor).copied() else {
6186 return;
6187 };
6188 match item {
6189 HealthItem::Event { event_idx } => {
6190 let Some(ev) = detail.events.get(event_idx) else {
6191 return;
6192 };
6193 let when = ev
6194 .at
6195 .map(|t| t.with_timezone(&chrono::Local).to_string())
6196 .unwrap_or_else(|| "?".into());
6197 let body = format!(
6198 "{when}\n[{}] {}\n\n{}\n\nesc / q to close",
6199 ev.severity, ev.env, ev.message
6200 );
6201 self.current_overlay = Some(Overlay::TextDump {
6202 title: "event detail".into(),
6203 body,
6204 });
6205 }
6206 HealthItem::Instance { instance_idx } => {
6207 let Some(d) = self.detail.as_mut() else {
6211 return;
6212 };
6213 if let Some(pos) = d.tabs.iter().position(|t| *t == DetailTab::Instances) {
6214 d.tab_idx = pos;
6215 }
6216 d.instances_cursor = instance_idx.min(d.instances.len().saturating_sub(1));
6217 d.instances_scroll = (d.instances_cursor as u16).saturating_sub(3);
6218 self.detail_refresh_active_tab();
6219 }
6220 HealthItem::MainQueue | HealthItem::Dlq => {
6221 let Some(d) = self.detail.as_mut() else {
6222 return;
6223 };
6224 if let Some(pos) = d.tabs.iter().position(|t| *t == DetailTab::Queue) {
6225 d.tab_idx = pos;
6226 }
6227 d.queue_cursor = match item {
6228 HealthItem::MainQueue => 0,
6229 HealthItem::Dlq => 1,
6230 _ => 0,
6231 };
6232 self.detail_refresh_active_tab();
6233 }
6234 }
6235 }
6236
6237 fn detail_cycle_tab(&mut self, delta: i32) {
6238 let Some(detail) = self.detail.as_mut() else {
6239 return;
6240 };
6241 let n = detail.tabs.len() as i32;
6242 let next = (detail.tab_idx as i32 + delta).rem_euclid(n) as usize;
6243 detail.tab_idx = next;
6244 self.detail_refresh_active_tab();
6245 }
6252
6253 fn detail_scroll(&mut self, delta: i32) {
6254 let Some(detail) = self.detail.as_mut() else {
6255 return;
6256 };
6257 match detail.tab() {
6258 DetailTab::Events => {
6259 detail.events_scroll =
6262 scroll_apply(detail.events_scroll, delta).min(detail.events_max_scroll);
6263 }
6264 DetailTab::Instances => {
6265 let n = detail.instances.len();
6266 if n == 0 {
6267 return;
6268 }
6269 let cur = detail.instances_cursor as i32;
6270 let next = (cur + delta).rem_euclid(n as i32) as usize;
6271 detail.instances_cursor = next;
6272 detail.instances_scroll = (next as u16).saturating_sub(3);
6275 }
6276 DetailTab::Logs => {
6277 detail.log_tail.scroll = scroll_apply(detail.log_tail.scroll, delta);
6278 }
6279 DetailTab::Queue => {
6280 let n: i32 = 2;
6282 let cur = detail.queue_cursor as i32;
6283 detail.queue_cursor = (cur + delta).rem_euclid(n) as usize;
6284 }
6285 DetailTab::Health => {
6286 let now = chrono::Utc::now();
6289 let n = crate::app::health_items(detail, now).len() as i32;
6290 if n == 0 {
6291 return;
6292 }
6293 let cur = detail.health_cursor as i32;
6294 detail.health_cursor = (cur + delta).rem_euclid(n) as usize;
6295 }
6296 DetailTab::Config => {
6297 let n = crate::app::config_editable_items(detail).len();
6301 if n == 0 {
6302 return;
6303 }
6304 let cur = detail.config_cursor as i32;
6305 detail.config_cursor = (cur + delta).clamp(0, n as i32 - 1) as usize;
6306 }
6307 DetailTab::Metrics => {}
6310 }
6311 }
6312
6313 fn detail_refresh_active_tab(&mut self) {
6314 let Some(detail) = self.detail.as_ref() else {
6315 return;
6316 };
6317 let env_name = detail.env_name.clone();
6318 let app_name = detail.env_snapshot.application.clone();
6319 let is_worker = detail.env_snapshot.tier.eq_ignore_ascii_case("Worker");
6320 let tab = detail.tab();
6321 let _ = detail;
6324 match tab {
6325 DetailTab::Health => {
6332 self.spawn_detail_events(env_name.clone());
6333 self.spawn_detail_alarms(env_name.clone());
6334 self.spawn_detail_recent_versions(app_name.clone(), env_name.clone());
6335 if is_worker {
6336 self.spawn_detail_queues(app_name, env_name);
6337 }
6338 }
6339 DetailTab::Events => self.spawn_detail_events(env_name),
6340 DetailTab::Instances => self.spawn_detail_instances(env_name),
6341 DetailTab::Queue => self.spawn_detail_queues(app_name, env_name),
6342 DetailTab::Metrics => self.spawn_detail_metrics(env_name),
6343 DetailTab::Logs => self.spawn_detail_logs(env_name),
6344 DetailTab::Config => {}
6345 }
6346 }
6347
6348 fn handle_detail_search_key(&mut self, key: KeyEvent) {
6349 let Some(detail) = self.detail.as_mut() else {
6350 return;
6351 };
6352 let on_logs = detail.log_tail.search_active;
6356 match key.code {
6357 KeyCode::Esc => {
6358 if on_logs {
6359 detail.log_tail.search_active = false;
6360 detail.log_tail.search_input.clear();
6361 detail.log_tail.search_error = None;
6362 } else {
6363 detail.search_active = false;
6364 detail.search_input.clear();
6365 detail.search_error = None;
6366 }
6367 }
6368 KeyCode::Enter => {
6369 if on_logs {
6370 detail.log_tail.search_active = false;
6371 if detail.log_tail.search_input.is_empty() {
6372 detail.log_tail.search_pattern = None;
6373 detail.log_tail.search_error = None;
6374 return;
6375 }
6376 match regex::RegexBuilder::new(&detail.log_tail.search_input)
6377 .case_insensitive(true)
6378 .build()
6379 {
6380 Ok(r) => {
6381 detail.log_tail.search_pattern = Some(r);
6382 detail.log_tail.search_error = None;
6383 }
6384 Err(e) => {
6385 detail.log_tail.search_pattern = None;
6386 detail.log_tail.search_error = Some(format!("invalid regex: {e}"));
6387 }
6388 }
6389 return;
6390 }
6391 detail.search_active = false;
6392 if detail.search_input.is_empty() {
6393 detail.search_pattern = None;
6394 detail.search_error = None;
6395 return;
6396 }
6397 match regex::RegexBuilder::new(&detail.search_input)
6398 .case_insensitive(true)
6399 .build()
6400 {
6401 Ok(r) => {
6402 detail.search_pattern = Some(r);
6403 detail.search_error = None;
6404 }
6405 Err(e) => {
6406 detail.search_pattern = None;
6407 detail.search_error = Some(format!("invalid regex: {e}"));
6408 }
6409 }
6410 }
6411 KeyCode::Backspace => {
6412 if on_logs {
6413 detail.log_tail.search_input.pop();
6414 } else {
6415 detail.search_input.pop();
6416 }
6417 }
6418 KeyCode::Char(c) if is_text_input(&key) => {
6419 if on_logs {
6420 detail.log_tail.search_input.push(c);
6421 } else {
6422 detail.search_input.push(c);
6423 }
6424 }
6425 _ => {}
6426 }
6427 }
6428
6429 fn start_config_edit(&mut self) {
6434 let env_name = match self.detail.as_ref() {
6435 Some(d) => d.env_name.clone(),
6436 None => return,
6437 };
6438 if self.deny_write(&env_name, "config editing") {
6439 return;
6440 }
6441 let Some(detail) = self.detail.as_mut() else {
6442 return;
6443 };
6444 let items = crate::app::config_editable_items(detail);
6445 let Some(item) = items.get(detail.config_cursor) else {
6446 self.error_message = Some("no editable config rows".into());
6447 return;
6448 };
6449 let key = item.key.clone();
6450 let caret = item.value.chars().count();
6453 detail.config_edit = Some(ConfigEdit {
6454 kind: item.kind,
6455 key: item.key.clone(),
6456 original: item.value.clone(),
6457 input: item.value.clone(),
6458 caret,
6459 mode: ConfigEditMode::Value,
6460 });
6461 self.status_message = Some(format!("editing {key} — enter saves, esc cancels"));
6462 }
6463
6464 fn handle_config_edit_key(&mut self, key: KeyEvent) {
6468 match key.code {
6469 KeyCode::Esc => {
6470 if let Some(d) = self.detail.as_mut() {
6471 d.config_edit = None;
6472 }
6473 self.status_message = Some("config edit cancelled".into());
6474 }
6475 KeyCode::Enter => self.commit_config_edit(),
6476 KeyCode::Backspace => {
6477 if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
6478 e.backspace();
6479 }
6480 }
6481 KeyCode::Delete => {
6482 if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
6483 e.delete();
6484 }
6485 }
6486 KeyCode::Left => {
6487 if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
6488 e.move_left();
6489 }
6490 }
6491 KeyCode::Right => {
6492 if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
6493 e.move_right();
6494 }
6495 }
6496 KeyCode::Home => {
6497 if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
6498 e.move_home();
6499 }
6500 }
6501 KeyCode::End => {
6502 if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
6503 e.move_end();
6504 }
6505 }
6506 KeyCode::Char(c) if is_text_input(&key) => {
6507 if let Some(e) = self.detail.as_mut().and_then(|d| d.config_edit.as_mut()) {
6508 e.insert(c);
6509 }
6510 }
6511 _ => {}
6512 }
6513 }
6514
6515 fn commit_config_edit(&mut self) {
6523 let Some(edit) = self.detail.as_mut().and_then(|d| d.config_edit.take()) else {
6524 return;
6525 };
6526 let ns = "aws:elasticbeanstalk:application:environment";
6527 match edit.mode {
6528 ConfigEditMode::Value => {
6529 if edit.input == edit.original {
6530 self.status_message = Some(format!("{} unchanged", edit.key));
6531 return;
6532 }
6533 match edit.kind {
6534 ConfigItemKind::EnvVar => self.spawn_option_settings_update(
6535 format!("env set {}", edit.key),
6536 vec![(ns.into(), edit.key.clone(), edit.input.clone())],
6537 vec![],
6538 ),
6539 ConfigItemKind::Tag => {
6540 self.spawn_tag_update(vec![(edit.key.clone(), edit.input.clone())], vec![])
6541 }
6542 }
6543 }
6544 ConfigEditMode::NewRow => {
6545 let Some((k, v)) = crate::mode_detail::parse_new_config_row(&edit.input) else {
6546 self.error_message = Some("new row needs KEY=VALUE (non-empty key)".into());
6547 return;
6548 };
6549 match edit.kind {
6550 ConfigItemKind::EnvVar => self.spawn_option_settings_update(
6551 format!("env set {k}"),
6552 vec![(ns.into(), k, v)],
6553 vec![],
6554 ),
6555 ConfigItemKind::Tag => self.spawn_tag_update(vec![(k, v)], vec![]),
6556 }
6557 }
6558 ConfigEditMode::RenameKey => {
6559 let new_key = edit.input.trim().to_string();
6560 if new_key.is_empty() {
6561 self.error_message = Some("rename: the new key can't be empty".into());
6562 return;
6563 }
6564 if new_key == edit.original {
6565 self.status_message = Some(format!("{} unchanged", edit.key));
6566 return;
6567 }
6568 let value = self.detail.as_ref().and_then(|d| {
6570 config_editable_items(d)
6571 .into_iter()
6572 .find(|it| it.kind == edit.kind && it.key == edit.key)
6573 .map(|it| it.value)
6574 });
6575 let Some(value) = value else {
6576 self.error_message = Some("rename: the row no longer exists".into());
6577 return;
6578 };
6579 let old = edit.key.clone();
6580 match edit.kind {
6581 ConfigItemKind::EnvVar => self.spawn_option_settings_update(
6582 format!("env rename {old} -> {new_key}"),
6583 vec![(ns.into(), new_key, value)],
6584 vec![(ns.into(), old)],
6585 ),
6586 ConfigItemKind::Tag => self.spawn_tag_update(vec![(new_key, value)], vec![old]),
6587 }
6588 }
6589 }
6590 }
6591
6592 fn start_config_add(&mut self) {
6598 let env_name = match self.detail.as_ref() {
6599 Some(d) => d.env_name.clone(),
6600 None => return,
6601 };
6602 if self.deny_write(&env_name, "config editing") {
6603 return;
6604 }
6605 let Some(detail) = self.detail.as_mut() else {
6606 return;
6607 };
6608 let items = crate::app::config_editable_items(detail);
6609 let kind = items
6610 .get(detail.config_cursor)
6611 .map(|i| i.kind)
6612 .unwrap_or(ConfigItemKind::EnvVar);
6613 detail.config_edit = Some(ConfigEdit {
6614 kind,
6615 key: String::new(),
6616 original: String::new(),
6617 input: String::new(),
6618 caret: 0,
6619 mode: ConfigEditMode::NewRow,
6620 });
6621 let what = match kind {
6622 ConfigItemKind::EnvVar => "env var",
6623 ConfigItemKind::Tag => "tag",
6624 };
6625 self.status_message = Some(format!(
6626 "new {what} — type KEY=VALUE, enter saves, esc cancels"
6627 ));
6628 }
6629
6630 fn start_config_rename(&mut self) {
6635 let env_name = match self.detail.as_ref() {
6636 Some(d) => d.env_name.clone(),
6637 None => return,
6638 };
6639 if self.deny_write(&env_name, "config editing") {
6640 return;
6641 }
6642 let Some(detail) = self.detail.as_mut() else {
6643 return;
6644 };
6645 let items = crate::app::config_editable_items(detail);
6646 let Some(item) = items.get(detail.config_cursor) else {
6647 self.error_message = Some("no editable config rows".into());
6648 return;
6649 };
6650 let key = item.key.clone();
6651 let caret = key.chars().count();
6652 detail.config_edit = Some(ConfigEdit {
6653 kind: item.kind,
6654 key: item.key.clone(),
6655 original: item.key.clone(),
6656 input: item.key.clone(),
6657 caret,
6658 mode: ConfigEditMode::RenameKey,
6659 });
6660 self.status_message = Some(format!(
6661 "renaming {key} — type the new key, enter saves, esc cancels"
6662 ));
6663 }
6664
6665 fn arm_config_delete(&mut self) {
6670 let env_name = match self.detail.as_ref() {
6671 Some(d) => d.env_name.clone(),
6672 None => return,
6673 };
6674 if self.deny_write(&env_name, "config editing") {
6675 return;
6676 }
6677 let Some(detail) = self.detail.as_mut() else {
6678 return;
6679 };
6680 let items = crate::app::config_editable_items(detail);
6681 let Some(item) = items.get(detail.config_cursor) else {
6682 self.error_message = Some("no editable config rows".into());
6683 return;
6684 };
6685 let key = item.key.clone();
6686 detail.config_delete_confirm = Some(detail.config_cursor);
6687 self.status_message = Some(format!("delete {key}? — y confirms, any other key cancels"));
6688 }
6689
6690 fn commit_config_delete(&mut self) {
6693 let Some(idx) = self
6694 .detail
6695 .as_mut()
6696 .and_then(|d| d.config_delete_confirm.take())
6697 else {
6698 return;
6699 };
6700 let Some(detail) = self.detail.as_ref() else {
6701 return;
6702 };
6703 let items = crate::app::config_editable_items(detail);
6704 let Some(item) = items.get(idx) else {
6705 self.error_message = Some("config row no longer exists".into());
6706 return;
6707 };
6708 let kind = item.kind;
6709 let key = item.key.clone();
6710 match kind {
6711 ConfigItemKind::EnvVar => {
6712 let ns = "aws:elasticbeanstalk:application:environment";
6713 self.spawn_option_settings_update(
6714 format!("env unset {key}"),
6715 vec![],
6716 vec![(ns.into(), key)],
6717 );
6718 }
6719 ConfigItemKind::Tag => {
6720 self.spawn_tag_update(vec![], vec![key]);
6721 }
6722 }
6723 }
6724
6725 fn detail_search_jump(&mut self, delta: i32) {
6726 let Some(detail) = self.detail.as_mut() else {
6727 return;
6728 };
6729 let Some(re) = detail.search_pattern.as_ref() else {
6730 return;
6731 };
6732 let visible = crate::mode_detail::filter_event_indices(
6737 &detail.events,
6738 detail.events_level,
6739 detail.events_window,
6740 chrono::Utc::now(),
6741 );
6742 let n = visible.len();
6743 if n == 0 {
6744 return;
6745 }
6746 let cur = (detail.events_scroll as usize).min(n - 1);
6747 let order: Vec<usize> = if delta >= 0 {
6748 (1..=n).map(|off| (cur + off) % n).collect()
6749 } else {
6750 (1..=n).map(|off| (cur + n - off) % n).collect()
6751 };
6752 for pos in order {
6753 if re.is_match(&detail.events[visible[pos]].message) {
6754 detail.events_scroll = pos as u16;
6755 return;
6756 }
6757 }
6758 }
6759
6760 fn cycle_saved_view(&mut self, delta: i32) {
6778 if self.saved_views.is_empty() {
6779 return;
6780 }
6781 let names: Vec<String> = self.saved_views.keys().cloned().collect();
6784 let cur_idx = if self.filter.is_empty() {
6785 None
6786 } else {
6787 names.iter().position(|n| {
6788 self.saved_views
6789 .get(n)
6790 .map(|encoded| view_filter_value(encoded) == self.filter)
6791 .unwrap_or(false)
6792 })
6793 };
6794 let next = match cur_idx {
6795 Some(i) => (i as i32 + delta).rem_euclid(names.len() as i32) as usize,
6796 None if delta >= 0 => 0,
6797 None => names.len() - 1,
6798 };
6799 let chosen = names[next].clone();
6800 if let Some(snap) = self.saved_views.get(&chosen).cloned() {
6801 apply_view(self, &snap);
6802 self.status_message = Some(format!("view: {chosen}"));
6803 }
6804 }
6805
6806 pub(crate) fn dispatch_auto_rollback(&mut self, env_name: String, health: String) {
6819 self.watching_deploys.remove(&env_name);
6826 let Some(snapshot) = self.deploy_snapshots.get(&env_name).cloned() else {
6827 self.pin_error(format!(
6832 "auto-rollback for {env_name}: no pre-deploy snapshot; manual rollback required"
6833 ));
6834 self.armed_watchdogs.remove(&env_name);
6835 return;
6836 };
6837 if self.deny_write(&env_name, "auto-rollback") {
6838 self.armed_watchdogs.remove(&env_name);
6839 return;
6840 }
6841 self.armed_watchdogs.remove(&env_name);
6842 let label = snapshot.previous_version_label.clone();
6843 let aws = self.aws.clone();
6844 let tx = self.msg_tx.clone();
6845 let gen = self.generation;
6846 let account = self.context.account_id.clone();
6847 let profile = self.context.profile.clone();
6848 let region = self.context.region.clone();
6849 write_audit_line(
6850 account.as_deref(),
6851 profile.as_deref(),
6852 ®ion,
6853 &format!(
6854 "stage=dispatched action=AutoRollback target={env_name} version={label} health={health}"
6855 ),
6856 );
6857 self.push_pending("Auto-rollback", env_name.clone());
6858 self.pin_status(format!(
6859 "auto-rollback for {env_name}: redeploying {label} (env was {health})"
6860 ));
6861 let env_for_msg = env_name.clone();
6862 tokio::spawn(async move {
6863 let result = aws
6864 .deploy_version(&env_name, &label)
6865 .await
6866 .map_err(|e| flatten_err("deploy_version", e));
6867 let _ = tx.send(AppMsg::ActionResult {
6868 gen,
6869 action: Action::Deploy,
6870 env_name: env_for_msg,
6871 result,
6872 });
6873 });
6874 }
6875
6876 fn cycle_metrics_range(&mut self, delta: i32) {
6877 const RANGES: &[i64] = &[900, 3600, 21_600, 86_400]; let Some(d) = self.detail.as_mut() else {
6879 return;
6880 };
6881 let cur = RANGES
6882 .iter()
6883 .position(|r| *r == d.metrics_range_secs)
6884 .unwrap_or(1) as i32;
6885 let next = (cur + delta).rem_euclid(RANGES.len() as i32) as usize;
6886 d.metrics_range_secs = RANGES[next];
6887 let env_name = d.env_name.clone();
6888 self.spawn_detail_metrics(env_name);
6889 }
6890
6891 fn spawn_detail_logs(&mut self, env_name: String) {
6892 if let Some(d) = self.detail.as_mut() {
6893 d.log_tail.stage = LogTailStage::Requesting;
6897 d.log_tail.poll_attempt = 0;
6898 d.log_tail.error = None;
6899 }
6900 let aws = self.aws.clone();
6901 let tx = self.msg_tx.clone();
6902 let gen = self.generation;
6903 let env_for_msg = env_name.clone();
6904 tokio::spawn(async move {
6905 let result = collect_tail_logs(aws, env_name.clone(), tx.clone(), gen).await;
6906 let _ = tx.send(AppMsg::DetailLogs {
6907 gen,
6908 env_name: env_for_msg,
6909 result,
6910 });
6911 });
6912 }
6913
6914 fn spawn_detail_metrics(&mut self, env_name: String) {
6915 let range = self
6916 .detail
6917 .as_ref()
6918 .map(|d| d.metrics_range_secs)
6919 .unwrap_or(3600);
6920 if let Some(d) = self.detail.as_mut() {
6921 d.loading_metrics = true;
6922 d.error = None;
6923 }
6924 let custom: Vec<crate::aws::CustomMetricQuery> = self
6927 .custom_metrics
6928 .iter()
6929 .map(|(label, spec)| {
6930 (
6931 label.clone(),
6932 spec.namespace.clone(),
6933 spec.name.clone(),
6934 spec.stat.clone(),
6935 spec.dimensions.clone(),
6936 )
6937 })
6938 .collect();
6939 let aws = self.aws.clone();
6940 let tx = self.msg_tx.clone();
6941 let gen = self.generation;
6942 let name = env_name.clone();
6943 tokio::spawn(async move {
6944 let (builtin, user) = tokio::join!(
6949 aws.fetch_env_metrics(&name, range),
6950 aws.fetch_custom_env_metrics(&name, range, &custom),
6951 );
6952 let result = match builtin {
6953 Ok(mut series) => {
6954 if let Ok(extra) = user {
6955 series.extend(extra);
6956 }
6957 Ok(series)
6958 }
6959 Err(e) => Err(flatten_err("fetch_env_metrics", e)),
6960 };
6961 let _ = tx.send(AppMsg::DetailMetrics {
6962 gen,
6963 env_name,
6964 result,
6965 });
6966 });
6967 }
6968
6969 fn open_queue_viewer(&mut self, viewing: QueueView) {
6973 let Some(detail) = self.detail.as_ref() else {
6974 return;
6975 };
6976 if detail.tab() != DetailTab::Queue {
6977 return;
6978 }
6979 let main_url = detail.queues.main_url.clone().unwrap_or_default();
6980 let dlq_url = detail.queues.dlq_url.clone().unwrap_or_default();
6981 let target_url = match viewing {
6982 QueueView::Main => main_url.clone(),
6983 QueueView::Dlq => dlq_url.clone(),
6984 };
6985 if target_url.is_empty() {
6986 self.status_message = Some(match viewing {
6987 QueueView::Main => "no main queue URL known".into(),
6988 QueueView::Dlq => "no DLQ for this env".into(),
6989 });
6990 return;
6991 }
6992 let dlq = DlqState {
6993 env_name: detail.env_name.clone(),
6994 main_queue_url: main_url,
6995 dlq_url,
6996 messages: Vec::new(),
6997 list_state: ListState::default(),
6998 loading: false,
6999 error: None,
7000 confirm_purge: false,
7001 purge_typed: String::new(),
7002 viewing,
7003 confirm_delete_idx: None,
7004 replay_input: None,
7005 };
7006 self.dlq = Some(dlq);
7007 self.mode = Mode::Dlq;
7008 self.spawn_dlq_fetch();
7009 }
7010
7011 fn open_dlq(&mut self) {
7012 let Some(detail) = self.detail.as_ref() else {
7013 return;
7014 };
7015 if detail.tab() != DetailTab::Queue {
7016 return;
7017 }
7018 let Some(dlq_url) = detail.queues.dlq_url.clone() else {
7019 self.status_message = Some("no DLQ for this env".into());
7020 return;
7021 };
7022 let main_url = detail.queues.main_url.clone().unwrap_or_default();
7023 let dlq = DlqState {
7024 env_name: detail.env_name.clone(),
7025 main_queue_url: main_url,
7026 dlq_url,
7027 messages: Vec::new(),
7028 list_state: ListState::default(),
7029 loading: false,
7030 error: None,
7031 confirm_purge: false,
7032 purge_typed: String::new(),
7033 viewing: QueueView::Dlq,
7034 confirm_delete_idx: None,
7035 replay_input: None,
7036 };
7037 self.dlq = Some(dlq);
7038 self.mode = Mode::Dlq;
7039 self.spawn_dlq_fetch();
7040 }
7041
7042 fn open_dlq_from_why(&mut self, env_name: String, main_queue_url: String, dlq_url: String) {
7047 let dlq = DlqState {
7048 env_name,
7049 main_queue_url,
7050 dlq_url,
7051 messages: Vec::new(),
7052 list_state: ListState::default(),
7053 loading: false,
7054 error: None,
7055 confirm_purge: false,
7056 purge_typed: String::new(),
7057 viewing: QueueView::Dlq,
7058 confirm_delete_idx: None,
7059 replay_input: None,
7060 };
7061 self.dlq = Some(dlq);
7062 self.mode = Mode::Dlq;
7063 self.spawn_dlq_fetch();
7064 }
7065
7066 fn close_dlq(&mut self) {
7067 self.dlq = None;
7068 self.mode = if self.detail.is_some() {
7069 Mode::Detail
7070 } else {
7071 Mode::Normal
7072 };
7073 }
7074
7075 fn spawn_dlq_fetch(&mut self) {
7076 let Some(dlq) = self.dlq.as_mut() else { return };
7077 dlq.loading = true;
7078 dlq.error = None;
7079 let env_name = dlq.env_name.clone();
7080 let queue_url = match dlq.viewing {
7081 QueueView::Dlq => dlq.dlq_url.clone(),
7082 QueueView::Main => dlq.main_queue_url.clone(),
7083 };
7084 self.spawn_aws(
7085 "peek_messages",
7086 move |aws| async move { aws.peek_messages(&queue_url, 50).await },
7087 move |gen, result| AppMsg::DlqMessages {
7088 gen,
7089 env_name,
7090 result,
7091 },
7092 );
7093 }
7094
7095 fn spawn_dlq_delete_one(&mut self, idx: usize) {
7100 let Some(dlq) = self.dlq.as_mut() else { return };
7101 let Some(msg) = dlq.messages.get(idx).cloned() else {
7102 return;
7103 };
7104 let queue_url = match dlq.viewing {
7105 QueueView::Dlq => dlq.dlq_url.clone(),
7106 QueueView::Main => dlq.main_queue_url.clone(),
7107 };
7108 if queue_url.is_empty() {
7109 self.error_message = Some("queue URL missing — cannot delete".into());
7110 return;
7111 }
7112 let env_name = dlq.env_name.clone();
7113 let aws = self.aws.clone();
7114 let tx = self.msg_tx.clone();
7115 let gen = self.generation;
7116 write_audit_line(
7117 self.context.account_id.as_deref(),
7118 self.context.profile.as_deref(),
7119 &self.context.region,
7120 &format!(
7121 "sqs-delete env={env_name} queue={} msg_id={}",
7122 if matches!(dlq.viewing, QueueView::Main) {
7123 "MAIN"
7124 } else {
7125 "DLQ"
7126 },
7127 msg.id
7128 ),
7129 );
7130 tokio::spawn(async move {
7131 let result = aws
7132 .delete_message(&queue_url, &msg.receipt_handle)
7133 .await
7134 .map(|_| DlqOp::Resent {
7135 message_id: msg.id.clone(),
7139 })
7140 .map_err(|e| flatten_err("delete_message", e));
7141 let _ = tx.send(AppMsg::DlqActionResult {
7142 gen,
7143 env_name,
7144 result,
7145 });
7146 });
7147 }
7148
7149 fn handle_dlq_key(&mut self, key: KeyEvent) {
7150 let Some(dlq) = self.dlq.as_mut() else { return };
7151 if let Some(idx) = dlq.confirm_delete_idx {
7153 match key.code {
7154 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
7155 dlq.confirm_delete_idx = None;
7156 self.spawn_dlq_delete_one(idx);
7157 }
7158 _ => {
7159 dlq.confirm_delete_idx = None;
7160 }
7161 }
7162 return;
7163 }
7164 if dlq.confirm_purge {
7166 match key.code {
7167 KeyCode::Esc => {
7168 dlq.confirm_purge = false;
7169 dlq.purge_typed.clear();
7170 }
7171 KeyCode::Enter if dlq.purge_typed == dlq.env_name => {
7172 let dlq_url = dlq.dlq_url.clone();
7173 let env_name = dlq.env_name.clone();
7174 dlq.confirm_purge = false;
7175 dlq.purge_typed.clear();
7176 self.spawn_dlq_purge(env_name, dlq_url);
7177 }
7178 KeyCode::Backspace => {
7179 dlq.purge_typed.pop();
7180 }
7181 KeyCode::Char(c) if is_text_input(&key) => dlq.purge_typed.push(c),
7182 _ => {}
7183 }
7184 return;
7185 }
7186 if let Some(input) = dlq.replay_input.as_mut() {
7188 match key.code {
7189 KeyCode::Esc => dlq.replay_input = None,
7190 KeyCode::Enter => match crate::mode_dlq::parse_replay_spec(input) {
7191 None => {
7192 dlq.error = Some(
7193 "replay: type `all`, a count (e.g. 20), or a window (1h / 24h / 7d)"
7194 .into(),
7195 );
7196 }
7197 Some(spec) => {
7198 let idxs = crate::mode_dlq::select_replay_indices(
7199 &dlq.messages,
7200 &spec,
7201 chrono::Utc::now(),
7202 );
7203 let msgs: Vec<_> = idxs
7204 .iter()
7205 .filter_map(|&i| dlq.messages.get(i).cloned())
7206 .collect();
7207 dlq.replay_input = None;
7208 if msgs.is_empty() {
7209 self.error_message = Some("replay: no messages match".into());
7210 } else {
7211 self.spawn_dlq_replay_batch(msgs);
7212 }
7213 }
7214 },
7215 KeyCode::Backspace => {
7216 input.pop();
7217 }
7218 KeyCode::Char(c) if is_text_input(&key) => input.push(c),
7219 _ => {}
7220 }
7221 return;
7222 }
7223
7224 match key.code {
7225 KeyCode::Esc | KeyCode::Char('q') => self.close_dlq(),
7226 KeyCode::Enter => {
7227 let Some(idx) = dlq.list_state.selected() else {
7228 return;
7229 };
7230 let Some(msg) = dlq.messages.get(idx).cloned() else {
7231 return;
7232 };
7233 let when = msg
7234 .sent_at
7235 .map(|t| {
7236 t.with_timezone(&chrono::Local)
7237 .format("%Y-%m-%d %H:%M:%S %Z")
7238 .to_string()
7239 })
7240 .unwrap_or_else(|| "—".into());
7241 let view_label = match dlq.viewing {
7242 QueueView::Main => "Main queue",
7243 QueueView::Dlq => "DLQ",
7244 };
7245 let body = format!(
7246 "{view_label} message\n\
7247 ─────────────────────────────\n\
7248 id: {}\n\
7249 receive-count:{}\n\
7250 sent: {when}\n\
7251 bytes: {}\n\n\
7252 ─ body ─\n{}\n\nesc / q to close",
7253 msg.id,
7254 msg.receive_count,
7255 msg.body.len(),
7256 msg.body
7257 );
7258 self.current_overlay = Some(Overlay::Describe(body));
7259 }
7260 KeyCode::Char('j') | KeyCode::Down => {
7261 let n = dlq.messages.len();
7262 if n == 0 {
7263 return;
7264 }
7265 let cur = dlq.list_state.selected().unwrap_or(0);
7266 dlq.list_state.select(Some((cur + 1) % n));
7267 }
7268 KeyCode::Char('k') | KeyCode::Up => {
7269 let n = dlq.messages.len();
7270 if n == 0 {
7271 return;
7272 }
7273 let cur = dlq.list_state.selected().unwrap_or(0);
7274 dlq.list_state.select(Some((cur + n - 1) % n));
7275 }
7276 KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
7277 self.spawn_dlq_fetch();
7278 }
7279 KeyCode::Char('r') => {
7280 if matches!(dlq.viewing, QueueView::Main) {
7281 self.error_message = Some("resend is only available in DLQ view".into());
7282 } else {
7283 self.spawn_dlq_resend_selected();
7284 }
7285 }
7286 KeyCode::Char('R') => {
7287 if matches!(dlq.viewing, QueueView::Main) {
7288 self.error_message = Some("replay is only available in DLQ view".into());
7289 } else if dlq.messages.is_empty() {
7290 self.error_message = Some("replay: DLQ is empty".into());
7291 } else {
7292 dlq.replay_input = Some(String::new());
7293 dlq.error = None;
7294 }
7295 }
7296 KeyCode::Char('m') => {
7297 if dlq.main_queue_url.is_empty() {
7300 self.error_message = Some("no main queue URL known".into());
7301 } else {
7302 dlq.viewing = match dlq.viewing {
7303 QueueView::Dlq => QueueView::Main,
7304 QueueView::Main => QueueView::Dlq,
7305 };
7306 dlq.messages.clear();
7307 dlq.list_state.select(None);
7308 self.spawn_dlq_fetch();
7309 }
7310 }
7311 KeyCode::Char('x') => {
7312 if let Some(idx) = dlq.list_state.selected() {
7315 if dlq.messages.get(idx).is_some() {
7316 dlq.confirm_delete_idx = Some(idx);
7317 }
7318 }
7319 }
7320 KeyCode::Char('p') => {
7321 if let Some(dlq) = self.dlq.as_mut() {
7322 dlq.confirm_purge = true;
7323 dlq.purge_typed.clear();
7324 }
7325 }
7326 _ => {}
7327 }
7328 }
7329
7330 fn spawn_dlq_resend_selected(&mut self) {
7331 let env_name = match self.dlq.as_ref() {
7332 Some(d) => d.env_name.clone(),
7333 None => return,
7334 };
7335 if self.deny_write(&env_name, "resend") {
7336 return;
7337 }
7338 let Some(dlq) = self.dlq.as_mut() else { return };
7339 let Some(idx) = dlq.list_state.selected() else {
7340 return;
7341 };
7342 let Some(msg) = dlq.messages.get(idx).cloned() else {
7343 return;
7344 };
7345 if dlq.main_queue_url.is_empty() {
7346 dlq.error = Some("main queue URL unknown — cannot resend".into());
7347 return;
7348 }
7349 let aws = self.aws.clone();
7350 let tx = self.msg_tx.clone();
7351 let gen = self.generation;
7352 let env_name = dlq.env_name.clone();
7353 let main_url = dlq.main_queue_url.clone();
7354 let dlq_url = dlq.dlq_url.clone();
7355 write_audit_line(
7356 self.context.account_id.as_deref(),
7357 self.context.profile.as_deref(),
7358 &self.context.region,
7359 &format!("dlq-resend env={env_name} msg_id={}", msg.id),
7360 );
7361 tokio::spawn(async move {
7362 let result = match aws.send_message(&main_url, &msg.body).await {
7363 Ok(()) => match aws.delete_message(&dlq_url, &msg.receipt_handle).await {
7364 Ok(()) => Ok(DlqOp::Resent {
7365 message_id: msg.id.clone(),
7366 }),
7367 Err(e) => {
7368 tracing::error!(target: "ebman::aws", op = "dlq_delete_after_send", error = ?e, "aws call failed");
7369 Err(format!("sent to main queue, but DLQ delete failed: {e}"))
7370 }
7371 },
7372 Err(e) => {
7373 tracing::error!(target: "ebman::aws", op = "dlq_send", error = ?e, "aws call failed");
7374 Err(format!("send to main queue failed: {e}"))
7375 }
7376 };
7377 let _ = tx.send(AppMsg::DlqActionResult {
7378 gen,
7379 env_name,
7380 result,
7381 });
7382 });
7383 }
7384
7385 fn spawn_dlq_purge(&mut self, env_name: String, dlq_url: String) {
7386 if self.deny_write(&env_name, "purge") {
7387 return;
7388 }
7389 write_audit_line(
7390 self.context.account_id.as_deref(),
7391 self.context.profile.as_deref(),
7392 &self.context.region,
7393 &format!("dlq-purge env={env_name}"),
7394 );
7395 let aws = self.aws.clone();
7396 let tx = self.msg_tx.clone();
7397 let gen = self.generation;
7398 tokio::spawn(async move {
7399 let result = aws
7400 .purge_queue(&dlq_url)
7401 .await
7402 .map(|_| DlqOp::Purged)
7403 .map_err(|e| flatten_err("purge_queue", e));
7404 let _ = tx.send(AppMsg::DlqActionResult {
7405 gen,
7406 env_name,
7407 result,
7408 });
7409 });
7410 }
7411
7412 fn spawn_dlq_replay_batch(&mut self, messages: Vec<crate::aws::QueueMessage>) {
7417 let env_name = match self.dlq.as_ref() {
7418 Some(d) => d.env_name.clone(),
7419 None => return,
7420 };
7421 if self.deny_write(&env_name, "replay") {
7422 return;
7423 }
7424 let Some(dlq) = self.dlq.as_ref() else { return };
7425 if matches!(dlq.viewing, QueueView::Main) {
7426 self.error_message = Some("replay is only available in DLQ view".into());
7427 return;
7428 }
7429 if dlq.main_queue_url.is_empty() {
7430 self.error_message = Some("main queue URL unknown — cannot replay".into());
7431 return;
7432 }
7433 let main_url = dlq.main_queue_url.clone();
7434 let dlq_url = dlq.dlq_url.clone();
7435 let env_name = dlq.env_name.clone();
7436 let aws = self.aws.clone();
7437 let tx = self.msg_tx.clone();
7438 let gen = self.generation;
7439 let count = messages.len();
7440 write_audit_line(
7441 self.context.account_id.as_deref(),
7442 self.context.profile.as_deref(),
7443 &self.context.region,
7444 &format!("dlq-replay env={env_name} count={count}"),
7445 );
7446 self.status_message = Some(format!("replaying {count} message(s) to the main queue…"));
7447 tokio::spawn(async move {
7448 let mut failures = 0usize;
7449 for msg in &messages {
7450 match aws.send_message(&main_url, &msg.body).await {
7451 Ok(()) => {
7452 if let Err(e) = aws.delete_message(&dlq_url, &msg.receipt_handle).await {
7453 tracing::error!(target: "ebman::aws", op = "dlq_replay_delete", error = ?e, msg_id = %msg.id, "DLQ delete after send failed");
7454 failures += 1;
7455 }
7456 }
7457 Err(e) => {
7458 tracing::error!(target: "ebman::aws", op = "dlq_replay_send", error = ?e, msg_id = %msg.id, "send to main queue failed");
7459 failures += 1;
7460 }
7461 }
7462 }
7463 let result = Ok(DlqOp::Replayed {
7464 count: count - failures,
7465 failures,
7466 });
7467 let _ = tx.send(AppMsg::DlqActionResult {
7468 gen,
7469 env_name,
7470 result,
7471 });
7472 });
7473 }
7474
7475 fn spawn_detail_queues(&mut self, application_name: String, env_name: String) {
7476 if let Some(d) = self.detail.as_mut() {
7477 d.loading_queues = true;
7478 d.error = None;
7479 }
7480 if self.demo_mode {
7481 let result = Ok(crate::demo_fixture::worker_queues_for_env(&env_name));
7482 let gen = self.generation;
7483 let _ = self.msg_tx.send(AppMsg::DetailQueues {
7484 gen,
7485 env_name,
7486 result,
7487 });
7488 return;
7489 }
7490 let env_for_msg = env_name.clone();
7491 self.spawn_aws(
7492 "describe_worker_queues",
7493 move |aws| async move {
7494 aws.describe_worker_queues(&application_name, &env_name)
7495 .await
7496 },
7497 move |gen, result| AppMsg::DetailQueues {
7498 gen,
7499 env_name: env_for_msg,
7500 result,
7501 },
7502 );
7503 }
7504
7505 fn spawn_detail_events(&mut self, env_name: String) {
7506 if let Some(d) = self.detail.as_mut() {
7507 d.loading_events = true;
7508 d.error = None;
7509 }
7510 if self.demo_mode {
7516 let result = Ok(crate::demo_fixture::events_for_env(&env_name));
7517 let gen = self.generation;
7518 let _ = self.msg_tx.send(AppMsg::DetailEvents {
7519 gen,
7520 env_name,
7521 result,
7522 });
7523 return;
7524 }
7525 let env_for_msg = env_name.clone();
7526 self.spawn_aws(
7527 "list_events_for_env",
7528 move |aws| async move { aws.list_events_for_env(&env_name, 50).await },
7529 move |gen, result| AppMsg::DetailEvents {
7530 gen,
7531 env_name: env_for_msg,
7532 result,
7533 },
7534 );
7535 }
7536
7537 fn target_env_for_action(&self) -> Option<Environment> {
7538 if let Some(d) = self.detail.as_ref() {
7540 return Some(d.env_snapshot.clone());
7541 }
7542 self.selected_env().cloned()
7543 }
7544
7545 fn open_action_menu(&mut self) {
7546 let Some(target) = self.target_env_for_action() else {
7547 self.status_message = Some("no env selected".into());
7548 return;
7549 };
7550 if self.deny_write(&target.name, "action menu") {
7551 return;
7552 }
7553 let mut list_state = ListState::default();
7554 list_state.select(Some(0));
7555 self.action_flow = Some(ActionFlow::Menu { list_state });
7556 self.mode = Mode::Action;
7557 }
7558
7559 fn close_action_flow(&mut self) {
7560 self.action_flow = None;
7561 if self.detail.is_some() {
7562 self.mode = Mode::Detail;
7563 } else {
7564 self.mode = Mode::Normal;
7565 }
7566 }
7567
7568 fn open_form(&mut self, mut form: crate::form::Form) {
7574 if matches!(form.submit, crate::form::FormSubmit::LocalConfig) {
7579 form.state = crate::form::FormState::Ready;
7580 self.form = Some(form);
7581 self.mode = Mode::Form;
7582 return;
7583 }
7584 let env_name = form.env_name.clone();
7585 let app_name = match self.environments.iter().find(|e| e.name == env_name) {
7589 Some(e) => e.application.clone(),
7590 None => {
7591 self.error_message = Some(format!("env '{env_name}' not in current list"));
7592 return;
7593 }
7594 };
7595 self.form = Some(form);
7596 self.mode = Mode::Form;
7597 let aws = self.aws.clone();
7598 let tx = self.msg_tx.clone();
7599 let gen = self.generation;
7600 let env_for_msg = env_name.clone();
7601 tokio::spawn(async move {
7602 let settings = aws
7603 .fetch_env_option_settings(&app_name, &env_for_msg)
7604 .await
7605 .map_err(|e| flatten_err("fetch_env_option_settings", e));
7606 let _ = tx.send(AppMsg::FormPrefilled {
7607 gen,
7608 env_name: env_for_msg,
7609 settings,
7610 });
7611 });
7612 }
7613
7614 fn handle_form_key(&mut self, key: KeyEvent) {
7619 use crate::form::{FieldKind, FormState};
7620 let state = self.form.as_ref().map(|f| f.state.clone());
7623 let cursor_kind = self
7624 .form
7625 .as_ref()
7626 .and_then(|f| f.current_field().map(|fld| fld.kind.clone()));
7627 match state {
7628 None => return,
7629 Some(FormState::Loading) | Some(FormState::Submitting) => {
7630 if matches!(key.code, KeyCode::Esc) {
7631 self.form = None;
7632 self.mode = Mode::Normal;
7633 }
7634 return;
7635 }
7636 Some(FormState::Ready) => {}
7637 }
7638 if matches!(key.code, KeyCode::Char('s')) && key.modifiers.contains(KeyModifiers::CONTROL) {
7640 self.submit_form();
7641 return;
7642 }
7643 if matches!(key.code, KeyCode::Esc) {
7644 self.form = None;
7645 self.mode = Mode::Normal;
7646 return;
7647 }
7648 let is_multi = matches!(cursor_kind.as_ref(), Some(FieldKind::MultiSelect { .. }));
7655 let between_fields = match key.code {
7656 KeyCode::Tab => Some(1),
7657 KeyCode::BackTab => Some(-1),
7658 KeyCode::Up | KeyCode::Down if !is_multi => {
7659 if matches!(key.code, KeyCode::Up) {
7660 Some(-1)
7661 } else {
7662 Some(1)
7663 }
7664 }
7665 _ => None,
7666 };
7667 if let Some(delta) = between_fields {
7668 if let Some(form) = self.form.as_mut() {
7669 form.move_cursor(delta);
7670 }
7671 return;
7672 }
7673 if is_multi
7676 && matches!(
7677 key.code,
7678 KeyCode::Up | KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('k')
7679 )
7680 {
7681 if let Some(form) = self.form.as_mut() {
7682 if let Some(field) = form.current_field_mut() {
7683 if let FieldKind::MultiSelect { options } = &field.kind {
7684 let n = options.len();
7685 if n > 0 {
7686 let delta: isize =
7687 matches!(key.code, KeyCode::Down | KeyCode::Char('j')) as isize * 2
7688 - 1;
7689 let cur = field.option_cursor as isize;
7690 let next = ((cur + delta) % n as isize + n as isize) % n as isize;
7691 field.option_cursor = next as usize;
7692 }
7693 }
7694 }
7695 }
7696 return;
7697 }
7698 let Some(form) = self.form.as_mut() else {
7700 return;
7701 };
7702 let Some(field) = form.current_field_mut() else {
7703 return;
7704 };
7705 match (cursor_kind.unwrap_or(FieldKind::Text), key.code) {
7708 (FieldKind::Text, KeyCode::Backspace) => {
7709 field.value.pop();
7710 }
7711 (FieldKind::Text, KeyCode::Char(c)) if is_text_input(&key) => {
7712 field.value.push(c);
7713 }
7714 (FieldKind::Integer { .. }, KeyCode::Backspace) => {
7715 field.value.pop();
7716 }
7717 (FieldKind::Integer { .. }, KeyCode::Char(c))
7718 if c.is_ascii_digit() || (c == '-' && field.value.is_empty()) =>
7719 {
7720 field.value.push(c);
7721 }
7722 (FieldKind::Boolean, KeyCode::Char(' ')) => {
7723 field.value = if field.value == "true" {
7724 "false".into()
7725 } else {
7726 "true".into()
7727 };
7728 }
7729 (FieldKind::Boolean, KeyCode::Char('t')) => {
7730 field.value = "true".into();
7731 }
7732 (FieldKind::Boolean, KeyCode::Char('f')) => {
7733 field.value = "false".into();
7734 }
7735 (FieldKind::Select { options }, KeyCode::Left)
7736 | (FieldKind::Select { options }, KeyCode::Char('h')) => {
7737 let i = options.iter().position(|o| o == &field.value).unwrap_or(0);
7738 let next = (i + options.len() - 1) % options.len();
7739 field.value = options[next].clone();
7740 }
7741 (FieldKind::Select { options }, KeyCode::Right)
7742 | (FieldKind::Select { options }, KeyCode::Char('l')) => {
7743 let i = options.iter().position(|o| o == &field.value).unwrap_or(0);
7744 let next = (i + 1) % options.len();
7745 field.value = options[next].clone();
7746 }
7747 (FieldKind::MultiSelect { options }, KeyCode::Char(' ')) => {
7748 if let Some(opt) = options.get(field.option_cursor) {
7749 field.value = crate::form::toggle_multi(&field.value, opt);
7750 }
7751 }
7752 _ => {}
7753 }
7754 let _ = crate::form::validate_field(&field.value, &field.kind).map(|_| field.error = None);
7756 }
7757
7758 fn submit_form(&mut self) {
7762 let Some(form) = self.form.as_mut() else {
7763 return;
7764 };
7765 if let Err(failing) = form.validate() {
7766 form.cursor = failing[0];
7767 return;
7768 }
7769 if matches!(form.submit, crate::form::FormSubmit::LocalConfig) {
7772 self.submit_local_config();
7773 return;
7774 }
7775 let env_name = form.env_name.clone();
7776 let summary = form.summary.clone();
7777 let (to_set, to_remove) = form.to_option_settings();
7778 form.state = crate::form::FormState::Submitting;
7779 if self.deny_write(&env_name, "form submit") {
7784 self.form = None;
7785 self.mode = Mode::Normal;
7786 return;
7787 }
7788 if to_set.is_empty() && to_remove.is_empty() {
7789 self.status_message = Some("no changes to apply".into());
7790 self.form = None;
7791 self.mode = Mode::Normal;
7792 return;
7793 }
7794 write_audit_line(
7795 self.context.account_id.as_deref(),
7796 self.context.profile.as_deref(),
7797 &self.context.region,
7798 &format!(
7799 "stage=dispatched action=UpdateOptionSettings target={env_name} summary=\"{summary}\""
7800 ),
7801 );
7802 self.push_pending(summary.clone(), env_name.clone());
7803 let aws = self.aws.clone();
7808 let tx = self.msg_tx.clone();
7809 let gen = self.generation;
7810 let env_for_msg = env_name.clone();
7811 let summary_for_msg = summary.clone();
7812 let account = self.context.account_id.clone();
7813 let profile = self.context.profile.clone();
7814 let region = self.context.region.clone();
7815 let app_for_undo = self
7821 .environments
7822 .iter()
7823 .find(|e| e.name == env_name)
7824 .map(|e| e.application.clone());
7825 let env_for_undo = env_name.clone();
7826 let summary_for_undo = summary.clone();
7827 let to_set_for_undo = to_set.clone();
7828 let to_remove_for_undo = to_remove.clone();
7829 tokio::spawn(async move {
7830 let undo_entry = if let Some(app_name) = app_for_undo {
7831 match aws
7832 .fetch_env_option_settings(&app_name, &env_for_undo)
7833 .await
7834 {
7835 Ok(opts) => Some(build_undo_entry(
7836 &env_for_undo,
7837 &summary_for_undo,
7838 &to_set_for_undo,
7839 &to_remove_for_undo,
7840 &opts,
7841 )),
7842 Err(_) => None,
7843 }
7844 } else {
7845 None
7846 };
7847 let result = aws
7848 .update_env_option_settings(&env_for_msg, &to_set, &to_remove)
7849 .await
7850 .map_err(|e| flatten_err("update_env_option_settings", e));
7851 let outcome = match &result {
7852 Ok(()) => format!(
7853 "stage=completed action=UpdateOptionSettings target={env_for_msg} summary=\"{summary_for_msg}\" outcome=ok"
7854 ),
7855 Err(e) => format!(
7856 "stage=completed action=UpdateOptionSettings target={env_for_msg} summary=\"{summary_for_msg}\" outcome=err err=\"{}\"",
7857 crate::audit::escape_value(e)
7858 ),
7859 };
7860 write_audit_line(account.as_deref(), profile.as_deref(), ®ion, &outcome);
7861 if result.is_ok() {
7862 if let Some(entry) = undo_entry {
7863 let _ = tx.send(AppMsg::UndoCaptured { gen, entry });
7864 }
7865 }
7866 let _ = tx.send(AppMsg::OptionSettingsUpdate {
7867 gen,
7868 env_name: env_for_msg,
7869 summary: summary_for_msg,
7870 result,
7871 });
7872 });
7873 self.form = None;
7876 self.mode = Mode::Normal;
7877 }
7878
7879 fn submit_local_config(&mut self) {
7887 let Some(form) = self.form.as_ref() else {
7888 return;
7889 };
7890 let snapshot = self.current_config_snapshot();
7891 let updated = form.apply_to_config(&snapshot);
7892 match crate::config::save(&updated) {
7893 Ok(()) => {
7894 let path = crate::config::config_path();
7895 self.apply_config_live(&updated);
7896 self.pin_status(format!("settings saved → {}", path.display()));
7897 }
7898 Err(e) => {
7899 self.error_message = Some(format!("settings save failed: {e}"));
7900 }
7901 }
7902 self.form = None;
7903 self.mode = Mode::Normal;
7904 }
7905
7906 fn open_subnets_form(&mut self) {
7913 self.open_multi_select_form(MultiSelectFlavour::Subnets);
7914 }
7915
7916 fn open_elb_subnets_form(&mut self) {
7921 self.open_multi_select_form(MultiSelectFlavour::ElbSubnets);
7922 }
7923
7924 fn open_security_groups_form(&mut self) {
7928 self.open_multi_select_form(MultiSelectFlavour::SecurityGroups);
7929 }
7930
7931 fn open_multi_select_form(&mut self, flavour: MultiSelectFlavour) {
7939 use crate::form::{Form, FormField, FormSubmit};
7940 let Some(env) = self.selected_env().cloned() else {
7941 self.error_message = Some("no env selected".into());
7942 return;
7943 };
7944 let (title_prefix, summary, field_key, label, ns, opt_name) = match flavour {
7945 MultiSelectFlavour::Subnets => (
7946 "subnets",
7947 "subnets update",
7948 "subnets",
7949 "Subnets",
7950 "aws:ec2:vpc",
7951 "Subnets",
7952 ),
7953 MultiSelectFlavour::ElbSubnets => (
7954 "elb-subnets",
7955 "elb-subnets update",
7956 "elb_subnets",
7957 "ELB subnets",
7958 "aws:ec2:vpc",
7959 "ELBSubnets",
7960 ),
7961 MultiSelectFlavour::SecurityGroups => (
7962 "security-groups",
7963 "security-groups update",
7964 "security_groups",
7965 "Security groups",
7966 "aws:autoscaling:launchconfiguration",
7967 "SecurityGroups",
7968 ),
7969 };
7970 let placeholder = FormField::multi_select(
7971 field_key,
7972 label,
7973 Vec::new(),
7974 Vec::new(),
7975 Some::<String>("space toggle · ↑↓ option cursor · tab field".into()),
7976 );
7977 let form = Form::loading(
7978 format!("{title_prefix} — {}", env.name),
7979 env.name.clone(),
7980 summary.to_string(),
7981 vec![placeholder],
7982 FormSubmit::OptionSettings {
7983 mappings: vec![(field_key.into(), ns.into(), opt_name.into())],
7984 },
7985 );
7986 self.form = Some(form);
7990 self.mode = Mode::Form;
7991 let aws = self.aws.clone();
7992 let tx = self.msg_tx.clone();
7993 let gen = self.generation;
7994 let env_for_msg = env.name.clone();
7995 let app_name = env.application.clone();
7996 let field_key_for_msg = field_key.to_string();
7997 tokio::spawn(async move {
7998 let result = load_multi_select(aws, &app_name, &env_for_msg, flavour).await;
7999 let _ = tx.send(AppMsg::FormMultiSelectLoaded {
8000 gen,
8001 env_name: env_for_msg,
8002 field_key: field_key_for_msg,
8003 result,
8004 });
8005 });
8006 }
8007
8008 fn open_settings_form(&mut self) {
8012 use crate::form::{Form, FormField, FormSubmit};
8013 let snapshot = self.current_config_snapshot();
8014 let bool_select = vec!["true".to_string(), "false".to_string()];
8015 let triple_select = vec!["auto".to_string(), "true".to_string(), "false".to_string()];
8016 let mut fields: Vec<FormField> = Vec::new();
8017 let theme_options = vec![
8020 "dark".to_string(),
8021 "light".to_string(),
8022 "high-contrast".to_string(),
8023 ];
8024 let mut theme_field = FormField::select(
8025 "theme",
8026 "Theme",
8027 theme_options.clone(),
8028 Some::<String>("dark / light / high-contrast".into()),
8029 );
8030 theme_field.value = if theme_options.iter().any(|o| o == &snapshot.theme) {
8035 snapshot.theme.clone()
8036 } else {
8037 theme_options[0].clone()
8038 };
8039 fields.push(theme_field);
8040
8041 let icons_options = vec![
8042 "unicode".to_string(),
8043 "ascii".to_string(),
8044 "powerline".to_string(),
8045 "auto".to_string(),
8046 ];
8047 let mut icons_field = FormField::select(
8048 "icons",
8049 "Icons",
8050 icons_options.clone(),
8051 Some::<String>("auto = probe the terminal at startup".into()),
8052 );
8053 icons_field.value = if icons_options
8054 .iter()
8055 .any(|o| o.eq_ignore_ascii_case(&snapshot.icons))
8056 {
8057 snapshot.icons.to_ascii_lowercase()
8058 } else {
8059 "unicode".to_string()
8060 };
8061 fields.push(icons_field);
8062
8063 let mut refresh_field = FormField::integer(
8064 "refresh_interval_secs",
8065 "Refresh interval (s)",
8066 Some("How often the env list reloads from AWS"),
8067 Some(5),
8068 Some(600),
8069 false,
8070 );
8071 refresh_field.value = snapshot.refresh_interval.as_secs().to_string();
8072 fields.push(refresh_field);
8073
8074 let mut redact_field = FormField::select(
8077 "redact_default",
8078 "Redact by default",
8079 triple_select.clone(),
8080 Some::<String>("auto leaves the toggle to per-session state".into()),
8081 );
8082 redact_field.value = match snapshot.redact_default {
8083 None => "auto".into(),
8084 Some(true) => "true".into(),
8085 Some(false) => "false".into(),
8086 };
8087 fields.push(redact_field);
8088
8089 let mut grouped_field = FormField::select(
8090 "grouped_default",
8091 "Group by app by default",
8092 triple_select,
8093 Some::<String>("auto leaves the toggle to per-session state".into()),
8094 );
8095 grouped_field.value = match snapshot.grouped_default {
8096 None => "auto".into(),
8097 Some(true) => "true".into(),
8098 Some(false) => "false".into(),
8099 };
8100 fields.push(grouped_field);
8101
8102 let mut notify_field = FormField::select(
8103 "notify_bell",
8104 "Bell on new Red",
8105 bool_select,
8106 Some::<String>("ring BEL when an env transitions into Red".into()),
8107 );
8108 notify_field.value = if snapshot.notify_bell {
8109 "true".into()
8110 } else {
8111 "false".into()
8112 };
8113 fields.push(notify_field);
8114
8115 let mut tags_field = FormField::text(
8116 "required_tags",
8117 "Required tags",
8118 Some::<String>("comma-separated; surfaced in :report".into()),
8119 );
8120 tags_field.value = snapshot.required_tags.join(",");
8121 fields.push(tags_field);
8122
8123 let mut regions_field = FormField::text(
8124 "extra_regions",
8125 "Extra regions",
8126 Some::<String>("comma-separated; appended to :region picker".into()),
8127 );
8128 regions_field.value = snapshot.extra_regions.join(",");
8129 fields.push(regions_field);
8130
8131 let form = Form::loading(
8132 "settings",
8133 String::new(),
8134 "settings".to_string(),
8135 fields,
8136 FormSubmit::LocalConfig,
8137 );
8138 self.open_form(form);
8139 }
8140
8141 fn current_config_snapshot(&self) -> Config {
8145 Config {
8146 refresh_interval: self.refresh_interval,
8147 extra_regions: self.extra_regions.clone(),
8148 redact_default: Some(self.redact),
8149 grouped_default: Some(self.grouped),
8150 theme: self.base_theme_name.clone(),
8154 icons: self.cfg_icons_raw.clone(),
8155 notify_bell: self.notify_bell,
8156 required_tags: self.required_tags.clone(),
8157 profile_themes: self.profile_themes.clone(),
8158 accounts: self.accounts.clone(),
8163 runbooks: self.runbooks.clone(),
8164 safety_envs: self.safety_envs.clone(),
8165 safety_accounts: self.safety_accounts.clone(),
8166 notify_webhook: self.notify_webhook.clone(),
8167 command_aliases: self.command_aliases.clone(),
8168 lint_disable: self.lint_disable.clone(),
8169 lint_fix_disable: crate::config::load_lint_fix_disables(),
8175 explain_enabled: self.explain_enabled,
8176 explain_provider: self.explain_provider.clone(),
8177 explain_model: self.explain_model.clone(),
8178 explain_api_key_env: self.explain_api_key_env.clone(),
8179 explain_ollama_url: self.explain_ollama_url.clone(),
8180 explain_max_tokens: self.explain_max_tokens,
8181 }
8182 }
8183
8184 pub fn is_read_only_for(&self, env_name: &str) -> bool {
8196 if self.read_only {
8197 return true;
8198 }
8199 if self.deploy_freeze.is_some() {
8205 return true;
8206 }
8207 if self.safety_envs.get(env_name).copied().unwrap_or(false) {
8208 return true;
8209 }
8210 if let Some(profile) = self.context.profile.as_deref() {
8211 if self.safety_accounts.get(profile).copied().unwrap_or(false) {
8212 return true;
8213 }
8214 }
8215 false
8216 }
8217
8218 pub fn deny_write(&mut self, env_name: &str, verb: &str) -> bool {
8231 if !self.is_read_only_for(env_name) {
8232 return false;
8233 }
8234 let reason = self
8235 .read_only_reason(env_name)
8236 .unwrap_or_else(|| "read-only mode".into());
8237 self.error_message = Some(format!("{reason} — {verb} disabled"));
8238 true
8239 }
8240
8241 pub fn read_only_reason(&self, env_name: &str) -> Option<String> {
8247 if self.read_only {
8248 return Some("read-only mode (global toggle)".into());
8249 }
8250 if let Some(freeze) = self.deploy_freeze.as_ref() {
8251 let age = (chrono::Utc::now() - freeze.frozen_at).num_seconds().max(0);
8252 let age = crate::app::humanize_short_age(std::time::Duration::from_secs(age as u64));
8253 return Some(if freeze.reason.is_empty() {
8254 format!("deploys frozen ({age} ago) — :thaw-deploys to unfreeze")
8255 } else {
8256 format!(
8257 "deploys frozen ({age} ago): {} — :thaw-deploys to unfreeze",
8258 freeze.reason
8259 )
8260 });
8261 }
8262 if self.safety_envs.get(env_name).copied().unwrap_or(false) {
8263 return Some(format!(
8264 "read-only mode (env pinned via safety.envs.{env_name})"
8265 ));
8266 }
8267 if let Some(profile) = self.context.profile.as_deref() {
8268 if self.safety_accounts.get(profile).copied().unwrap_or(false) {
8269 return Some(format!(
8270 "read-only mode (account pinned via safety.accounts.{profile})"
8271 ));
8272 }
8273 }
8274 None
8275 }
8276
8277 fn maybe_apply_profile_theme(&mut self) {
8283 let profile = self.context.profile.as_deref().unwrap_or("default");
8284 let target_name = self
8285 .profile_themes
8286 .get(profile)
8287 .cloned()
8288 .unwrap_or_else(|| self.base_theme_name.clone());
8289 if self.theme.name == target_name {
8291 return;
8292 }
8293 let (mut t, warning) = Theme::resolve(&target_name);
8294 if let Some(w) = warning {
8295 tracing::warn!("{w}");
8296 }
8297 t.icons = self.theme.icons;
8301 self.theme = Arc::new(t);
8302 self.cached_app_colors.clear();
8305 }
8306
8307 fn apply_config_live(&mut self, cfg: &Config) {
8311 let (mut t, warning) = Theme::resolve(&cfg.theme);
8315 if let Some(w) = warning {
8316 tracing::warn!("{w}");
8317 }
8318 let icons_raw = cfg.icons.clone();
8323 let resolved_icons = if icons_raw.eq_ignore_ascii_case("auto") {
8324 self.theme.icons
8327 } else {
8328 match icons_raw.trim().to_ascii_lowercase().as_str() {
8329 "ascii" => IconStyle::Ascii,
8330 "powerline" | "nerd" | "nerdfont" => IconStyle::Powerline,
8331 _ => IconStyle::Unicode,
8332 }
8333 };
8334 t.icons = resolved_icons;
8335 self.theme = Arc::new(t);
8336 self.cfg_icons_raw = icons_raw;
8337 self.refresh_interval = cfg.refresh_interval;
8340 self.extra_regions = cfg.extra_regions.clone();
8346 self.notify_bell = cfg.notify_bell;
8347 self.required_tags = cfg.required_tags.clone();
8348 self.rebuild_view();
8352 }
8353
8354 fn handle_action_key(&mut self, key: KeyEvent) {
8355 let Some(flow) = self.action_flow.as_mut() else {
8356 self.mode = Mode::Normal;
8357 return;
8358 };
8359 match flow {
8360 ActionFlow::Menu { list_state } => match key.code {
8361 KeyCode::Esc | KeyCode::Char('q') => self.close_action_flow(),
8365 KeyCode::Char('j') | KeyCode::Down => {
8366 let cur = list_state.selected().unwrap_or(0);
8367 let next = (cur + 1) % ACTIONS.len();
8368 list_state.select(Some(next));
8369 }
8370 KeyCode::Char('k') | KeyCode::Up => {
8371 let cur = list_state.selected().unwrap_or(0);
8372 let next = (cur + ACTIONS.len() - 1) % ACTIONS.len();
8373 list_state.select(Some(next));
8374 }
8375 KeyCode::Enter => {
8376 let Some(idx) = list_state.selected() else {
8377 return;
8378 };
8379 let action = ACTIONS[idx];
8380 self.advance_action_flow(action);
8381 }
8382 _ => {}
8383 },
8384 ActionFlow::SwapTarget { picker, .. } => match key.code {
8385 KeyCode::Esc => self.close_action_flow(),
8386 KeyCode::Down | KeyCode::Char('j')
8387 if !key.modifiers.contains(KeyModifiers::CONTROL) =>
8388 {
8389 picker.move_selection(1);
8390 }
8391 KeyCode::Up | KeyCode::Char('k')
8392 if !key.modifiers.contains(KeyModifiers::CONTROL) =>
8393 {
8394 picker.move_selection(-1);
8395 }
8396 KeyCode::Backspace => {
8397 picker.filter.pop();
8398 }
8399 KeyCode::Enter => {
8400 let Some(target) = picker.selected_value() else {
8401 return;
8402 };
8403 let source = match flow {
8404 ActionFlow::SwapTarget { source, .. } => source.clone(),
8405 _ => return,
8406 };
8407 let warning = self
8408 .environments
8409 .iter()
8410 .find(|e| e.name == source)
8411 .map(compute_traffic_warning)
8412 .unwrap_or(None);
8413 self.action_flow = Some(ActionFlow::Confirm(ConfirmModal {
8414 action: Action::SwapCnames,
8415 target_env: source,
8416 swap_with: Some(target),
8417 typed: String::new(),
8418 kind: ConfirmKind::YesNo,
8419 dryrun: None,
8420 loading_dryrun: false,
8421 recent_events: None,
8422 loading_events: false,
8423 traffic_warning: warning,
8424 deploy_version: None,
8425 upgrade_platform_arn: None,
8426 upgrade_platform_label: None,
8427 clone_target: None,
8428 scale_min: None,
8429 scale_max: None,
8430 auto_rollback_secs: None,
8431 wait_for_green_secs: None,
8432 version_preview: None,
8433 loading_version_preview: false,
8434 health_check_probe: None,
8435 loading_health_check: false,
8436 unavailability_line: None,
8437 loading_unavailability: false,
8438 lint_issues: None,
8439 loading_lint: false,
8440 }));
8441 }
8442 KeyCode::Char(c) if is_text_input(&key) => {
8443 picker.filter.push(c);
8444 let filt = picker.filtered();
8445 if !filt
8446 .iter()
8447 .any(|i| Some(*i) == picker.list_state.selected())
8448 {
8449 picker.list_state.select(filt.first().copied());
8450 }
8451 }
8452 _ => {}
8453 },
8454 ActionFlow::Confirm(modal) => match (key.code, modal.kind) {
8455 (KeyCode::Esc, _) => self.close_action_flow(),
8456 (KeyCode::Char('q'), ConfirmKind::YesNo) => self.close_action_flow(),
8460 (KeyCode::Char('y'), ConfirmKind::YesNo) | (KeyCode::Enter, ConfirmKind::YesNo) => {
8461 let m = modal.clone();
8466 self.close_action_flow();
8467 self.queue_action_dispatch(m);
8468 }
8469 (KeyCode::Char('n'), ConfirmKind::YesNo) => self.close_action_flow(),
8470 (KeyCode::Enter, ConfirmKind::TypeName) if modal.typed == modal.target_env => {
8471 let m = modal.clone();
8476 self.close_action_flow();
8477 self.queue_action_dispatch(m);
8478 }
8479 (KeyCode::Backspace, ConfirmKind::TypeName) => {
8480 modal.typed.pop();
8481 }
8482 (KeyCode::Char(c), ConfirmKind::TypeName) if is_text_input(&key) => {
8483 modal.typed.push(c);
8484 }
8485 _ => {}
8486 },
8487 ActionFlow::Rollout(flow) => match (key.code, &flow.state) {
8488 (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => self.close_action_flow(),
8496 (KeyCode::Char('y'), crate::mode_action::RolloutState::AwaitingConfirm)
8500 | (KeyCode::Enter, crate::mode_action::RolloutState::AwaitingConfirm) => {
8501 let any_ok = flow.regions.iter().any(|r| r.env_found == Some(true));
8504 if !any_ok {
8505 self.error_message = Some(
8506 "rollout: no regions passed pre-flight — fix or `esc` to abort".into(),
8507 );
8508 return;
8509 }
8510 let Some((first_idx, _)) = flow
8515 .regions
8516 .iter()
8517 .enumerate()
8518 .find(|(_, r)| r.env_found == Some(true))
8519 else {
8520 return;
8521 };
8522 flow.state = crate::mode_action::RolloutState::Dispatching {
8523 next_index: first_idx,
8524 };
8525 let region = flow.regions[first_idx].region.clone();
8526 let env_name = flow.env_name.clone();
8527 let version_label = flow.version_label.clone();
8528 let wait_for_green_secs = flow.wait_for_green_secs;
8529 let profile = self.context.profile.clone();
8530 self.spawn_rollout_dispatch(
8531 profile,
8532 region,
8533 env_name,
8534 version_label,
8535 wait_for_green_secs,
8536 );
8537 }
8538 (KeyCode::Char('n'), crate::mode_action::RolloutState::AwaitingConfirm) => {
8542 self.close_action_flow();
8543 }
8544 _ => {}
8545 },
8546 }
8547 }
8548
8549 fn advance_action_flow(&mut self, action: Action) {
8550 let Some(env) = self.target_env_for_action() else {
8551 self.close_action_flow();
8552 return;
8553 };
8554 match action {
8555 Action::SwapCnames => {
8556 let candidates: Vec<String> = self
8558 .environments
8559 .iter()
8560 .filter(|e| e.application == env.application && e.name != env.name)
8561 .map(|e| e.name.clone())
8562 .collect();
8563 if candidates.is_empty() {
8564 self.action_flow = None;
8565 self.mode = if self.detail.is_some() {
8566 Mode::Detail
8567 } else {
8568 Mode::Normal
8569 };
8570 self.error_message = Some(format!(
8571 "no swap candidates: app '{}' has only one env",
8572 env.application
8573 ));
8574 return;
8575 }
8576 let picker = Picker::new(PickerKind::Region, candidates, None); self.action_flow = Some(ActionFlow::SwapTarget {
8578 source: env.name.clone(),
8579 picker,
8580 });
8581 }
8582 Action::Terminate => {
8583 let wants_preflight = action.wants_preflight();
8588 self.action_flow = Some(ActionFlow::Confirm(ConfirmModal {
8589 action,
8590 target_env: env.name.clone(),
8591 swap_with: None,
8592 typed: String::new(),
8593 kind: ConfirmKind::TypeName,
8594 dryrun: None,
8595 loading_dryrun: wants_preflight,
8596 recent_events: None,
8597 loading_events: wants_preflight,
8598 traffic_warning: compute_traffic_warning(&env),
8599 deploy_version: None,
8600 upgrade_platform_arn: None,
8601 upgrade_platform_label: None,
8602 clone_target: None,
8603 scale_min: None,
8604 scale_max: None,
8605 auto_rollback_secs: None,
8606 wait_for_green_secs: None,
8607 version_preview: None,
8608 loading_version_preview: false,
8609 health_check_probe: None,
8610 loading_health_check: false,
8611 unavailability_line: None,
8612 loading_unavailability: false,
8613 lint_issues: None,
8614 loading_lint: false,
8615 }));
8616 if wants_preflight {
8617 self.spawn_dry_run(env.name.clone());
8618 self.spawn_preflight_events(env.name.clone());
8619 }
8620 }
8621 Action::Rebuild => {
8622 self.open_parameterised_action(action, ParameterisedAction::default());
8623 }
8624 Action::Deploy => {
8630 self.close_action_flow();
8631 self.mode = Mode::Command;
8632 self.command_input = "deploy ".into();
8633 self.status_message = Some("type a version label and press enter".into());
8634 }
8635 Action::UpgradePlatform => {
8636 self.close_action_flow();
8637 self.spawn_list_compatible_platforms(env.name.clone());
8638 self.mode = Mode::Command;
8639 self.command_input = "upgrade ".into();
8640 self.status_message =
8641 Some("listing platforms in overlay; paste an ARN and press enter".into());
8642 }
8643 Action::Clone => {
8644 self.close_action_flow();
8645 self.mode = Mode::Command;
8646 self.command_input = "clone ".into();
8647 self.status_message = Some("type a new env name and press enter".into());
8648 }
8649 Action::Scale => {
8650 self.close_action_flow();
8651 self.mode = Mode::Command;
8652 self.command_input = "scale ".into();
8653 self.status_message = Some(
8654 "scale N (instances), or `scale min N` / `scale max N`; enter to apply".into(),
8655 );
8656 }
8657 Action::Capacity => {
8658 self.close_action_flow();
8663 self.cmd_capacity();
8664 }
8665 Action::AbortUpdate => {
8666 self.action_flow = Some(ActionFlow::Confirm(ConfirmModal {
8667 action,
8668 target_env: env.name.clone(),
8669 swap_with: None,
8670 typed: String::new(),
8671 kind: ConfirmKind::YesNo,
8672 dryrun: None,
8673 loading_dryrun: false,
8674 recent_events: None,
8675 loading_events: false,
8676 traffic_warning: compute_traffic_warning(&env),
8677 deploy_version: None,
8678 upgrade_platform_arn: None,
8679 upgrade_platform_label: None,
8680 clone_target: None,
8681 scale_min: None,
8682 scale_max: None,
8683 auto_rollback_secs: None,
8684 wait_for_green_secs: None,
8685 version_preview: None,
8686 loading_version_preview: false,
8687 health_check_probe: None,
8688 loading_health_check: false,
8689 unavailability_line: None,
8690 loading_unavailability: false,
8691 lint_issues: None,
8692 loading_lint: false,
8693 }));
8694 }
8695 _ => {
8696 self.action_flow = Some(ActionFlow::Confirm(ConfirmModal {
8697 action,
8698 target_env: env.name.clone(),
8699 swap_with: None,
8700 typed: String::new(),
8701 kind: ConfirmKind::YesNo,
8702 dryrun: None,
8703 loading_dryrun: false,
8704 recent_events: None,
8705 loading_events: false,
8706 traffic_warning: compute_traffic_warning(&env),
8707 deploy_version: None,
8708 upgrade_platform_arn: None,
8709 upgrade_platform_label: None,
8710 clone_target: None,
8711 scale_min: None,
8712 scale_max: None,
8713 auto_rollback_secs: None,
8714 wait_for_green_secs: None,
8715 version_preview: None,
8716 loading_version_preview: false,
8717 health_check_probe: None,
8718 loading_health_check: false,
8719 unavailability_line: None,
8720 loading_unavailability: false,
8721 lint_issues: None,
8722 loading_lint: false,
8723 }));
8724 }
8725 }
8726 }
8727
8728 fn handle_log_tail_key(&mut self, key: KeyEvent) {
8733 if matches!(key.code, KeyCode::Tab)
8737 && !matches!(
8738 self.current_overlay.as_ref(),
8739 Some(Overlay::LogTail {
8740 filter_active: true,
8741 ..
8742 })
8743 )
8744 {
8745 self.open_log_group_picker();
8746 return;
8747 }
8748 {
8750 let Some(Overlay::LogTail {
8751 filter_active,
8752 filter_input,
8753 filter_pattern,
8754 ..
8755 }) = self.current_overlay.as_mut()
8756 else {
8757 return;
8758 };
8759 if *filter_active {
8760 match key.code {
8761 KeyCode::Esc => {
8762 *filter_active = false;
8763 filter_input.clear();
8764 *filter_pattern = None;
8765 return;
8766 }
8767 KeyCode::Enter => {
8768 *filter_active = false;
8769 if filter_input.is_empty() {
8770 *filter_pattern = None;
8771 } else {
8772 match regex::RegexBuilder::new(filter_input)
8773 .case_insensitive(true)
8774 .build()
8775 {
8776 Ok(re) => *filter_pattern = Some(re),
8777 Err(_) => *filter_pattern = None,
8778 }
8779 }
8780 return;
8781 }
8782 KeyCode::Backspace => {
8783 filter_input.pop();
8784 return;
8785 }
8786 KeyCode::Char(c) if is_text_input(&key) => {
8787 filter_input.push(c);
8788 return;
8789 }
8790 _ => return,
8791 }
8792 }
8793 }
8794 let Some(Overlay::LogTail {
8795 scroll,
8796 following,
8797 filter_active,
8798 filter_input,
8799 filter_pattern,
8800 ..
8801 }) = self.current_overlay.as_mut()
8802 else {
8803 return;
8804 };
8805 match key.code {
8806 KeyCode::Esc | KeyCode::Char('q') => {
8807 if let Some(handle) = self.log_tail_task.take() {
8808 handle.abort();
8809 }
8810 self.log_tail_session = self.log_tail_session.wrapping_add(1);
8814 self.current_overlay = None;
8815 }
8816 KeyCode::Char('j') | KeyCode::Down => {
8817 if *scroll > 0 {
8818 *scroll -= 1;
8819 }
8820 if *scroll == 0 {
8821 *following = true;
8822 }
8823 }
8824 KeyCode::Char('k') | KeyCode::Up => {
8825 *scroll = scroll.saturating_add(1);
8826 *following = false;
8827 }
8828 KeyCode::Char('G') | KeyCode::End => {
8829 *scroll = 0;
8830 *following = true;
8831 }
8832 KeyCode::Char('g') | KeyCode::Home => {
8833 *scroll = u16::MAX;
8834 *following = false;
8835 }
8836 KeyCode::Char('/') => {
8837 *filter_active = true;
8838 filter_input.clear();
8839 *filter_pattern = None;
8840 }
8841 KeyCode::Char('n') => {
8842 filter_input.clear();
8843 *filter_pattern = None;
8844 }
8845 _ => {}
8846 }
8847 }
8848
8849 fn open_log_group_picker(&mut self) {
8854 let Some(Overlay::LogTail { log_group, .. }) = self.current_overlay.as_ref() else {
8855 return;
8856 };
8857 let current_group = log_group.clone();
8858 let groups: Vec<String> = self
8859 .detail
8860 .as_ref()
8861 .and_then(|d| d.cw_log_groups.clone())
8862 .unwrap_or_default();
8863 if groups.is_empty() {
8864 self.status_message = Some(
8865 "no CW log groups discovered for this env — try `:logs-tail <full-group-name>`"
8866 .into(),
8867 );
8868 return;
8869 }
8870 self.picker = Some(Picker::new(
8871 PickerKind::LogGroup,
8872 groups,
8873 Some(current_group.as_str()),
8874 ));
8875 self.mode = Mode::Picker;
8876 }
8877
8878 fn spawn_config_apply_template(&mut self, env_name: String, template: String) {
8885 if self.deny_write(&env_name, "config-apply") {
8886 return;
8887 }
8888 let aws = self.aws.clone();
8889 let tx = self.msg_tx.clone();
8890 let gen = self.generation;
8891 write_audit_line(
8893 self.context.account_id.as_deref(),
8894 self.context.profile.as_deref(),
8895 &self.context.region,
8896 &format!("stage=dispatched action=ConfigApply target={env_name} template={template}"),
8897 );
8898 self.push_pending(Action::ConfigApply.label(), env_name.clone());
8899 let env_for_msg = env_name.clone();
8900 tokio::spawn(async move {
8901 let result = aws
8902 .apply_config_template(&env_for_msg, &template)
8903 .await
8904 .map_err(|e| flatten_err("apply_config_template", e));
8905 let _ = tx.send(AppMsg::ActionResult {
8906 gen,
8907 action: Action::ConfigApply,
8908 env_name: env_for_msg,
8909 result,
8910 });
8911 });
8912 }
8913
8914 fn spawn_config_delete_template(&mut self, app_name: String, template: String) {
8918 if self.deny_write("", "config-delete") {
8924 return;
8925 }
8926 let aws = self.aws.clone();
8927 let tx = self.msg_tx.clone();
8928 let gen = self.generation;
8929 let target = format!("{app_name}/{template}");
8930 self.status_message = Some(format!(
8931 "deleting template '{template}' from app '{app_name}'…"
8932 ));
8933 write_audit_line(
8934 self.context.account_id.as_deref(),
8935 self.context.profile.as_deref(),
8936 &self.context.region,
8937 &format!("stage=dispatched action=ConfigDelete target={target}"),
8938 );
8939 self.push_pending(Action::ConfigDelete.label(), target.clone());
8940 let template_for_msg = template.clone();
8941 tokio::spawn(async move {
8942 let result = aws
8943 .delete_config_template(&app_name, &template)
8944 .await
8945 .map_err(|e| flatten_err("delete_config_template", e))
8946 .map_err(|e| format!("config-delete '{template_for_msg}': {e}"));
8947 let _ = tx.send(AppMsg::ActionResult {
8948 gen,
8949 action: Action::ConfigDelete,
8950 env_name: target,
8951 result,
8952 });
8953 });
8954 }
8955
8956 fn spawn_config_inspect_template(&mut self, app_name: String, template: String) {
8960 let aws = self.aws.clone();
8961 let tx = self.msg_tx.clone();
8962 let gen = self.generation;
8963 let title = format!("template — {app_name}/{template}");
8964 tokio::spawn(async move {
8966 let body = match aws.describe_template_settings(&app_name, &template).await {
8967 Ok(settings) if settings.is_empty() => {
8968 "(template has no option settings)".to_string()
8969 }
8970 Ok(settings) => format_template_settings(&settings),
8971 Err(e) => format!("error: {}", flatten_err("describe_template_settings", e)),
8972 };
8973 let _ = tx.send(AppMsg::TextOverlay { gen, title, body });
8974 });
8975 }
8976
8977 fn spawn_logs_tail(&mut self, env_name: String, explicit_group: Option<String>) {
8985 if let Some(handle) = self.log_tail_task.take() {
8987 handle.abort();
8988 }
8989 self.log_tail_session = self.log_tail_session.wrapping_add(1);
8990 let session_id = self.log_tail_session;
8991 let aws = self.aws.clone();
8992 let tx = self.msg_tx.clone();
8993 let gen = self.generation;
8994 let env_for_msg = env_name.clone();
8995 let handle = tokio::spawn(async move {
8997 let group = match explicit_group {
9000 Some(g) => g,
9001 None => match aws.discover_env_log_groups(&env_for_msg).await {
9002 Ok(groups) => match pick_default_log_group(&groups) {
9003 Some(g) => g,
9004 None => {
9005 let _ = tx.send(AppMsg::LogTailEvents {
9006 gen,
9007 session_id,
9008 next_since_ms: 0,
9009 result: Err(format!(
9010 "no CW log groups under /aws/elasticbeanstalk/{env_for_msg}/ — enable streaming with `:logs-stream on`"
9011 )),
9012 });
9013 return;
9014 }
9015 },
9016 Err(e) => {
9017 let _ = tx.send(AppMsg::LogTailEvents {
9018 gen,
9019 session_id,
9020 next_since_ms: 0,
9021 result: Err(format!("discover log groups: {e}")),
9022 });
9023 return;
9024 }
9025 },
9026 };
9027 let mut since_ms = chrono::Utc::now().timestamp_millis() - 5 * 60 * 1000;
9030 let _ = tx.send(AppMsg::LogTailOpened {
9033 gen,
9034 session_id,
9035 env_name: env_for_msg.clone(),
9036 log_group: group.clone(),
9037 since_ms,
9038 });
9039 loop {
9040 match aws.fetch_recent_log_events(&group, since_ms, 1000).await {
9041 Ok((events, next_since)) => {
9042 let next_since_ms = next_since;
9043 let _ = tx.send(AppMsg::LogTailEvents {
9044 gen,
9045 session_id,
9046 next_since_ms,
9047 result: Ok(events),
9048 });
9049 since_ms = next_since;
9050 }
9051 Err(e) => {
9052 let _ = tx.send(AppMsg::LogTailEvents {
9053 gen,
9054 session_id,
9055 next_since_ms: since_ms,
9056 result: Err(format!("{e}")),
9057 });
9058 }
9061 }
9062 tokio::time::sleep(std::time::Duration::from_secs(2)).await;
9063 }
9064 });
9065 self.log_tail_task = Some(handle);
9066 }
9067
9068 pub(crate) fn spawn_option_settings_update(
9074 &mut self,
9075 summary: String,
9076 to_set: Vec<(String, String, String)>,
9077 to_remove: Vec<(String, String)>,
9078 ) {
9079 let Some(env) = self.selected_env().cloned() else {
9080 self.error_message = Some("no env selected".into());
9081 return;
9082 };
9083 if self.deny_write(&env.name, &summary) {
9084 return;
9085 }
9086 if to_set.is_empty() && to_remove.is_empty() {
9087 self.error_message = Some(format!(
9088 "{summary}: nothing to do (no options to set or remove)"
9089 ));
9090 return;
9091 }
9092 let env_name = env.name.clone();
9093 write_audit_line(
9094 self.context.account_id.as_deref(),
9095 self.context.profile.as_deref(),
9096 &self.context.region,
9097 &format!(
9098 "stage=dispatched action=UpdateOptionSettings target={env_name} summary=\"{summary}\""
9099 ),
9100 );
9101 self.push_pending(summary.clone(), env_name.clone());
9102 let aws = self.aws.clone();
9107 let tx = self.msg_tx.clone();
9108 let gen = self.generation;
9109 let env_for_msg = env_name.clone();
9110 let summary_for_msg = summary.clone();
9111 let account = self.context.account_id.clone();
9112 let profile = self.context.profile.clone();
9113 let region = self.context.region.clone();
9114 let app_for_undo = env.application.clone();
9118 let env_for_undo = env_name.clone();
9119 let summary_for_undo = summary.clone();
9120 let to_set_for_undo = to_set.clone();
9121 let to_remove_for_undo = to_remove.clone();
9122 tokio::spawn(async move {
9123 let undo_entry = match aws
9128 .fetch_env_option_settings(&app_for_undo, &env_for_undo)
9129 .await
9130 {
9131 Ok(opts) => Some(build_undo_entry(
9132 &env_for_undo,
9133 &summary_for_undo,
9134 &to_set_for_undo,
9135 &to_remove_for_undo,
9136 &opts,
9137 )),
9138 Err(_) => None,
9139 };
9140 let result = aws
9141 .update_env_option_settings(&env_for_msg, &to_set, &to_remove)
9142 .await
9143 .map_err(|e| flatten_err("update_env_option_settings", e));
9144 let outcome = match &result {
9145 Ok(()) => format!(
9146 "stage=completed action=UpdateOptionSettings target={env_for_msg} summary=\"{summary_for_msg}\" outcome=ok"
9147 ),
9148 Err(e) => format!(
9149 "stage=completed action=UpdateOptionSettings target={env_for_msg} summary=\"{summary_for_msg}\" outcome=err err=\"{}\"",
9150 crate::audit::escape_value(e)
9151 ),
9152 };
9153 write_audit_line(account.as_deref(), profile.as_deref(), ®ion, &outcome);
9154 if result.is_ok() {
9157 if let Some(entry) = undo_entry {
9158 let _ = tx.send(AppMsg::UndoCaptured { gen, entry });
9159 }
9160 }
9161 let _ = tx.send(AppMsg::OptionSettingsUpdate {
9162 gen,
9163 env_name: env_for_msg,
9164 summary: summary_for_msg,
9165 result,
9166 });
9167 });
9168 }
9169
9170 fn spawn_deploy_from_s3(
9176 &mut self,
9177 bucket: String,
9178 key: String,
9179 explicit_label: Option<String>,
9180 description: Option<String>,
9181 and_deploy: bool,
9182 ) {
9183 let Some(env) = self.selected_env().cloned() else {
9184 self.error_message = Some("no env selected".into());
9185 return;
9186 };
9187 if self.deny_write(&env.name, "deploy-from-s3") {
9188 return;
9189 }
9190 let label = explicit_label
9194 .unwrap_or_else(|| derive_version_label(&key, chrono::Utc::now().timestamp()));
9195 let env_name = env.name.clone();
9196 let app_name = env.application.clone();
9197 let summary = if and_deploy {
9198 format!("deploy-from-s3 {label}")
9199 } else {
9200 format!("create-version-from-s3 {label}")
9201 };
9202 write_audit_line(
9203 self.context.account_id.as_deref(),
9204 self.context.profile.as_deref(),
9205 &self.context.region,
9206 &format!(
9207 "stage=dispatched action=DeployFromS3 target={env_name} label={label} source=s3://{bucket}/{key} and_deploy={and_deploy}"
9208 ),
9209 );
9210 self.push_pending(summary.clone(), env_name.clone());
9211 let aws = self.aws.clone();
9213 let tx = self.msg_tx.clone();
9214 let gen = self.generation;
9215 let env_for_msg = env_name.clone();
9216 let label_for_msg = label.clone();
9217 let summary_for_msg = summary.clone();
9218 let account = self.context.account_id.clone();
9219 let profile = self.context.profile.clone();
9220 let region = self.context.region.clone();
9221 let description_owned = description;
9222 tokio::spawn(async move {
9223 if let Err(e) = aws
9224 .create_app_version(
9225 &app_name,
9226 &label_for_msg,
9227 description_owned.as_deref(),
9228 &bucket,
9229 &key,
9230 )
9231 .await
9232 {
9233 let err = format!("create-version: {}", flatten_err("create_app_version", e));
9234 finish_deploy_from_local(
9235 &tx,
9236 gen,
9237 env_for_msg,
9238 label_for_msg,
9239 summary_for_msg,
9240 account.as_deref(),
9241 profile.as_deref(),
9242 ®ion,
9243 Err(err),
9244 );
9245 return;
9246 }
9247 if and_deploy {
9248 if let Err(e) = aws.deploy_version(&env_for_msg, &label_for_msg).await {
9249 let err = format!("deploy: {}", flatten_err("deploy_version", e));
9250 finish_deploy_from_local(
9251 &tx,
9252 gen,
9253 env_for_msg,
9254 label_for_msg,
9255 summary_for_msg,
9256 account.as_deref(),
9257 profile.as_deref(),
9258 ®ion,
9259 Err(err),
9260 );
9261 return;
9262 }
9263 }
9264 finish_deploy_from_local(
9265 &tx,
9266 gen,
9267 env_for_msg,
9268 label_for_msg,
9269 summary_for_msg,
9270 account.as_deref(),
9271 profile.as_deref(),
9272 ®ion,
9273 Ok(()),
9274 );
9275 });
9276 }
9277
9278 fn spawn_deploy_preview(&self, env: crate::aws::Environment, label: String) {
9291 let aws = self.aws.clone();
9292 let tx = self.msg_tx.clone();
9293 let gen = self.generation;
9294 let app_name = env.application.clone();
9295 let env_name = env.name.clone();
9296 let current_label = env.version_label.clone();
9297 tokio::spawn(async move {
9298 let body = match aws.list_application_versions(&app_name).await {
9299 Ok(versions) => format_deploy_preview(&env_name, ¤t_label, &label, &versions),
9300 Err(e) => format!(
9301 "deploy preview — failed to fetch application versions:\n {}\n",
9302 flatten_err_to_string(&e)
9303 ),
9304 };
9305 let _ = tx.send(AppMsg::TextOverlay {
9306 gen,
9307 title: format!("deploy preview — {env_name} ← {label}"),
9308 body,
9309 });
9310 });
9311 }
9312
9313 fn spawn_deploy_from_local(
9314 &mut self,
9315 path: String,
9316 explicit_label: Option<String>,
9317 description: Option<String>,
9318 and_deploy: bool,
9319 ) {
9320 let Some(env) = self.selected_env().cloned() else {
9321 self.error_message = Some("no env selected".into());
9322 return;
9323 };
9324 if self.is_read_only_for(&env.name) {
9325 let reason = self
9326 .read_only_reason(&env.name)
9327 .unwrap_or_else(|| "read-only mode".into());
9328 self.error_message = Some(format!("{reason} — deploy-from-local disabled"));
9329 return;
9330 }
9331 let resolved = expand_tilde(&path);
9336 let resolved_path = std::path::PathBuf::from(&resolved);
9337 let size = match std::fs::metadata(&resolved_path) {
9338 Ok(m) => m.len(),
9339 Err(e) => {
9340 self.error_message = Some(format!("can't read {resolved}: {e}"));
9341 return;
9342 }
9343 };
9344 if size == 0 {
9345 self.error_message = Some(format!("{resolved} is empty"));
9346 return;
9347 }
9348 let label = explicit_label
9351 .unwrap_or_else(|| derive_version_label(&resolved, chrono::Utc::now().timestamp()));
9352 let env_name = env.name.clone();
9353 let app_name = env.application.clone();
9354 let summary = if and_deploy {
9355 format!("deploy-from-local {label}")
9356 } else {
9357 format!("upload-version {label}")
9358 };
9359 write_audit_line(
9360 self.context.account_id.as_deref(),
9361 self.context.profile.as_deref(),
9362 &self.context.region,
9363 &format!(
9364 "stage=dispatched action=DeployFromLocal target={env_name} label={label} bytes={size} and_deploy={and_deploy}"
9365 ),
9366 );
9367 self.push_pending(summary.clone(), env_name.clone());
9368 let aws = self.aws.clone();
9369 let tx = self.msg_tx.clone();
9370 let gen = self.generation;
9371 let env_for_msg = env_name.clone();
9372 let label_for_msg = label.clone();
9373 let summary_for_msg = summary.clone();
9374 let account = self.context.account_id.clone();
9375 let profile = self.context.profile.clone();
9376 let region = self.context.region.clone();
9377 let description_owned = description;
9378 tokio::spawn(async move {
9379 let bucket = match aws.create_storage_location().await {
9383 Ok(b) => b,
9384 Err(e) => {
9385 let err = format!(
9386 "storage-location: {}",
9387 flatten_err("create_storage_location", e)
9388 );
9389 finish_deploy_from_local(
9390 &tx,
9391 gen,
9392 env_for_msg,
9393 label_for_msg,
9394 summary_for_msg,
9395 account.as_deref(),
9396 profile.as_deref(),
9397 ®ion,
9398 Err(err),
9399 );
9400 return;
9401 }
9402 };
9403 let key = format!("applications/{app_name}/{label_for_msg}");
9405 if let Err(e) = aws.upload_bundle(&bucket, &key, &resolved_path).await {
9406 let err = format!("s3-put: {}", flatten_err("upload_bundle", e));
9407 finish_deploy_from_local(
9408 &tx,
9409 gen,
9410 env_for_msg,
9411 label_for_msg,
9412 summary_for_msg,
9413 account.as_deref(),
9414 profile.as_deref(),
9415 ®ion,
9416 Err(err),
9417 );
9418 return;
9419 }
9420 if let Err(e) = aws
9421 .create_app_version(
9422 &app_name,
9423 &label_for_msg,
9424 description_owned.as_deref(),
9425 &bucket,
9426 &key,
9427 )
9428 .await
9429 {
9430 let err = format!("create-version: {}", flatten_err("create_app_version", e));
9431 finish_deploy_from_local(
9432 &tx,
9433 gen,
9434 env_for_msg,
9435 label_for_msg,
9436 summary_for_msg,
9437 account.as_deref(),
9438 profile.as_deref(),
9439 ®ion,
9440 Err(err),
9441 );
9442 return;
9443 }
9444 if and_deploy {
9445 if let Err(e) = aws.deploy_version(&env_for_msg, &label_for_msg).await {
9446 let err = format!("deploy: {}", flatten_err("deploy_version", e));
9447 finish_deploy_from_local(
9448 &tx,
9449 gen,
9450 env_for_msg,
9451 label_for_msg,
9452 summary_for_msg,
9453 account.as_deref(),
9454 profile.as_deref(),
9455 ®ion,
9456 Err(err),
9457 );
9458 return;
9459 }
9460 }
9461 finish_deploy_from_local(
9462 &tx,
9463 gen,
9464 env_for_msg,
9465 label_for_msg,
9466 summary_for_msg,
9467 account.as_deref(),
9468 profile.as_deref(),
9469 ®ion,
9470 Ok(()),
9471 );
9472 });
9473 }
9474
9475 fn spawn_delete_app_version(&mut self, label: String, force: bool) {
9479 let Some(env) = self.selected_env().cloned() else {
9480 self.error_message = Some("no env selected".into());
9481 return;
9482 };
9483 if self.deny_write(&env.name, "delete-version") {
9484 return;
9485 }
9486 let application = env.application.clone();
9487 let force_str = if force { " (+source bundle)" } else { "" };
9488 let detail = format!(
9489 "stage=dispatched action=DeleteAppVersion target={application}/{label}{force_str}"
9490 );
9491 write_audit_line(
9492 self.context.account_id.as_deref(),
9493 self.context.profile.as_deref(),
9494 &self.context.region,
9495 &detail,
9496 );
9497 let _ = force_str;
9499 let pending_label = if force {
9500 "Delete app version (+source)"
9501 } else {
9502 "Delete app version"
9503 };
9504 let pending_target = format!("{application}/{label}");
9505 self.push_pending(pending_label, pending_target);
9506 let aws = self.aws.clone();
9507 let tx = self.msg_tx.clone();
9508 let gen = self.generation;
9509 let account = self.context.account_id.clone();
9510 let profile = self.context.profile.clone();
9511 let region = self.context.region.clone();
9512 let app_for_msg = application.clone();
9513 let label_for_msg = label.clone();
9514 tokio::spawn(async move {
9515 let result = aws
9516 .delete_application_version(&application, &label, force)
9517 .await
9518 .map_err(|e| flatten_err("delete_application_version", e));
9519 let outcome = match &result {
9520 Ok(()) => format!(
9521 "stage=completed action=DeleteAppVersion target={application}/{label}{force_str} outcome=ok"
9522 ),
9523 Err(e) => format!(
9524 "stage=completed action=DeleteAppVersion target={application}/{label}{force_str} outcome=err err=\"{}\"",
9525 crate::audit::escape_value(e)
9526 ),
9527 };
9528 write_audit_line(account.as_deref(), profile.as_deref(), ®ion, &outcome);
9529 let _ = tx.send(AppMsg::DeleteAppVersion {
9530 gen,
9531 application: app_for_msg,
9532 label: label_for_msg,
9533 force,
9534 result,
9535 });
9536 });
9537 }
9538
9539 fn handle_saved_configs_interactive_key(&mut self, key: KeyEvent) {
9546 {
9551 let Some(Overlay::SavedConfigsInteractive {
9552 items,
9553 cursor,
9554 confirm_delete,
9555 }) = self.current_overlay.as_mut()
9556 else {
9557 return;
9558 };
9559 if items.is_empty() {
9560 self.current_overlay = None;
9561 return;
9562 }
9563 let len = items.len();
9564 if *confirm_delete {
9568 match key.code {
9569 KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
9570 *confirm_delete = false;
9571 return;
9572 }
9573 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
9574 }
9576 _ => return,
9577 }
9578 } else {
9579 match key.code {
9580 KeyCode::Char('j') | KeyCode::Down => {
9581 *cursor = (*cursor + 1).min(len.saturating_sub(1));
9582 return;
9583 }
9584 KeyCode::Char('k') | KeyCode::Up => {
9585 *cursor = cursor.saturating_sub(1);
9586 return;
9587 }
9588 KeyCode::Char('g') | KeyCode::Home => {
9589 *cursor = 0;
9590 return;
9591 }
9592 KeyCode::Char('G') | KeyCode::End => {
9593 *cursor = len.saturating_sub(1);
9594 return;
9595 }
9596 KeyCode::Char('x') => {
9597 *confirm_delete = true;
9598 return;
9599 }
9600 _ => {}
9601 }
9602 }
9603 }
9604 let Some(Overlay::SavedConfigsInteractive {
9605 items,
9606 cursor,
9607 confirm_delete,
9608 }) = self.current_overlay.as_ref()
9609 else {
9610 return;
9611 };
9612 let cursor = *cursor;
9613 let confirm_delete = *confirm_delete;
9614 let selected = items.get(cursor).cloned();
9615 match key.code {
9616 KeyCode::Esc | KeyCode::Char('q') => {
9617 self.current_overlay = None;
9618 }
9619 KeyCode::Char('a') | KeyCode::Enter if !confirm_delete => {
9620 if let Some((_app, template)) = selected {
9621 self.current_overlay = None;
9622 let Some(env) = self.selected_env().cloned() else {
9623 self.error_message = Some("no env selected".into());
9624 return;
9625 };
9626 self.spawn_config_apply_template(env.name, template);
9629 }
9630 }
9631 KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter if confirm_delete => {
9633 if let Some((app_name, template)) = selected {
9634 self.current_overlay = None;
9635 self.spawn_config_delete_template(app_name, template);
9636 }
9637 }
9638 KeyCode::Char('c') => {
9639 self.current_overlay = None;
9640 self.command_input = "config-save ".into();
9641 self.mode = Mode::Command;
9642 }
9643 KeyCode::Char('i') => {
9644 if let Some((app_name, template)) = selected {
9649 self.current_overlay = None;
9650 self.spawn_config_inspect_template(app_name, template);
9651 }
9652 }
9653 KeyCode::Char('?') => {
9654 self.help.pre_overlay = self.current_overlay.take();
9655 self.help.pre_mode = Some(self.mode);
9656 self.help.topic = HelpTopic::SavedConfigs;
9657 self.mode = Mode::Help;
9658 }
9659 _ => {}
9660 }
9661 }
9662
9663 fn spawn_tag_update(&mut self, to_add: Vec<(String, String)>, to_remove: Vec<String>) {
9667 let Some(env) = self.selected_env().cloned() else {
9668 self.error_message = Some("no env selected".into());
9669 return;
9670 };
9671 if self.deny_write(&env.name, "tag edits") {
9672 return;
9673 }
9674 let Some(arn) = env.arn.clone() else {
9675 self.error_message = Some(format!("env {} has no ARN — re-fetch and retry", env.name));
9676 return;
9677 };
9678 if to_add.is_empty() && to_remove.is_empty() {
9679 self.error_message =
9680 Some("nothing to do — provide tags to add or keys to remove".into());
9681 return;
9682 }
9683 let summary = if !to_add.is_empty() {
9684 let keys: Vec<String> = to_add.iter().map(|(k, _)| k.clone()).collect();
9685 format!("tag {}", keys.join(","))
9686 } else {
9687 format!("untag {}", to_remove.join(","))
9688 };
9689 let detail = format!(
9690 "stage=dispatched action=UpdateTags target={} {}",
9691 env.name, summary
9692 );
9693 write_audit_line(
9694 self.context.account_id.as_deref(),
9695 self.context.profile.as_deref(),
9696 &self.context.region,
9697 &detail,
9698 );
9699 self.push_pending(summary.clone(), env.name.clone());
9704 let aws = self.aws.clone();
9705 let tx = self.msg_tx.clone();
9706 let gen = self.generation;
9707 let env_name = env.name.clone();
9708 let summary_for_msg = summary.clone();
9709 let account = self.context.account_id.clone();
9710 let profile = self.context.profile.clone();
9711 let region = self.context.region.clone();
9712 tokio::spawn(async move {
9713 let result = aws
9714 .update_tags(&arn, &to_add, &to_remove)
9715 .await
9716 .map_err(|e| flatten_err("update_tags", e));
9717 let outcome_detail = match &result {
9718 Ok(()) => format!(
9719 "stage=completed action=UpdateTags target={env_name} {summary} outcome=ok"
9720 ),
9721 Err(e) => format!(
9722 "stage=completed action=UpdateTags target={env_name} {summary} outcome=err err=\"{}\"",
9723 crate::audit::escape_value(e),
9724 ),
9725 };
9726 write_audit_line(
9727 account.as_deref(),
9728 profile.as_deref(),
9729 ®ion,
9730 &outcome_detail,
9731 );
9732 let _ = tx.send(AppMsg::TagUpdate {
9733 gen,
9734 env_name,
9735 summary: summary_for_msg,
9736 result,
9737 });
9738 });
9739 }
9740
9741 fn spawn_preflight_events(&mut self, env_name: String) {
9742 let env_for_msg = env_name.clone();
9743 self.spawn_aws(
9744 "preflight_events",
9745 move |aws| async move { aws.list_events_for_env(&env_name, 3).await },
9746 move |gen, result| AppMsg::PreflightEvents {
9747 gen,
9748 env_name: env_for_msg,
9749 result,
9750 },
9751 );
9752 }
9753
9754 fn spawn_dry_run(&mut self, env_name: String) {
9755 let env_for_msg = env_name.clone();
9756 self.spawn_aws(
9757 "dry_run_list_instances",
9758 move |aws| async move { aws.list_instances(&env_name).await },
9759 move |gen, result| AppMsg::DryRunResult {
9760 gen,
9761 env_name: env_for_msg,
9762 result,
9763 },
9764 );
9765 }
9766
9767 fn spawn_version_preview(
9774 &mut self,
9775 app_name: String,
9776 env_name: String,
9777 current_label: String,
9778 candidate_label: String,
9779 ) {
9780 let env_for_msg = env_name.clone();
9781 let env_for_render = env_name.clone();
9782 let candidate_for_render = candidate_label.clone();
9783 self.spawn_aws(
9784 "version_preview",
9785 move |aws| async move { aws.list_application_versions(&app_name).await },
9786 move |gen, result| {
9787 let body = match result {
9788 Ok(versions) => Ok(format_deploy_preview(
9789 &env_for_render,
9790 ¤t_label,
9791 &candidate_for_render,
9792 &versions,
9793 )),
9794 Err(e) => Err(e),
9795 };
9796 AppMsg::VersionPreview {
9797 gen,
9798 env_name: env_for_msg,
9799 result: body,
9800 }
9801 },
9802 );
9803 }
9804
9805 pub(crate) fn spawn_rollout_preflight(
9824 &self,
9825 profile: Option<String>,
9826 region: String,
9827 env_name: String,
9828 ) {
9829 let tx = self.msg_tx.clone();
9830 let gen = self.generation;
9831 tokio::spawn(async move {
9832 let result = match crate::aws::AwsClient::with(profile, Some(region.clone())).await {
9833 Ok(client) => match client.list_environments().await {
9834 Ok(envs) => match envs.iter().find(|e| e.name == env_name) {
9835 Some(e) => Ok(e.version_label.clone()),
9836 None => Err(format!("env '{env_name}' not found in region '{region}'")),
9837 },
9838 Err(e) => Err(format!("list_environments: {e}")),
9839 },
9840 Err(e) => Err(format!("AwsClient::with({region}): {e}")),
9841 };
9842 let _ = tx.send(AppMsg::RolloutPreflight {
9843 gen,
9844 region,
9845 result,
9846 });
9847 });
9848 }
9849
9850 pub(crate) fn spawn_rollout_dispatch(
9862 &self,
9863 profile: Option<String>,
9864 region: String,
9865 env_name: String,
9866 version_label: String,
9867 wait_for_green_secs: Option<u64>,
9868 ) {
9869 let tx = self.msg_tx.clone();
9870 let gen = self.generation;
9871 tokio::spawn(async move {
9872 let client = match crate::aws::AwsClient::with(profile, Some(region.clone())).await {
9873 Ok(c) => c,
9874 Err(e) => {
9875 let _ = tx.send(AppMsg::RolloutDispatched {
9876 gen,
9877 region,
9878 result: Err(format!("client: {e}")),
9879 });
9880 return;
9881 }
9882 };
9883 if let Err(e) = client.deploy_version(&env_name, &version_label).await {
9884 let _ = tx.send(AppMsg::RolloutDispatched {
9885 gen,
9886 region,
9887 result: Err(format!("deploy_version: {e}")),
9888 });
9889 return;
9890 }
9891 if let Some(secs) = wait_for_green_secs {
9892 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(secs);
9893 loop {
9894 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
9895 if tokio::time::Instant::now() >= deadline {
9896 let _ = tx.send(AppMsg::RolloutDispatched {
9897 gen,
9898 region,
9899 result: Err(format!("did not reach Green within {secs}s")),
9900 });
9901 return;
9902 }
9903 match client.list_environments().await {
9904 Ok(envs) => {
9905 let (status, health) = envs
9906 .iter()
9907 .find(|e| e.name == env_name)
9908 .map(|e| (e.status.clone(), e.health.clone()))
9909 .unwrap_or_default();
9910 if deploy_settled_green(&status, &health) {
9911 break;
9912 }
9913 }
9914 Err(e) => {
9915 let _ = tx.send(AppMsg::RolloutDispatched {
9916 gen,
9917 region,
9918 result: Err(format!("poll list_environments: {e}")),
9919 });
9920 return;
9921 }
9922 }
9923 }
9924 }
9925 let _ = tx.send(AppMsg::RolloutDispatched {
9926 gen,
9927 region,
9928 result: Ok(()),
9929 });
9930 });
9931 }
9932
9933 fn spawn_health_check_probe(&mut self, app_name: String, env_name: String, cname: String) {
9936 let env_for_msg = env_name.clone();
9937 let aws = self.aws.clone();
9938 let tx = self.msg_tx.clone();
9939 let gen = self.generation;
9940 tokio::spawn(async move {
9941 let path = match aws.fetch_env_option_settings(&app_name, &env_name).await {
9945 Ok(opts) => opts
9946 .into_iter()
9947 .find(|(ns, name, _)| {
9948 ns == "aws:elasticbeanstalk:application"
9949 && name == "Application Healthcheck URL"
9950 })
9951 .map(|(_, _, v)| v)
9952 .filter(|s| !s.is_empty())
9953 .unwrap_or_else(|| "/".into()),
9954 Err(_) => "/".into(),
9955 };
9956 let url = build_health_check_probe_url(&cname, &path);
9957 let result = run_health_check_probe(&url).await;
9958 let _ = tx.send(AppMsg::HealthCheckProbe {
9959 gen,
9960 env_name: env_for_msg,
9961 result,
9962 });
9963 });
9964 }
9965
9966 fn spawn_unavailability_estimate(&mut self, app_name: String, env_name: String) {
9975 let env_for_msg = env_name.clone();
9976 self.spawn_aws(
9977 "unavailability_estimate",
9978 move |aws| async move { aws.fetch_env_option_settings(&app_name, &env_name).await },
9979 move |gen, result| {
9980 let line = result.ok().map(|opts| {
9981 let (policy, batch, btype, asg_max) = extract_unavailability_inputs(&opts);
9982 let count = compute_unavailability_count(&policy, batch, &btype, asg_max);
9983 format_unavailability_line(&policy, count, asg_max)
9984 });
9985 AppMsg::UnavailabilityEstimate {
9986 gen,
9987 env_name: env_for_msg,
9988 line,
9989 }
9990 },
9991 );
9992 }
9993
9994 fn spawn_confirm_lint(&mut self, env: crate::aws::Environment) {
10008 let aws = self.aws.clone();
10009 let tx = self.msg_tx.clone();
10010 let gen = self.generation;
10011 let mut disabled = self.lint_disable.clone();
10014 disabled.extend(crate::project::load_lint_disables_from_cwd());
10015 let env_for_msg = env.name.clone();
10016 let app_name = env.application.clone();
10017 let env_name = env.name.clone();
10018 tokio::spawn(async move {
10019 let issues = match aws.fetch_env_option_settings(&app_name, &env_name).await {
10020 Ok(opts) => {
10021 let ctx = crate::lint::LintContext {
10022 env: &env,
10023 options: &opts,
10024 events: &[],
10025 cost_usd_per_month: None,
10026 latest_stack_version: None,
10027 };
10028 let rules = crate::lint::default_rules(&disabled);
10029 crate::lint::run_rules(&rules, &ctx)
10030 }
10031 Err(_) => Vec::new(),
10032 };
10033 let _ = tx.send(AppMsg::ConfirmModalLint {
10034 gen,
10035 env_name: env_for_msg,
10036 issues,
10037 });
10038 });
10039 }
10040
10041 fn spawn_batch_action(&mut self, action: Action, env: String) {
10046 write_audit_entry(
10047 self.context.account_id.as_deref(),
10048 self.context.profile.as_deref(),
10049 &self.context.region,
10050 action,
10051 &env,
10052 None,
10053 );
10054 self.push_pending(action.label(), env.clone());
10055 let env_for_msg = env.clone();
10056 self.spawn_aws(
10057 "batch_action",
10058 move |aws| async move {
10059 match action {
10060 Action::Rebuild => aws.rebuild_env(&env).await,
10061 Action::RestartAppServer => aws.restart_app_server(&env).await,
10062 _ => Err(color_eyre::eyre::eyre!(
10063 "batch-mode only supports Rebuild / Restart"
10064 )),
10065 }
10066 },
10067 move |gen, result| AppMsg::ActionResult {
10068 gen,
10069 action,
10070 env_name: env_for_msg,
10071 result,
10072 },
10073 );
10074 }
10075
10076 fn spawn_batch_deploy(&mut self, env: String, label: String) {
10080 let aws = self.aws.clone();
10081 let tx = self.msg_tx.clone();
10082 let gen = self.generation;
10083 write_audit_line(
10084 self.context.account_id.as_deref(),
10085 self.context.profile.as_deref(),
10086 &self.context.region,
10087 &format!("stage=dispatched action=Deploy target={env} version={label}"),
10088 );
10089 self.push_pending(Action::Deploy.label(), env.clone());
10090 let env_for_msg = env.clone();
10091 tokio::spawn(async move {
10092 let result = aws
10093 .deploy_version(&env, &label)
10094 .await
10095 .map_err(|e| flatten_err("deploy_version", e));
10096 let _ = tx.send(AppMsg::ActionResult {
10097 gen,
10098 action: Action::Deploy,
10099 env_name: env_for_msg,
10100 result,
10101 });
10102 });
10103 }
10104
10105 fn spawn_batch_tag(&mut self, env: String, arn: String, key: String, value: Option<String>) {
10110 let aws = self.aws.clone();
10111 let tx = self.msg_tx.clone();
10112 let gen = self.generation;
10113 let is_add = value.is_some();
10114 let op_label = if is_add { "tag" } else { "untag" };
10115 let detail = match &value {
10116 Some(v) => {
10117 format!("stage=dispatched action=Tag target={env} key={key} value={v}")
10118 }
10119 None => format!("stage=dispatched action=Untag target={env} key={key}"),
10120 };
10121 write_audit_line(
10122 self.context.account_id.as_deref(),
10123 self.context.profile.as_deref(),
10124 &self.context.region,
10125 &detail,
10126 );
10127 let pending_label = format!("{op_label} {key}");
10128 self.push_pending(pending_label.clone(), env.clone());
10129 let env_for_msg = env.clone();
10130 tokio::spawn(async move {
10131 let to_add: Vec<(String, String)> = match &value {
10132 Some(v) => vec![(key.clone(), v.clone())],
10133 None => Vec::new(),
10134 };
10135 let to_remove: Vec<String> = if value.is_none() {
10136 vec![key.clone()]
10137 } else {
10138 Vec::new()
10139 };
10140 let result = aws
10141 .update_tags(&arn, &to_add, &to_remove)
10142 .await
10143 .map_err(|e| flatten_err("update_tags", e));
10144 let _ = tx.send(AppMsg::TagUpdate {
10145 gen,
10146 env_name: env_for_msg,
10147 summary: pending_label,
10148 result,
10149 });
10150 });
10151 }
10152
10153 fn spawn_batch_set_option(
10158 &mut self,
10159 env: String,
10160 namespace: String,
10161 name: String,
10162 value: String,
10163 ) {
10164 let Some(app_name) = self
10170 .environments
10171 .iter()
10172 .find(|e| e.name == env)
10173 .map(|e| e.application.clone())
10174 else {
10175 write_audit_line(
10176 self.context.account_id.as_deref(),
10177 self.context.profile.as_deref(),
10178 &self.context.region,
10179 &format!(
10180 "stage=skipped action=SetOption target={env} reason=\"env not in current view\""
10181 ),
10182 );
10183 return;
10184 };
10185 let aws = self.aws.clone();
10186 let tx = self.msg_tx.clone();
10187 let gen = self.generation;
10188 let detail = format!(
10189 "stage=dispatched action=SetOption target={env} ns={namespace} name={name} value={value}"
10190 );
10191 write_audit_line(
10192 self.context.account_id.as_deref(),
10193 self.context.profile.as_deref(),
10194 &self.context.region,
10195 &detail,
10196 );
10197 let pending_label = format!("set-option {namespace}.{name}");
10198 self.push_pending(pending_label.clone(), env.clone());
10199 let env_for_msg = env.clone();
10200 let env_for_undo = env.clone();
10201 let pending_label_for_undo = pending_label.clone();
10202 let to_set_for_undo: Vec<(String, String, String)> =
10203 vec![(namespace.clone(), name.clone(), value.clone())];
10204 tokio::spawn(async move {
10205 let undo_entry = match aws
10212 .fetch_env_option_settings(&app_name, &env_for_undo)
10213 .await
10214 {
10215 Ok(opts) => Some(build_undo_entry(
10216 &env_for_undo,
10217 &pending_label_for_undo,
10218 &to_set_for_undo,
10219 &[],
10220 &opts,
10221 )),
10222 Err(_) => None,
10223 };
10224 let settings = vec![(namespace, name, value)];
10225 let result = aws
10226 .update_env_option_settings(&env, &settings, &[])
10227 .await
10228 .map_err(|e| flatten_err("update_env_option_settings", e));
10229 if result.is_ok() {
10233 if let Some(entry) = undo_entry {
10234 let _ = tx.send(AppMsg::UndoCaptured { gen, entry });
10235 }
10236 }
10237 let _ = tx.send(AppMsg::OptionSettingsUpdate {
10238 gen,
10239 env_name: env_for_msg,
10240 summary: pending_label,
10241 result,
10242 });
10243 });
10244 }
10245
10246 fn open_instance_info_overlay(&mut self) {
10255 let Some(d) = self.detail.as_ref() else {
10256 return;
10257 };
10258 let Some(inst) = d.instances.get(d.instances_cursor) else {
10259 self.status_message = Some("no instance selected".into());
10260 return;
10261 };
10262 let mut body = String::new();
10263 body.push_str(&format!("Instance ID {}\n", inst.id));
10264 body.push_str(&format!("Type {}\n", inst.instance_type));
10265 body.push_str(&format!("Availability zone {}\n", inst.availability_zone));
10266 body.push_str(&format!(
10267 "Health {} ({})\n",
10268 inst.health, inst.color
10269 ));
10270 if let Some(t) = inst.launched_at {
10271 let age = chrono::Utc::now().signed_duration_since(t);
10272 body.push_str(&format!(
10273 "Launched {} (up {})\n",
10274 t.format("%Y-%m-%d %H:%M UTC"),
10275 humanize_short_age(age.to_std().unwrap_or_default())
10276 ));
10277 }
10278 if !inst.causes.is_empty() {
10279 body.push_str("\nCauses:\n");
10280 for c in &inst.causes {
10281 body.push_str(&format!(" • {c}\n"));
10282 }
10283 }
10284 body.push_str(
10285 "\nKeys: b → open in EC2 console · s → SSM shell · y → yank id · x → terminate",
10286 );
10287 self.current_overlay = Some(Overlay::TextDump {
10288 title: format!("instance — {}", inst.id),
10289 body,
10290 });
10291 }
10292
10293 fn open_instance_in_console(&mut self) {
10296 let Some(d) = self.detail.as_ref() else {
10297 return;
10298 };
10299 let Some(inst) = d.instances.get(d.instances_cursor) else {
10300 return;
10301 };
10302 let region = self.context.region.clone();
10303 let id = inst.id.clone();
10304 let url = format!(
10305 "https://{region}.console.aws.amazon.com/ec2/home?region={region}#InstanceDetails:instanceId={id}"
10306 );
10307 let display = id.clone();
10308 let result = std::process::Command::new(if cfg!(target_os = "macos") {
10309 "open"
10310 } else {
10311 "xdg-open"
10312 })
10313 .arg(&url)
10314 .stdout(std::process::Stdio::null())
10315 .stderr(std::process::Stdio::null())
10316 .spawn();
10317 match result {
10318 Ok(_) => {
10319 self.status_message = Some(format!("opened {display} in EC2 console"));
10320 }
10321 Err(e) => {
10322 self.error_message = Some(format!("could not open browser: {e}"));
10323 }
10324 }
10325 }
10326
10327 fn yank_instance_id(&mut self) {
10329 let Some(d) = self.detail.as_ref() else {
10330 return;
10331 };
10332 let Some(inst) = d.instances.get(d.instances_cursor) else {
10333 return;
10334 };
10335 let id = inst.id.clone();
10336 match yank(&id) {
10337 Ok(()) => self.status_message = Some(format!("yanked instance id: {id}")),
10338 Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
10339 }
10340 }
10341
10342 fn spawn_terminate_instance(&mut self, idx: usize) {
10346 let Some(d) = self.detail.as_ref() else {
10347 return;
10348 };
10349 let Some(inst) = d.instances.get(idx).cloned() else {
10350 return;
10351 };
10352 let env_name = d.env_name.clone();
10353 if self.is_read_only_for(&env_name) {
10354 let reason = self
10355 .read_only_reason(&env_name)
10356 .unwrap_or_else(|| "read-only mode".into());
10357 self.error_message = Some(format!("{reason} — terminate-instance disabled"));
10358 return;
10359 }
10360 let id = inst.id.clone();
10361 let aws = self.aws.clone();
10362 let tx = self.msg_tx.clone();
10363 let gen = self.generation;
10364 write_audit_line(
10365 self.context.account_id.as_deref(),
10366 self.context.profile.as_deref(),
10367 &self.context.region,
10368 &format!("stage=dispatched action=TerminateInstance target={env_name} instance={id}"),
10369 );
10370 let target = format!("{env_name}/{id}");
10375 self.push_pending(Action::TerminateInstance.label(), target.clone());
10376 let _ = id;
10378 tokio::spawn(async move {
10379 let result = aws
10380 .terminate_instance(&id)
10381 .await
10382 .map_err(|e| flatten_err("terminate_instance", e));
10383 let _ = tx.send(AppMsg::ActionResult {
10384 gen,
10385 action: Action::TerminateInstance,
10386 env_name: target,
10387 result,
10388 });
10389 });
10390 }
10391
10392 pub fn push_pending(&mut self, label: impl Into<String>, target: impl Into<String>) {
10397 if self.pending_actions.len() >= PENDING_CAP {
10398 self.pending_actions.pop_front();
10399 }
10400 self.pending_actions.push_back(PendingAction {
10401 label: label.into(),
10402 target: target.into(),
10403 started: Instant::now(),
10404 completed: None,
10405 });
10406 }
10407
10408 pub fn complete_pending(&mut self, label: &str, target: &str, result: Result<(), String>) {
10414 if let Some(entry) = self
10415 .pending_actions
10416 .iter_mut()
10417 .find(|e| e.completed.is_none() && e.label == label && e.target == target)
10418 {
10419 entry.completed = Some((Instant::now(), result));
10420 }
10421 }
10422
10423 pub fn expire_pending(&mut self) {
10427 let now = Instant::now();
10428 self.pending_actions.retain(|e| match e.completed {
10429 Some((c, _)) => now.duration_since(c) < PENDING_COMPLETED_TTL,
10430 None => true,
10431 });
10432 }
10433
10434 fn open_parameterised_action(&mut self, action: Action, params: ParameterisedAction) {
10435 let Some(env) = self.selected_env().cloned() else {
10436 self.error_message = Some("no env selected".into());
10437 return;
10438 };
10439 self.open_parameterised_action_on(env, action, params);
10440 }
10441
10442 pub(crate) fn open_parameterised_action_on(
10449 &mut self,
10450 env: crate::aws::Environment,
10451 action: Action,
10452 params: ParameterisedAction,
10453 ) {
10454 if self.deny_write(&env.name, action.label()) {
10455 return;
10456 }
10457 let wants_preflight = action.wants_preflight();
10462 let wants_version_preview = action == Action::Deploy && params.deploy_version.is_some();
10468 let modal = ConfirmModal {
10469 action,
10470 target_env: env.name.clone(),
10471 swap_with: params.swap_with,
10472 typed: String::new(),
10473 kind: ConfirmKind::YesNo,
10474 dryrun: None,
10475 loading_dryrun: wants_preflight,
10476 recent_events: None,
10477 loading_events: wants_preflight,
10478 traffic_warning: compute_traffic_warning(&env),
10479 deploy_version: params.deploy_version.clone(),
10480 upgrade_platform_arn: params.upgrade_platform_arn,
10481 upgrade_platform_label: params.upgrade_platform_label,
10482 clone_target: params.clone_target,
10483 scale_min: params.scale_min,
10484 scale_max: params.scale_max,
10485 auto_rollback_secs: params.auto_rollback_secs,
10486 wait_for_green_secs: params.wait_for_green_secs,
10487 version_preview: None,
10488 loading_version_preview: wants_version_preview,
10489 health_check_probe: None,
10490 loading_health_check: wants_version_preview && !env.cname.is_empty() && !self.demo_mode,
10498 unavailability_line: None,
10499 loading_unavailability: wants_version_preview && !self.demo_mode,
10502 lint_issues: None,
10503 loading_lint: !self.demo_mode,
10511 };
10512 let needs_health_check_probe = modal.loading_health_check;
10513 let needs_unavailability = modal.loading_unavailability;
10514 let needs_lint = modal.loading_lint;
10515 self.action_flow = Some(ActionFlow::Confirm(modal));
10516 self.mode = Mode::Action;
10517 if wants_preflight {
10518 self.spawn_dry_run(env.name.clone());
10519 self.spawn_preflight_events(env.name.clone());
10520 }
10521 if wants_version_preview {
10522 if let Some(label) = params.deploy_version {
10523 self.spawn_version_preview(
10524 env.application.clone(),
10525 env.name.clone(),
10526 env.version_label.clone(),
10527 label,
10528 );
10529 }
10530 }
10531 if needs_health_check_probe {
10532 self.spawn_health_check_probe(
10533 env.application.clone(),
10534 env.name.clone(),
10535 env.cname.clone(),
10536 );
10537 }
10538 if needs_unavailability {
10539 self.spawn_unavailability_estimate(env.application.clone(), env.name.clone());
10540 }
10541 if needs_lint {
10542 self.spawn_confirm_lint(env.clone());
10543 }
10544 }
10545
10546 fn spawn_list_compatible_platforms(&mut self, env_name: String) {
10549 let aws = self.aws.clone();
10550 let tx = self.msg_tx.clone();
10551 let gen = self.generation;
10552 self.status_message = Some(format!(
10553 "fetching compatible platform versions for {env_name}…"
10554 ));
10555 let env_for_msg = env_name.clone();
10556 tokio::spawn(async move {
10557 let result = aws
10558 .list_compatible_platforms(&env_name)
10559 .await
10560 .map_err(|e| flatten_err("list_compatible_platforms", e));
10561 let body = match result {
10562 Ok(p) if p.is_empty() => {
10563 format!("No compatible platform versions found for {env_for_msg}.\n\nesc / q to close")
10564 }
10565 Ok(platforms) => {
10566 let mut lines: Vec<String> = vec![
10567 format!("Compatible platform versions for {env_for_msg}"),
10568 "─────────────────────────────────────────────".into(),
10569 String::new(),
10570 ];
10571 for p in platforms.iter().take(20) {
10572 lines.push(format!(
10573 " v{} {} ({}, {})",
10574 p.version, p.branch, p.status, p.lifecycle
10575 ));
10576 lines.push(format!(" {}", p.arn));
10577 }
10578 lines.push(String::new());
10579 lines.push(
10580 "Copy an ARN and run `:upgrade <ARN>` to migrate. esc / q to close".into(),
10581 );
10582 lines.join("\n")
10583 }
10584 Err(e) => format!("upgrade list failed: {e}\n\nesc / q to close"),
10585 };
10586 let _ = tx.send(AppMsg::TextOverlay {
10587 gen,
10588 title: format!("compatible platforms — {env_for_msg}"),
10589 body,
10590 });
10591 });
10592 }
10593
10594 fn queue_action_dispatch(&mut self, modal: ConfirmModal) {
10597 if self.pending_dispatch.is_some() {
10598 self.error_message = Some(
10599 "another action is mid-dispatch — wait for it to land or press U to undo".into(),
10600 );
10601 return;
10602 }
10603 let label = modal.action.label().to_string();
10604 let target = modal.target_env.clone();
10605 let deadline = Instant::now() + UNDO_WINDOW;
10606 self.pending_dispatch = Some(PendingDispatch {
10607 deadline,
10608 label: label.clone(),
10609 target: target.clone(),
10610 kind: PendingDispatchKind::Single { modal },
10611 });
10612 self.status_message = Some(format!(
10613 "{} → {} dispatches in {}s — press U to undo",
10614 label,
10615 target,
10616 UNDO_WINDOW.as_secs()
10617 ));
10618 }
10619
10620 pub(crate) fn queue_batch_dispatch(
10625 &mut self,
10626 label: String,
10627 target: String,
10628 kind: PendingDispatchKind,
10629 ) {
10630 if self.pending_dispatch.is_some() {
10631 self.error_message = Some(
10632 "another dispatch is mid-window — wait for it to land or press U to undo".into(),
10633 );
10634 return;
10635 }
10636 let deadline = Instant::now() + UNDO_WINDOW;
10637 let status = format!(
10638 "{} → {} dispatches in {}s — press U to undo",
10639 label,
10640 target,
10641 UNDO_WINDOW.as_secs()
10642 );
10643 self.pending_dispatch = Some(PendingDispatch {
10644 deadline,
10645 label,
10646 target,
10647 kind,
10648 });
10649 self.status_message = Some(status);
10650 }
10651
10652 fn tick_pending_dispatch(&mut self) {
10658 let now = Instant::now();
10659 let Some(pd) = self.pending_dispatch.as_ref() else {
10660 return;
10661 };
10662 if now < pd.deadline {
10663 return;
10664 }
10665 let kind = pd.kind.clone();
10666 self.pending_dispatch = None;
10667 match kind {
10668 PendingDispatchKind::Single { modal } => self.spawn_action(modal),
10669 PendingDispatchKind::BatchAction { action, env_names } => {
10670 for env in env_names {
10671 self.spawn_batch_action(action, env);
10672 }
10673 }
10674 PendingDispatchKind::BatchDeploy {
10675 env_names,
10676 version_label,
10677 } => {
10678 for env in env_names {
10679 self.spawn_batch_deploy(env, version_label.clone());
10680 }
10681 }
10682 PendingDispatchKind::BatchTag {
10683 envs_with_arns,
10684 key,
10685 value,
10686 } => {
10687 for (env, arn) in envs_with_arns {
10688 self.spawn_batch_tag(env, arn, key.clone(), value.clone());
10689 }
10690 }
10691 PendingDispatchKind::BatchSetOption {
10692 env_names,
10693 namespace,
10694 option_name,
10695 value,
10696 } => {
10697 for env in env_names {
10698 self.spawn_batch_set_option(
10699 env,
10700 namespace.clone(),
10701 option_name.clone(),
10702 value.clone(),
10703 );
10704 }
10705 }
10706 }
10707 }
10708
10709 fn cancel_pending_dispatch(&mut self) {
10713 let Some(pd) = self.pending_dispatch.take() else {
10714 return;
10715 };
10716 let msg = format!("undone — {} → {} not dispatched", pd.label, pd.target);
10717 let action_for_audit = match &pd.kind {
10718 PendingDispatchKind::Single { modal } => format!("{:?}", modal.action),
10719 PendingDispatchKind::BatchAction { action, .. } => format!("Batch{action:?}"),
10720 PendingDispatchKind::BatchDeploy { .. } => "BatchDeploy".into(),
10721 PendingDispatchKind::BatchTag { value, .. } => {
10722 if value.is_some() {
10723 "BatchTag".into()
10724 } else {
10725 "BatchUntag".into()
10726 }
10727 }
10728 PendingDispatchKind::BatchSetOption { .. } => "BatchSetOption".into(),
10729 };
10730 write_audit_line(
10731 self.context.account_id.as_deref(),
10732 self.context.profile.as_deref(),
10733 &self.context.region,
10734 &format!(
10735 "stage=undone action={action_for_audit} target={}",
10736 pd.target
10737 ),
10738 );
10739 self.status_message = Some(msg);
10740 }
10741
10742 fn spawn_action(&mut self, modal: ConfirmModal) {
10743 if self.is_read_only_for(&modal.target_env) {
10749 let reason = self
10750 .read_only_reason(&modal.target_env)
10751 .unwrap_or_else(|| "read-only mode".into());
10752 self.error_message = Some(format!("{reason} — {} disabled", modal.action.label()));
10753 return;
10754 }
10755 if modal.action == Action::Deploy {
10763 if let Some(env) = self
10764 .environments
10765 .iter()
10766 .find(|e| e.name == modal.target_env)
10767 {
10768 if !env.version_label.is_empty() {
10769 self.deploy_snapshots.insert(
10770 env.name.clone(),
10771 DeploySnapshot {
10772 env_name: env.name.clone(),
10773 previous_version_label: env.version_label.clone(),
10774 taken_at: chrono::Utc::now(),
10775 },
10776 );
10777 }
10778 }
10779 if let Some(secs) = modal.auto_rollback_secs {
10786 let tx = self.msg_tx.clone();
10787 let env_name = modal.target_env.clone();
10788 let gen = self.generation;
10789 let target_label = self
10793 .deploy_snapshots
10794 .get(&modal.target_env)
10795 .map(|s| s.previous_version_label.clone())
10796 .unwrap_or_default();
10797 let armed_at = chrono::Utc::now();
10798 let deadline_at = armed_at + chrono::Duration::seconds(secs as i64);
10799 self.armed_watchdogs.insert(
10800 modal.target_env.clone(),
10801 ArmedWatchdog {
10802 env_name: modal.target_env.clone(),
10803 target_label,
10804 armed_at,
10805 deadline_at,
10806 },
10807 );
10808 self.status_message = Some(format!(
10809 "auto-rollback armed: {secs}s to reach Green or revert"
10810 ));
10811 tokio::spawn(async move {
10812 tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
10813 let _ = tx.send(AppMsg::AutoRollbackCheck { gen, env_name });
10814 });
10815 }
10816 if let Some(secs) = modal.wait_for_green_secs {
10823 let target_label = modal.deploy_version.clone().unwrap_or_default();
10824 let armed_at = chrono::Utc::now();
10825 let deadline_at = armed_at + chrono::Duration::seconds(secs as i64);
10826 self.watching_deploys.insert(
10827 modal.target_env.clone(),
10828 WatchingDeploy {
10829 env_name: modal.target_env.clone(),
10830 target_label,
10831 armed_at,
10832 deadline_at,
10833 },
10834 );
10835 if let Some(existing) = self.status_message.as_mut() {
10838 existing.push_str(&format!("; watching for Green ({secs}s)"));
10839 } else {
10840 self.status_message = Some(format!(
10841 "watching deploy: {secs}s to reach Green or report timeout"
10842 ));
10843 }
10844 }
10845 }
10846 let aws = self.aws.clone();
10847 let tx = self.msg_tx.clone();
10848 let gen = self.generation;
10849 let action = modal.action;
10850 let env = modal.target_env.clone();
10851 let swap_with = modal.swap_with.clone();
10852 let deploy_version = modal.deploy_version.clone();
10853 let upgrade_arn = modal.upgrade_platform_arn.clone();
10854 let clone_target = modal.clone_target.clone();
10855 let scale_min = modal.scale_min;
10856 let scale_max = modal.scale_max;
10857 write_audit_entry(
10858 self.context.account_id.as_deref(),
10859 self.context.profile.as_deref(),
10860 &self.context.region,
10861 action,
10862 &env,
10863 swap_with.as_deref(),
10864 );
10865 self.push_pending(action.label(), env.clone());
10866 tokio::spawn(async move {
10867 let result = match action {
10868 Action::Rebuild => aws.rebuild_env(&env).await,
10869 Action::RestartAppServer => aws.restart_app_server(&env).await,
10870 Action::Terminate => aws.terminate_env(&env).await,
10871 Action::SwapCnames => match swap_with {
10872 Some(dest) => aws.swap_cnames(&env, &dest).await,
10873 None => Err(color_eyre::eyre::eyre!("swap target missing")),
10874 },
10875 Action::Deploy => match deploy_version {
10876 Some(ver) => aws.deploy_version(&env, &ver).await,
10877 None => Err(color_eyre::eyre::eyre!("deploy version missing")),
10878 },
10879 Action::UpgradePlatform => match upgrade_arn {
10880 Some(arn) => aws.upgrade_platform(&env, &arn).await,
10881 None => Err(color_eyre::eyre::eyre!("upgrade platform ARN missing")),
10882 },
10883 Action::Clone => match clone_target {
10884 Some(target) => aws.clone_env(&env, &target).await,
10885 None => Err(color_eyre::eyre::eyre!("clone target name missing")),
10886 },
10887 Action::Scale => match (scale_min, scale_max) {
10888 (Some(mn), Some(mx)) => aws.scale_env(&env, mn, mx).await,
10889 _ => Err(color_eyre::eyre::eyre!("scale min/max missing")),
10890 },
10891 Action::AbortUpdate => aws.abort_environment_update(&env).await,
10892 Action::Capacity
10897 | Action::ConfigSave
10898 | Action::ConfigDelete
10899 | Action::ConfigApply
10900 | Action::TerminateInstance => Err(color_eyre::eyre::eyre!(
10901 "internal: {} dispatched through spawn_action path",
10902 action.label()
10903 )),
10904 }
10905 .map_err(|e| flatten_err("action", e));
10906 let _ = tx.send(AppMsg::ActionResult {
10907 gen,
10908 action,
10909 env_name: env,
10910 result,
10911 });
10912 });
10913 }
10914
10915 fn spawn_detail_instances(&mut self, env_name: String) {
10916 if let Some(d) = self.detail.as_mut() {
10917 d.loading_instances = true;
10918 d.error = None;
10919 }
10920 if self.demo_mode {
10925 let result = Ok(crate::demo_fixture::instances_for(&env_name));
10926 let gen = self.generation;
10927 let _ = self.msg_tx.send(AppMsg::DetailInstances {
10928 gen,
10929 env_name,
10930 result,
10931 });
10932 return;
10933 }
10934 let env_for_msg = env_name.clone();
10935 self.spawn_aws(
10936 "list_instances",
10937 move |aws| async move { aws.list_instances(&env_name).await },
10938 move |gen, result| AppMsg::DetailInstances {
10939 gen,
10940 env_name: env_for_msg,
10941 result,
10942 },
10943 );
10944 }
10945
10946 fn execute_command(&mut self, raw: &str) {
10947 let line = raw.trim();
10948 if line.is_empty() {
10949 return;
10950 }
10951 let expanded = expand_command_alias(line, &self.command_aliases);
10958 let line = expanded.as_str();
10959 let mut parts = line.split_whitespace();
10960 let Some(cmd) = parts.next() else { return };
10961 let rest: Vec<&str> = parts.collect();
10962 match cmd {
10963 "q" | "quit" => self.quit = true,
10964 "refresh" => self.manual_refresh(),
10965 "help" | "?" => {
10966 self.help.topic = if self.detail.is_some() {
10972 HelpTopic::Detail
10973 } else if self.action_flow.is_some() {
10974 HelpTopic::Action
10975 } else if self.dlq.is_some() {
10976 HelpTopic::Dlq
10977 } else if matches!(
10978 self.current_overlay,
10979 Some(Overlay::SavedConfigsInteractive { .. })
10980 ) {
10981 HelpTopic::SavedConfigs
10982 } else {
10983 HelpTopic::Global
10984 };
10985 self.help.pre_mode = Some(self.mode);
10986 self.mode = Mode::Help;
10987 }
10988 "region" | "r" => self.cmd_region(&rest),
10989 "custom-platforms" | "platforms" => self.cmd_custom_platforms(),
10990 "accounts" => self.cmd_accounts(),
10991 "org-health" => self.cmd_org_health(),
10992 "find-env" => match rest.first().copied() {
10993 None => {
10994 self.error_message = Some(
10995 "usage: :find-env <name-substring> (scans every AWS profile + AssumeRole account)"
10996 .into(),
10997 );
10998 }
10999 Some(needle) => self.cmd_find_env(needle),
11000 },
11001 "envs-by-version" => match rest.first().copied() {
11002 None => {
11003 self.error_message = Some(
11004 "usage: :envs-by-version <label> (scans every AWS profile + AssumeRole account for envs running that exact version label)"
11005 .into(),
11006 );
11007 }
11008 Some(label) => self.cmd_envs_by_version(label),
11009 },
11010 "logs-insights" => {
11011 let args = rest.join(" ");
11016 self.cmd_logs_insights(&args);
11017 }
11018 "account" => self.cmd_account(&rest),
11019 "profile" | "p" => self.cmd_profile(&rest),
11020 "sort" => self.cmd_sort(&rest),
11021 "group" => self.cmd_group(&rest),
11022 "redact" => self.cmd_redact(&rest),
11023 "events" => {
11024 self.event_panel.visible =
11025 parse_toggle(rest.first().copied(), self.event_panel.visible);
11026 if self.event_panel.visible && self.event_panel.events.is_empty() {
11027 self.spawn_events();
11028 }
11029 self.status_message = Some(if self.event_panel.visible {
11030 "events panel ON".into()
11031 } else {
11032 "events panel off".into()
11033 });
11034 }
11035 "event-time" => self.cmd_event_time(&rest),
11036 "export" => self.export_tsv(),
11037 "json" => self.export_json(),
11038 "report" | "markdown" => self.export_markdown(),
11039 "readonly" => {
11040 self.read_only = parse_toggle(rest.first().copied(), self.read_only);
11041 self.status_message = Some(if self.read_only {
11042 "read-only ON — destructive actions disabled".into()
11043 } else {
11044 "read-only off".into()
11045 });
11046 }
11047 "pin" => self.toggle_pin_selected(),
11048 "alias" => match rest.first().copied() {
11049 Some(name) => {
11050 let label = rest[1..].join(" ");
11051 if label.is_empty() {
11052 self.error_message = Some(
11053 "usage: :alias <env-name> <label> (label cannot be empty)".to_string(),
11054 );
11055 } else {
11056 self.aliases.insert(name.to_string(), label.clone());
11057 self.status_message = Some(format!("alias '{name}' → \"{label}\""));
11058 self.persist_state();
11059 }
11060 }
11061 None => {
11062 if self.aliases.is_empty() {
11063 self.status_message = Some("no aliases set".into());
11064 } else {
11065 let list: Vec<String> = self
11066 .aliases
11067 .iter()
11068 .map(|(k, v)| format!("{k} → \"{v}\""))
11069 .collect();
11070 self.status_message = Some(format!("aliases: {}", list.join(" ")));
11071 }
11072 }
11073 },
11074 "alias-drop" | "alias-rm" => match rest.first() {
11075 Some(name) => {
11076 if self.aliases.remove(*name).is_some() {
11077 self.status_message = Some(format!("alias '{name}' removed"));
11078 self.persist_state();
11079 } else {
11080 self.error_message = Some(format!("no alias for '{name}'"));
11081 }
11082 }
11083 None => self.error_message = Some("usage: :alias-drop <env-name>".into()),
11084 },
11085 "whatsnew" => self.open_whatsnew(),
11086 "about" | "credits" => self.open_about_overlay(),
11087 "apps-info" => self.open_apps_info_overlay(),
11088 "cost" => self.cmd_cost(&rest),
11089 "listeners" => self.cmd_listeners(),
11090 "listener-edit" => self.cmd_listener_edit(&rest),
11091 "rds" => self.cmd_rds(),
11092 "rds-attach" => self.cmd_rds_attach(),
11093 "rds-detach" => self.cmd_rds_detach(&rest),
11094 "options" => self.cmd_options(&rest),
11095 "config-diff" => self.cmd_config_diff(&rest),
11096 "config-diff-local" => self.cmd_config_diff_local(&rest),
11097 "explain" => self.cmd_explain(&rest),
11098 "env-edit" => self.cmd_env_edit(),
11099 "secrets" => self.cmd_secrets(&rest),
11100 "secret" => self.cmd_secret_view(&rest),
11101 "report-bug" => self.open_report_bug_overlay(),
11102 "settings" => {
11103 self.open_settings_form();
11104 }
11105 "capacity" => self.cmd_capacity(),
11106 "scaling-triggers" => self.cmd_scaling_triggers(),
11107 "subnets" => self.open_subnets_form(),
11108 "elb-subnets" => self.open_elb_subnets_form(),
11109 "security-groups" => self.open_security_groups_form(),
11110 "update" => {
11111 let channel = crate::update_check::detect_install_channel();
11117 let cmd = channel.upgrade_command();
11118 let current = env!("CARGO_PKG_VERSION");
11119 let msg = match self.update_available.as_ref() {
11120 Some(release) => format!(
11121 "update available: {current} → {}. run: {cmd}",
11122 release.version
11123 ),
11124 None => {
11125 format!("already on the latest ({current}). to force-reinstall: {cmd}")
11126 }
11127 };
11128 if let Ok(mut cb) = arboard::Clipboard::new() {
11132 let _ = cb.set_text(cmd.to_string());
11133 }
11134 self.pin_status(msg);
11135 }
11136 "history" => {
11137 self.current_overlay = Some(Overlay::History(self.format_message_log()));
11138 }
11139 "saved-configs" | "configs" => {
11140 let items = collect_saved_configs(&self.applications);
11141 if items.is_empty() {
11142 self.current_overlay = Some(Overlay::SavedConfigs(format_saved_configs(
11143 &self.applications,
11144 )));
11145 } else {
11146 self.current_overlay = Some(Overlay::SavedConfigsInteractive {
11147 items,
11148 cursor: 0,
11149 confirm_delete: false,
11150 });
11151 }
11152 }
11153 "plugins" => {
11154 if self.plugins.is_empty() {
11155 self.status_message =
11156 Some("no plugins — add ~/.config/ebman/commands.toml".into());
11157 } else {
11158 let names: Vec<&str> = self.plugins.keys().map(String::as_str).collect();
11159 self.status_message = Some(format!(":<plugin> {}", names.join(", ")));
11160 }
11161 }
11162 "diff" => match (rest.first(), rest.get(1)) {
11163 (None, _) => {
11164 self.error_message = Some(
11165 "usage: :diff ENV (selected ↔ ENV) | :diff ENV-A ENV-B (name both)"
11166 .into(),
11167 );
11168 }
11169 (Some(a), Some(b)) => {
11174 if a == b {
11175 self.error_message = Some("pick two different envs to compare".into());
11176 return;
11177 }
11178 let Some(left) = self.environments.iter().find(|e| e.name == **a).cloned()
11179 else {
11180 self.error_message = Some(format!("no env named '{a}' in current view"));
11181 return;
11182 };
11183 let Some(right) = self.environments.iter().find(|e| e.name == **b).cloned()
11184 else {
11185 self.error_message = Some(format!("no env named '{b}' in current view"));
11186 return;
11187 };
11188 self.current_overlay =
11189 Some(Overlay::Diff(diff_envs(&left, &right, self.redact)));
11190 }
11191 (Some(target), None) => {
11195 let left_opt = if let Some(d) = self.detail.as_ref() {
11196 Some(d.env_snapshot.clone())
11197 } else {
11198 self.selected_env().cloned()
11199 };
11200 let Some(left) = left_opt else {
11201 self.error_message = Some("no env selected".into());
11202 return;
11203 };
11204 if left.name == **target {
11205 self.error_message = Some("pick a different env to compare against".into());
11206 return;
11207 }
11208 let right = self
11209 .environments
11210 .iter()
11211 .find(|e| e.name == **target)
11212 .cloned();
11213 match right {
11214 None => {
11215 self.error_message =
11216 Some(format!("no env named '{target}' in current view"));
11217 }
11218 Some(right) => {
11219 self.current_overlay =
11220 Some(Overlay::Diff(diff_envs(&left, &right, self.redact)));
11221 }
11222 }
11223 }
11224 },
11225 "alarms" => {
11226 let env_opt = if let Some(d) = self.detail.as_ref() {
11227 Some(d.env_name.clone())
11228 } else {
11229 self.selected_env().map(|e| e.name.clone())
11230 };
11231 match env_opt {
11232 Some(env_name) => self.spawn_alarms_fetch(env_name),
11233 None => self.error_message = Some("no env selected".into()),
11234 }
11235 }
11236 "why" | "diagnose" => {
11237 let env_opt = if let Some(d) = self.detail.as_ref() {
11238 Some((d.env_name.clone(), d.env_snapshot.application.clone()))
11239 } else {
11240 self.selected_env()
11241 .map(|e| (e.name.clone(), e.application.clone()))
11242 };
11243 match env_opt {
11244 Some((env_name, app_name)) => self.open_why_red(env_name, app_name),
11245 None => self.error_message = Some("no env selected".into()),
11246 }
11247 }
11248 "loglevel" => match rest.first() {
11249 None => {
11250 self.status_message =
11251 Some(format!("current log directive: {}", self.log_directive));
11252 }
11253 Some(level) => {
11254 self.set_log_level(level);
11255 }
11256 },
11257 "cols" => self.cmd_cols(&rest),
11258 "save-view" => self.cmd_save_view(&rest),
11259 "view" => self.cmd_view(&rest),
11260 "views" => self.cmd_views(),
11261 "view-drop" => self.cmd_view_drop(&rest),
11262 "filter" | "f" => self.cmd_filter_load(&rest),
11263 "save" => self.cmd_save_filter(&rest),
11264 "drop" => self.cmd_drop_filter(&rest),
11265 "filters" => self.cmd_filters(),
11266 "batch-rebuild" => self.cmd_batch_action(Action::Rebuild),
11267 "batch-restart" => self.cmd_batch_action(Action::RestartAppServer),
11268 "batch-deploy" => self.cmd_batch_deploy(&rest),
11269 "batch-tag" => self.cmd_batch_tag_or_untag(true, &rest),
11270 "batch-untag" => self.cmd_batch_tag_or_untag(false, &rest),
11271 "batch-set-option" => self.cmd_batch_set_option(&rest),
11272 "versions" => self.cmd_versions(),
11273 "deploy" => self.cmd_deploy(&rest),
11274 "rollback" => self.cmd_rollback(&rest),
11275 "changes" => self.cmd_changes(),
11276 "lineage" => self.cmd_lineage(),
11277 "ssh" => self.cmd_ssh(&rest),
11278 "ssm-run" => self.cmd_ssm_run(&rest),
11279 "delete-version" => self.cmd_delete_version(&rest),
11280 "upgrade" => self.cmd_upgrade(&rest),
11281 "clone" => self.cmd_clone(&rest),
11282 "promote-env" => self.cmd_promote_env(&rest),
11283 "rollout" => self.cmd_rollout(&rest),
11284 "scale" => self.cmd_scale(&rest),
11285 "stop" => self.cmd_stop(),
11286 "start" => self.cmd_start(),
11287 "abort" => self.cmd_abort(),
11288 "pending" | "in-flight" | "inflight" => self.cmd_pending(),
11289 "rollbacks-armed" | "rb-armed" => self.cmd_rollbacks_armed(),
11290 "abort-rollback" => self.cmd_abort_rollback(&rest),
11291 "freeze-deploys" => self.cmd_freeze_deploys(&rest),
11292 "thaw-deploys" => self.cmd_thaw_deploys(),
11293 "undo" => self.cmd_undo(),
11294 "lint" => self.cmd_lint(&rest),
11295 "drift" => self.cmd_drift(&rest),
11296 "tag" => self.cmd_tag(&rest),
11297 "untag" => self.cmd_untag(&rest),
11298 "resources" | "res" => self.cmd_resources(),
11299 "rebuild" => self.cmd_rebuild(),
11300 "restart" => self.cmd_restart(),
11301 "terminate" => self.cmd_terminate(),
11302 "swap" => self.cmd_swap(&rest),
11303 "config-save" => self.cmd_config_save(&rest),
11304 "config-delete" => self.cmd_config_delete(&rest),
11305 "config-apply" => self.cmd_config_apply(&rest),
11306 "deployment-policy" => self.cmd_deployment_policy(&rest),
11307 "rolling-update" => self.cmd_rolling_update(&rest),
11308 "health-check-url" => self.cmd_health_check_url(&rest),
11309 "keypair" => self.cmd_keypair(&rest),
11310 "service-role" => self.cmd_service_role(&rest),
11311 "instance-profile" => self.cmd_instance_profile(&rest),
11312 "public-ip" => self.cmd_public_ip(&rest),
11313 "elb-scheme" => self.cmd_elb_scheme(&rest),
11314 "set-option" => self.cmd_set_option(&rest),
11315 "unset-option" => self.cmd_unset_option(&rest),
11316 "instance-type" => self.cmd_instance_type(&rest),
11317 "custom-platform-delete" => self.cmd_custom_platform_delete(&rest),
11318 "env" => self.cmd_env(&rest),
11319 "metric" => self.cmd_metric(&rest),
11320 "logs-tail" => {
11321 let Some(env) = self.selected_env().cloned() else {
11328 self.error_message = Some("no env selected".into());
11329 return;
11330 };
11331 let explicit_group = rest.first().map(|s| s.to_string());
11332 self.spawn_logs_tail(env.name.clone(), explicit_group);
11333 }
11334 "logs-stream" => self.cmd_logs_stream(&rest),
11335 "notify" => self.cmd_notify(&rest),
11336 "managed-window" => self.cmd_managed_window(&rest),
11337 "alarm-create" => self.cmd_alarm_create(&rest),
11338 "alarm-delete" => self.cmd_alarm_delete(&rest),
11339 "alarm-history" => self.cmd_alarm_history(&rest),
11340 "config-inspect" => self.cmd_config_inspect(&rest),
11341 "deselect" | "select-clear" => {
11342 let n = self.multi_selected.len();
11343 self.multi_selected.clear();
11344 self.status_message = Some(format!("cleared {n} env selection(s)"));
11345 }
11346 other => {
11347 if let Some(plugin) = self.plugins.get(other).cloned() {
11348 self.run_plugin_command(other, &plugin);
11349 return;
11350 }
11351 let suggestion = suggest_command(other);
11357 let msg = match suggestion {
11358 Some(name) => {
11359 format!("unknown command: :{other} — did you mean :{name}? (try :help)")
11360 }
11361 None => format!("unknown command: :{other} (try :help)"),
11362 };
11363 self.error_message = Some(msg);
11364 }
11365 }
11366 }
11367
11368 fn run_plugin_command(&mut self, name: &str, plugin: &crate::plugins::Plugin) {
11369 let env_opt = if let Some(d) = self.detail.as_ref() {
11370 Some(d.env_snapshot.clone())
11371 } else {
11372 self.selected_env().cloned()
11373 };
11374 let Some(env) = env_opt else {
11375 self.error_message = Some(format!(":{name} — no env selected"));
11376 return;
11377 };
11378 let rendered = crate::plugins::render(
11379 &plugin.template,
11380 &env.name,
11381 &env.cname,
11382 &env.application,
11383 &env.tier,
11384 &self.context.region,
11385 self.override_profile
11386 .as_deref()
11387 .or(self.context.profile.as_deref()),
11388 );
11389 match yank(&rendered) {
11390 Ok(()) => {
11391 self.status_message = Some(format!(
11392 "plugin :{name} → clipboard ({} chars)",
11393 rendered.chars().count()
11394 ));
11395 }
11396 Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
11397 }
11398 }
11399
11400 fn open_profile_picker(&mut self) {
11401 let items = profiles::load_profiles();
11402 let current = self.context.profile.as_deref();
11403 self.picker = Some(Picker::new(PickerKind::Profile, items, current));
11404 self.mode = Mode::Picker;
11405 }
11406
11407 fn open_region_picker(&mut self) {
11408 let mut items: Vec<String> = profiles::REGIONS.iter().map(|s| (*s).to_string()).collect();
11409 for r in &self.extra_regions {
11410 if !items.iter().any(|i| i == r) {
11411 items.push(r.clone());
11412 }
11413 }
11414 let current = Some(self.context.region.as_str());
11415 self.picker = Some(Picker::new(PickerKind::Region, items, current));
11416 self.mode = Mode::Picker;
11417 }
11418
11419 pub fn persist_state(&self) {
11420 if self.demo_mode {
11427 return;
11428 }
11429 let selected = self.selected_env().map(|e| e.name.clone());
11430 let region = self.override_region.clone().or_else(|| {
11440 if !self.context.region.is_empty() && self.context.region != "unknown" {
11441 Some(self.context.region.clone())
11442 } else {
11443 None
11444 }
11445 });
11446 let profile = self
11447 .override_profile
11448 .clone()
11449 .or_else(|| self.context.profile.clone());
11450 tracing::debug!(
11451 target: "ebman::state",
11452 override_region = ?self.override_region,
11453 context_region = %self.context.region,
11454 persisted_region = ?region,
11455 override_profile = ?self.override_profile,
11456 context_profile = ?self.context.profile,
11457 persisted_profile = ?profile,
11458 "persist_state"
11459 );
11460 state::save(&PersistedState {
11461 profile,
11462 region,
11463 filter: if self.filter.is_empty() {
11464 None
11465 } else {
11466 Some(self.filter.clone())
11467 },
11468 sort: Some(format!(
11469 "{}:{}",
11470 self.sort_key.label(),
11471 if self.sort_desc { "desc" } else { "asc" }
11472 )),
11473 grouped: Some(self.grouped),
11474 redact: Some(self.redact),
11475 events_visible: Some(self.event_panel.visible),
11476 event_time_format: Some(self.event_panel.time_format),
11477 selected_env: selected,
11478 pinned: self.pinned.clone(),
11479 pinned_apps: self.pinned_apps.clone(),
11480 cost_enabled: Some(self.cost_enabled),
11481 aliases: self.aliases.clone(),
11482 saved_views: self.saved_views.clone(),
11483 deploy_snapshots: self
11484 .deploy_snapshots
11485 .iter()
11486 .map(|(env, snap)| (env.clone(), snap.to_persisted()))
11487 .collect(),
11488 hidden_cols: self.hidden_cols.clone(),
11489 custom_metrics: self.custom_metrics.clone(),
11490 });
11491 }
11492
11493 fn resort_envs(&mut self) {
11494 let key = self.sort_key;
11495 let desc = self.sort_desc;
11496 let pinned = self.pinned.clone();
11497 self.environments.sort_by(|a, b| {
11498 let a_pin = pinned.contains(&a.name);
11500 let b_pin = pinned.contains(&b.name);
11501 if a_pin != b_pin {
11502 return if a_pin {
11503 std::cmp::Ordering::Less
11504 } else {
11505 std::cmp::Ordering::Greater
11506 };
11507 }
11508 let ord = match key {
11509 SortKey::App => a
11510 .application
11511 .to_lowercase()
11512 .cmp(&b.application.to_lowercase())
11513 .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())),
11514 SortKey::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
11515 SortKey::Status => a
11516 .status
11517 .to_lowercase()
11518 .cmp(&b.status.to_lowercase())
11519 .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())),
11520 SortKey::Health => health_rank(&a.health)
11521 .cmp(&health_rank(&b.health))
11522 .then(a.name.to_lowercase().cmp(&b.name.to_lowercase())),
11523 SortKey::Age => a.updated.cmp(&b.updated),
11524 SortKey::Version => a
11525 .version_label
11526 .to_lowercase()
11527 .cmp(&b.version_label.to_lowercase()),
11528 };
11529 if desc {
11530 ord.reverse()
11531 } else {
11532 ord
11533 }
11534 });
11535 self.rebuild_view();
11536 }
11537
11538 fn yank_selected(&mut self, kind: YankKind) {
11539 let Some(env) = self.selected_env() else {
11540 self.status_message = Some("nothing to yank".into());
11541 return;
11542 };
11543 let value = match kind {
11544 YankKind::Cname => env.cname.clone(),
11545 YankKind::Name => env.name.clone(),
11546 };
11547 if value.is_empty() {
11548 self.status_message = Some("selected env has no value to yank".into());
11549 return;
11550 }
11551 match yank(&value) {
11552 Ok(()) => {
11553 self.status_message = Some(format!(
11554 "copied {} to clipboard",
11555 match kind {
11556 YankKind::Cname => "CNAME",
11557 YankKind::Name => "name",
11558 }
11559 ));
11560 }
11561 Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
11562 }
11563 }
11564
11565 fn export_tsv(&mut self) {
11566 let count = self.cached_filtered.len();
11567 let mut out = String::new();
11568 out.push_str(
11569 "NAME\tAPPLICATION\tTIER\tSTATUS\tHEALTH\tPLATFORM\tVERSION\tCNAME\tUPDATED\n",
11570 );
11571 for &i in &self.cached_filtered {
11572 let e = &self.environments[i];
11573 let cname = if self.redact {
11574 redact_block(&e.cname)
11575 } else {
11576 e.cname.clone()
11577 };
11578 let updated = e.updated.map(|u| u.to_rfc3339()).unwrap_or_default();
11579 out.push_str(&format!(
11580 "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
11581 e.name,
11582 e.application,
11583 e.tier,
11584 e.status,
11585 e.health,
11586 e.platform,
11587 e.version_label,
11588 cname,
11589 updated
11590 ));
11591 }
11592 match yank(&out) {
11593 Ok(()) => {
11594 self.status_message = Some(format!("exported {count} rows (TSV) to clipboard"));
11595 }
11596 Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
11597 }
11598 }
11599
11600 pub(crate) fn refresh_tf_managed_envs(&mut self) {
11606 self.tf_managed_envs = self
11607 .tf_state
11608 .as_ref()
11609 .map(|s| s.managed_names())
11610 .unwrap_or_default();
11611 }
11612
11613 pub fn selected_env(&self) -> Option<&Environment> {
11614 let sel = self.table_state.selected()?;
11615 match self.display_rows().get(sel)? {
11616 DisplayRow::Env(i) => self.environments.get(*i),
11617 DisplayRow::Separator => None,
11618 }
11619 }
11620
11621 fn apply_picker_choice(&mut self, kind: PickerKind, value: String) {
11622 match kind {
11623 PickerKind::Profile => {
11624 tracing::info!(
11625 target: "ebman::state",
11626 new_profile = %value,
11627 cleared_override_region = ?self.override_region,
11628 "apply_picker_choice(Profile) clears override_region so SDK re-resolves from new profile config"
11629 );
11630 self.override_profile = Some(value.clone());
11631 self.override_region = None;
11632 self.status_message = Some(format!("switching to profile {value}…"));
11633 self.spawn_rebuild();
11634 }
11635 PickerKind::Region => {
11636 tracing::info!(
11637 target: "ebman::state",
11638 new_region = %value,
11639 prior_override = ?self.override_region,
11640 "apply_picker_choice(Region) sets override_region"
11641 );
11642 self.override_region = Some(value.clone());
11643 self.status_message = Some(format!("switching to region {value}…"));
11644 self.spawn_rebuild();
11645 }
11646 PickerKind::LogGroup => {
11647 let env = match self.current_overlay.as_ref() {
11653 Some(Overlay::LogTail { env_name, .. }) => env_name.clone(),
11654 _ => return,
11655 };
11656 self.spawn_logs_tail(env, Some(value));
11657 }
11658 PickerKind::SshInstance => {
11659 write_audit_line(
11663 self.context.account_id.as_deref(),
11664 self.context.profile.as_deref(),
11665 &self.context.region,
11666 &format!(
11667 "stage=dispatched action=SsmSession target={value} via=cmd_ssh_picker"
11668 ),
11669 );
11670 self.pending_shell_target = Some(value.clone());
11671 self.status_message = Some(format!("opening SSM session to {value}…"));
11672 }
11673 }
11674 }
11675
11676 fn spawn_rebuild(&mut self) {
11677 self.load_state = LoadState::Loading;
11678 self.loading_since = Some(Instant::now());
11679 let profile = self.override_profile.clone();
11680 let region = self.override_region.clone();
11681 let tx = self.msg_tx.clone();
11682 tokio::spawn(async move {
11683 let result = match AwsClient::with(profile, region).await {
11684 Ok(c) => Ok(Box::new(c)),
11685 Err(e) => Err(flatten_err("aws_client_with", e)),
11686 };
11687 let _ = tx.send(AppMsg::Rebuild(result));
11688 });
11689 }
11690
11691 fn spawn_assume_role_switch(&mut self, account_name: String) {
11697 let Some(spec) = self.accounts.get(&account_name).cloned() else {
11698 self.error_message = Some(format!(
11699 "no `accounts.{account_name}` in config.toml — add `accounts.{account_name}.role_arn = …`"
11700 ));
11701 return;
11702 };
11703 self.load_state = LoadState::Loading;
11704 self.loading_since = Some(Instant::now());
11705 self.status_message = Some(format!("assuming role for account '{account_name}'…"));
11706 let tx = self.msg_tx.clone();
11707 tokio::spawn(async move {
11708 let result = match AwsClient::assume_role(&account_name, &spec).await {
11709 Ok(c) => Ok(Box::new(c)),
11710 Err(e) => Err(flatten_err("aws_client_assume_role", e)),
11711 };
11712 let _ = tx.send(AppMsg::Rebuild(result));
11713 });
11714 }
11715
11716 fn spawn_identity(&mut self) {
11717 self.spawn_aws(
11718 "verify_identity",
11719 move |aws| async move { aws.verify_identity().await },
11720 |gen, result| AppMsg::Identity { gen, result },
11721 );
11722 }
11723
11724 fn spawn_update_check(&mut self) {
11725 if self.demo_mode {
11728 return;
11729 }
11730 let tx = self.msg_tx.clone();
11731 tokio::spawn(async move {
11732 let result = crate::update_check::check_async().await;
11733 let _ = tx.send(AppMsg::UpdateCheck(result));
11734 });
11735 }
11736
11737 fn arm_loading_linger(&mut self) {
11744 let now = Instant::now();
11745 if let Some(until) = compute_loading_linger_target(
11746 self.loading_since,
11747 LOADING_INDICATOR_THRESHOLD,
11748 LOADING_INDICATOR_LINGER,
11749 now,
11750 ) {
11751 self.loading_visible_until = Some(until);
11752 }
11753 }
11754
11755 fn spawn_refresh(&mut self) {
11756 if self.demo_mode {
11760 return;
11761 }
11762 if matches!(self.load_state, LoadState::Loading) {
11763 return;
11764 }
11765 self.load_state = LoadState::Loading;
11766 self.loading_since = Some(Instant::now());
11767 self.status_snapshot_at_refresh =
11768 Some((self.status_message.clone(), self.error_message.clone()));
11769 let tx = self.msg_tx.clone();
11770 let gen = self.generation;
11771 if self.multi_regions.is_empty() {
11772 let aws = self.aws.clone();
11773 tokio::spawn(async move {
11774 let result = aws
11775 .list_environments()
11776 .await
11777 .map_err(|e| flatten_err("list_environments", e));
11778 let _ = tx.send(AppMsg::Refresh { gen, result });
11779 });
11780 } else {
11781 let regions = self.multi_regions.clone();
11782 let profile = self
11783 .override_profile
11784 .clone()
11785 .or_else(|| self.context.profile.clone());
11786 tokio::spawn(async move {
11787 use futures::future::join_all;
11788 let tasks = regions.into_iter().map(|r| {
11789 let p = profile.clone();
11790 async move { crate::aws::list_environments_in_region(p, r).await }
11791 });
11792 let results = join_all(tasks).await;
11793 let mut envs = Vec::new();
11794 let mut errs = Vec::new();
11795 for r in results {
11796 match r {
11797 Ok(v) => envs.extend(v),
11798 Err(e) => errs.push(format!("{e}")),
11799 }
11800 }
11801 let result = if envs.is_empty() && !errs.is_empty() {
11802 Err(errs.join("; "))
11803 } else {
11804 Ok(envs)
11805 };
11806 let _ = tx.send(AppMsg::Refresh { gen, result });
11807 });
11808 }
11809 if self.event_panel.visible {
11810 self.spawn_events();
11811 }
11812 self.spawn_applications();
11813 if self.latest_stacks.is_empty() {
11817 self.spawn_solution_stacks();
11818 }
11819 }
11820
11821 fn spawn_solution_stacks(&self) {
11825 self.spawn_aws(
11826 "list_solution_stacks",
11827 move |aws| async move { aws.list_solution_stacks().await },
11828 |gen, result| AppMsg::SolutionStacks { gen, result },
11829 );
11830 }
11831
11832 fn spawn_applications(&self) {
11833 self.spawn_aws(
11834 "list_applications",
11835 move |aws| async move { aws.list_applications().await },
11836 |gen, result| AppMsg::Applications { gen, result },
11837 );
11838 }
11839
11840 fn set_scope(&mut self, new: Scope) {
11845 let changed = self.scope != new;
11846 self.scope = new;
11847 if changed && new == Scope::Apps {
11848 self.spawn_app_latest_versions();
11849 }
11850 }
11851
11852 fn spawn_app_latest_versions(&self) {
11859 let aws = self.aws.clone();
11860 let tx = self.msg_tx.clone();
11861 let gen = self.generation;
11862 let names: Vec<String> = self.applications.iter().map(|a| a.name.clone()).collect();
11863 if names.is_empty() {
11864 return;
11865 }
11866 tokio::spawn(async move {
11867 use futures::future::join_all;
11868 let futs = names.into_iter().map(|name| {
11869 let aws = aws.clone();
11870 async move {
11871 let res = aws.list_application_versions(&name).await;
11872 let head = res.ok().and_then(|mut v| v.drain(..).next());
11873 (
11874 name,
11875 head.as_ref().map(|h| h.label.clone()),
11876 head.and_then(|h| h.created),
11877 )
11878 }
11879 });
11880 let results: Vec<(
11881 String,
11882 Option<String>,
11883 Option<chrono::DateTime<chrono::Utc>>,
11884 )> = join_all(futs).await;
11885 let _ = tx.send(AppMsg::AppLatestVersions { gen, results });
11886 });
11887 }
11888
11889 fn spawn_worker_queue_check(&self) {
11894 let aws = self.aws.clone();
11895 let tx = self.msg_tx.clone();
11896 let gen = self.generation;
11897 let workers: Vec<(String, String)> = self
11898 .environments
11899 .iter()
11900 .filter(|e| e.tier.eq_ignore_ascii_case("Worker"))
11901 .map(|e| (e.name.clone(), e.application.clone()))
11902 .collect();
11903 if workers.is_empty() {
11904 return;
11905 }
11906 tokio::spawn(async move {
11907 use futures::future::join_all;
11908 let futs = workers.into_iter().map(|(env, app)| {
11909 let aws = aws.clone();
11910 async move {
11911 aws.describe_worker_queues(&app, &env)
11912 .await
11913 .ok()
11914 .and_then(|q| q.dlq_stats.map(|s| (env, s.visible)))
11915 }
11916 });
11917 let results: Vec<(String, i64)> = join_all(futs).await.into_iter().flatten().collect();
11918 let _ = tx.send(AppMsg::WorkerQueueCheck { gen, results });
11919 });
11920 }
11921
11922 fn spawn_env_instance_counts(&self) {
11928 let aws = self.aws.clone();
11929 let tx = self.msg_tx.clone();
11930 let gen = self.generation;
11931 let targets: Vec<String> = self
11932 .environments
11933 .iter()
11934 .filter(|e| {
11935 !matches!(
11938 e.status.as_str(),
11939 "Terminated" | "Terminating" | "Launching"
11940 )
11941 })
11942 .map(|e| e.name.clone())
11943 .collect();
11944 if targets.is_empty() {
11945 return;
11946 }
11947 tokio::spawn(async move {
11948 use futures::future::join_all;
11949 let futs = targets.into_iter().map(|env| {
11950 let aws = aws.clone();
11951 async move {
11952 aws.fetch_env_instance_counts(&env)
11953 .await
11954 .ok()
11955 .map(|counts| (env, counts))
11956 }
11957 });
11958 let results: Vec<(String, crate::aws::EnvInstanceCounts)> =
11959 join_all(futs).await.into_iter().flatten().collect();
11960 let _ = tx.send(AppMsg::EnvInstanceCountsCheck { gen, results });
11961 });
11962 }
11963
11964 fn spawn_events(&mut self) {
11965 let selected = self.selected_env().map(|e| e.name.clone());
11971 self.event_panel.for_env = selected.clone();
11972 self.spawn_aws(
11973 "list_events",
11974 move |aws| async move {
11975 match selected {
11976 Some(name) => aws.list_events_for_env(&name, 50).await,
11977 None => aws.list_events(50).await,
11978 }
11979 },
11980 |gen, result| AppMsg::Events { gen, result },
11981 );
11982 }
11983
11984 fn refresh_events_if_selection_changed(&mut self) {
11989 if !self.event_panel.visible {
11990 return;
11991 }
11992 let selected = self.selected_env().map(|e| e.name.clone());
11993 if selected != self.event_panel.for_env {
11994 self.spawn_events();
11995 }
11996 }
11997
11998 fn apply_detail_msg<T, F>(&mut self, env_name: &str, result: Result<T, String>, apply: F)
12009 where
12010 F: FnOnce(&mut DetailState, Result<T, String>),
12011 {
12012 let Some(detail) = self.detail.as_mut() else {
12013 return;
12014 };
12015 if detail.env_name != env_name {
12016 return;
12017 }
12018 apply(detail, result);
12019 }
12020
12021 fn apply_rebuild(&mut self, result: Result<Box<AwsClient>, String>) {
12022 match result {
12023 Ok(client) => {
12024 self.generation = self.generation.wrapping_add(1);
12025 self.context = client.context.clone();
12026 self.aws = Arc::new(*client);
12027 self.maybe_apply_profile_theme();
12028 self.environments.clear();
12029 self.event_panel.events.clear();
12030 self.event_panel.scroll = 0;
12031 self.history.clear();
12032 self.latest_stacks.clear();
12035 self.current_overlay = None;
12038 if let Some(handle) = self.log_tail_task.take() {
12043 handle.abort();
12044 }
12045 self.log_tail_session = self.log_tail_session.wrapping_add(1);
12046 self.throttle_until = None;
12049 self.consecutive_throttles = 0;
12050 self.prev_health.clear();
12055 self.prev_status.clear();
12056 self.prev_alerts = 0;
12057 self.newly_red.clear();
12058 self.newly_added.clear();
12059 self.health_delta.clear();
12060 self.status_delta.clear();
12061 self.armed_watchdogs.clear();
12069 self.watching_deploys.clear();
12072 self.deploy_snapshots.clear();
12076 self.undo_history.clear();
12080 self.tf_state = crate::terraform::load_from_cwd();
12087 self.refresh_tf_managed_envs();
12088 self.rebuild_view();
12089 self.table_state.select(None);
12090 self.status_message = Some(format!(
12091 "context: {} / {}",
12092 self.context.profile.as_deref().unwrap_or("default"),
12093 self.context.region
12094 ));
12095 self.error_message = None;
12096 self.arm_loading_linger();
12097 self.load_state = LoadState::Idle;
12098 self.persist_state();
12099 self.spawn_identity();
12100 self.spawn_refresh();
12101 }
12102 Err(msg) => {
12103 tracing::error!(error = %msg, "rebuild failed");
12104 self.arm_loading_linger();
12105 self.load_state = LoadState::Error;
12106 self.loading_since = None;
12107 self.error_message = Some(self.format_aws_error("context switch", &msg));
12108 }
12109 }
12110 }
12111
12112 fn move_scope_selection(&mut self, delta: i32) {
12113 match self.scope {
12114 Scope::Envs => self.move_selection(delta),
12115 Scope::Apps => {
12116 let n = self.applications.len();
12117 if n == 0 {
12118 self.app_table_state.select(None);
12119 return;
12120 }
12121 let cur = self.app_table_state.selected().unwrap_or(0) as i32;
12122 let next = (cur + delta).rem_euclid(n as i32) as usize;
12123 self.app_table_state.select(Some(next));
12124 }
12125 }
12126 }
12127
12128 fn scope_select_first(&mut self) {
12129 match self.scope {
12130 Scope::Envs => self.select_first(),
12131 Scope::Apps => {
12132 if !self.applications.is_empty() {
12133 self.app_table_state.select(Some(0));
12134 }
12135 }
12136 }
12137 }
12138
12139 fn scope_select_last(&mut self) {
12140 match self.scope {
12141 Scope::Envs => self.select_last(),
12142 Scope::Apps => {
12143 if !self.applications.is_empty() {
12144 self.app_table_state
12145 .select(Some(self.applications.len() - 1));
12146 }
12147 }
12148 }
12149 }
12150
12151 pub(crate) fn open_apps_action_menu(&mut self) {
12157 let Some(idx) = self.app_table_state.selected() else {
12158 return;
12159 };
12160 let Some(app_name) = self.applications.get(idx).map(|a| a.name.clone()) else {
12161 return;
12162 };
12163 let env_names: Vec<String> = self
12164 .environments
12165 .iter()
12166 .filter(|e| e.application == app_name)
12167 .map(|e| e.name.clone())
12168 .collect();
12169 if env_names.is_empty() {
12170 self.status_message = Some(format!(
12171 "application '{app_name}' has no envs — nothing to act on"
12172 ));
12173 return;
12174 }
12175 self.current_overlay = Some(Overlay::AppsActionMenu {
12176 app_name,
12177 env_names,
12178 cursor: 0,
12179 });
12180 }
12181
12182 fn handle_apps_action_menu_key(&mut self, key: KeyEvent) {
12187 let n_items = APPS_ACTION_ITEMS.len() as i32;
12188 match key.code {
12189 KeyCode::Esc | KeyCode::Char('q') => {
12190 self.current_overlay = None;
12191 }
12192 KeyCode::Down | KeyCode::Char('j') => {
12193 if let Some(Overlay::AppsActionMenu { cursor, .. }) = self.current_overlay.as_mut()
12194 {
12195 let cur = *cursor as i32;
12196 *cursor = (cur + 1).rem_euclid(n_items) as usize;
12197 }
12198 }
12199 KeyCode::Up | KeyCode::Char('k') => {
12200 if let Some(Overlay::AppsActionMenu { cursor, .. }) = self.current_overlay.as_mut()
12201 {
12202 let cur = *cursor as i32;
12203 *cursor = (cur - 1).rem_euclid(n_items) as usize;
12204 }
12205 }
12206 KeyCode::Enter => self.dispatch_apps_action_menu(),
12207 _ => {}
12208 }
12209 }
12210
12211 fn dispatch_apps_action_menu(&mut self) {
12212 let Some(Overlay::AppsActionMenu {
12213 app_name,
12214 env_names,
12215 cursor,
12216 }) = self.current_overlay.as_ref().cloned()
12217 else {
12218 return;
12219 };
12220 self.current_overlay = None;
12224 let item = match APPS_ACTION_ITEMS.get(cursor) {
12225 Some(it) => *it,
12226 None => return,
12227 };
12228 match item {
12229 AppsActionItem::Drill => {
12230 self.filter = app_name.clone();
12231 self.set_scope(Scope::Envs);
12232 self.rebuild_view();
12233 self.status_message = Some(format!("filtered envs to application '{app_name}'"));
12234 }
12235 AppsActionItem::BatchRebuild => {
12236 self.multi_selected = env_names.into_iter().collect();
12237 self.cmd_batch_action(Action::Rebuild);
12238 }
12239 AppsActionItem::BatchRestart => {
12240 self.multi_selected = env_names.into_iter().collect();
12241 self.cmd_batch_action(Action::RestartAppServer);
12242 }
12243 AppsActionItem::BatchDeploy => {
12244 self.multi_selected = env_names.into_iter().collect();
12248 self.mode = Mode::Command;
12249 self.command_input = "batch-deploy ".into();
12250 self.status_message = Some("type a version label and press enter".into());
12251 }
12252 AppsActionItem::OpenInConsole => {
12253 self.open_app_in_console();
12254 }
12255 }
12256 }
12257
12258 pub(crate) fn open_app_in_console(&mut self) {
12264 let Some(idx) = self.app_table_state.selected() else {
12265 self.status_message = Some("no application selected".into());
12266 return;
12267 };
12268 let Some(name) = self.applications.get(idx).map(|a| a.name.clone()) else {
12269 return;
12270 };
12271 let region = &self.context.region;
12272 let app_enc = urlencode(&name);
12273 let url = format!(
12274 "https://{region}.console.aws.amazon.com/elasticbeanstalk/home?region={region}#/application/overview?applicationName={app_enc}"
12275 );
12276 match open_url(&url) {
12277 Ok(()) => {
12278 self.status_message = Some(format!("opened {name} in browser"));
12279 }
12280 Err(e) => {
12281 self.error_message = Some(format!("couldn't open browser: {e}"));
12282 }
12283 }
12284 }
12285
12286 fn drill_into_app(&mut self) {
12287 let Some(idx) = self.app_table_state.selected() else {
12288 return;
12289 };
12290 let Some(name) = self.applications.get(idx).map(|a| a.name.clone()) else {
12291 return;
12292 };
12293 self.filter = name.clone();
12294 self.set_scope(Scope::Envs);
12295 self.rebuild_view();
12296 self.status_message = Some(format!("filtered envs to application '{name}'"));
12297 }
12298
12299 fn select_first(&mut self) {
12300 let rows = self.display_rows();
12301 if let Some(pos) = rows.iter().position(|r| matches!(r, DisplayRow::Env(_))) {
12302 self.table_state.select(Some(pos));
12303 }
12304 }
12305
12306 fn select_last(&mut self) {
12307 let rows = self.display_rows();
12308 if let Some(pos) = rows.iter().rposition(|r| matches!(r, DisplayRow::Env(_))) {
12309 self.table_state.select(Some(pos));
12310 }
12311 }
12312
12313 fn move_selection(&mut self, delta: i32) {
12314 let rows = self.display_rows();
12315 if rows.is_empty() {
12316 self.table_state.select(None);
12317 return;
12318 }
12319 let selectable: Vec<usize> = rows
12321 .iter()
12322 .enumerate()
12323 .filter_map(|(i, r)| matches!(r, DisplayRow::Env(_)).then_some(i))
12324 .collect();
12325 if selectable.is_empty() {
12326 self.table_state.select(None);
12327 return;
12328 }
12329 let current = self.table_state.selected().unwrap_or(selectable[0]);
12330 let pos_in_selectable = selectable.iter().position(|i| *i == current).unwrap_or(0) as i32;
12331 let next = (pos_in_selectable + delta).rem_euclid(selectable.len() as i32) as usize;
12332 self.table_state.select(Some(selectable[next]));
12333 }
12334
12335 pub fn display_rows(&self) -> &[DisplayRow] {
12336 &self.cached_display
12337 }
12338
12339 pub fn filtered_indexes(&self) -> &[usize] {
12340 &self.cached_filtered
12341 }
12342
12343 pub fn rebuild_view(&mut self) {
12346 self.cached_filtered.clear();
12348 if self.filter.is_empty() {
12349 self.cached_filtered.extend(0..self.environments.len());
12350 } else {
12351 let needle = self.filter.to_lowercase();
12352 for (i, e) in self.environments.iter().enumerate() {
12353 let alias_hit = self
12354 .aliases
12355 .get(&e.name)
12356 .map(|a| a.to_lowercase().contains(&needle))
12357 .unwrap_or(false);
12358 if e.name.to_lowercase().contains(&needle)
12359 || alias_hit
12360 || e.application.to_lowercase().contains(&needle)
12361 || e.health.to_lowercase().contains(&needle)
12362 || e.status.to_lowercase().contains(&needle)
12363 {
12364 self.cached_filtered.push(i);
12365 }
12366 }
12367 }
12368
12369 self.cached_display.clear();
12371 let mut prev_app: Option<&str> = None;
12372 for i in &self.cached_filtered {
12373 let e = &self.environments[*i];
12374 if self.grouped && prev_app.is_some() && prev_app != Some(e.application.as_str()) {
12375 self.cached_display.push(DisplayRow::Separator);
12376 }
12377 self.cached_display.push(DisplayRow::Env(*i));
12378 prev_app = Some(e.application.as_str());
12379 }
12380
12381 self.cached_app_colors = assign_app_colors(
12385 self.cached_filtered
12386 .iter()
12387 .map(|i| self.environments[*i].application.as_str()),
12388 &self.theme.app_palette,
12389 );
12390
12391 self.cached_stale_platforms.clear();
12396 if !self.latest_stacks.is_empty() {
12397 for e in &self.environments {
12398 if let Some(newer) =
12399 crate::aws::newer_stack_version(&e.solution_stack, &self.latest_stacks)
12400 {
12401 self.cached_stale_platforms.insert(e.name.clone(), newer);
12402 }
12403 }
12404 }
12405 }
12406
12407 fn apply_refresh(&mut self, result: Result<Vec<Environment>, String>) {
12408 match result {
12409 Ok(envs) => {
12410 let is_red =
12412 |h: &str| h.eq_ignore_ascii_case("Red") || h.eq_ignore_ascii_case("Severe");
12413 self.newly_red.clear();
12414 self.newly_added.clear();
12419 if !self.prev_health.is_empty() {
12420 for e in &envs {
12421 if !self.prev_health.contains_key(&e.name) {
12422 self.newly_added.insert(e.name.clone());
12423 }
12424 }
12425 }
12426 for e in &envs {
12427 let prev_red = self
12428 .prev_health
12429 .get(&e.name)
12430 .map(|h| is_red(h))
12431 .unwrap_or(false);
12432 if is_red(&e.health) && !prev_red {
12433 self.newly_red.insert(e.name.clone());
12434 tracing::warn!(
12440 env = %e.name,
12441 application = %e.application,
12442 health = %e.health,
12443 region = %self.context.region,
12444 "env transitioned into Red",
12445 );
12446 write_audit_line(
12447 self.context.account_id.as_deref(),
12448 self.context.profile.as_deref(),
12449 &self.context.region,
12450 &format!(
12451 "stage=event kind=red_transition env={} application={} health={}",
12452 e.name, e.application, e.health
12453 ),
12454 );
12455 }
12456 }
12457 self.health_delta = bucket_delta(&self.prev_health, &envs, |e| e.health.clone());
12459 self.status_delta = bucket_delta(&self.prev_status, &envs, |e| e.status.clone());
12460
12461 self.prev_health = envs
12462 .iter()
12463 .map(|e| (e.name.clone(), e.health.clone()))
12464 .collect();
12465 self.prev_status = envs
12466 .iter()
12467 .map(|e| (e.name.clone(), e.status.clone()))
12468 .collect();
12469
12470 let new_alerts = compute_red_alerts(&envs, &self.worker_dlq_depths);
12471 if self.notify_bell && new_alerts > self.prev_alerts {
12472 use std::io::Write;
12475 let mut err = std::io::stderr().lock();
12476 let _ = err.write_all(b"\x07");
12477 let _ = err.flush();
12478 }
12479 self.prev_alerts = new_alerts;
12480 self.alerts = new_alerts;
12481
12482 self.environments = envs;
12483 self.resort_envs();
12484
12485 let armed: Vec<(String, chrono::DateTime<chrono::Utc>)> = self
12501 .armed_watchdogs
12502 .iter()
12503 .map(|(env, w)| (env.clone(), w.deadline_at))
12504 .collect();
12505 let now = chrono::Utc::now();
12506 for (env_name, deadline_at) in armed {
12507 let (status, health) = self
12508 .environments
12509 .iter()
12510 .find(|e| e.name == env_name)
12511 .map(|e| (e.status.clone(), e.health.clone()))
12512 .unwrap_or_default();
12513 let healthy = deploy_settled_green(&status, &health);
12514 if healthy {
12515 self.armed_watchdogs.remove(&env_name);
12516 self.pin_status(format!(
12521 "auto-rollback for {env_name}: env reached Green, watchdog disarmed"
12522 ));
12523 } else if now >= deadline_at {
12524 self.dispatch_auto_rollback(env_name, health);
12528 }
12529 }
12531
12532 let watching: Vec<(String, chrono::DateTime<chrono::Utc>, String, u64)> = self
12540 .watching_deploys
12541 .iter()
12542 .map(|(env, w)| {
12543 let secs = (w.deadline_at - w.armed_at).num_seconds().max(0) as u64;
12544 (env.clone(), w.deadline_at, w.target_label.clone(), secs)
12545 })
12546 .collect();
12547 for (env_name, deadline_at, target_label, total_secs) in watching {
12548 let (status, health) = self
12549 .environments
12550 .iter()
12551 .find(|e| e.name == env_name)
12552 .map(|e| (e.status.clone(), e.health.clone()))
12553 .unwrap_or_default();
12554 let healthy = deploy_settled_green(&status, &health);
12555 if healthy {
12556 self.watching_deploys.remove(&env_name);
12557 let label_hint = if target_label.is_empty() {
12558 String::new()
12559 } else {
12560 format!(" ({target_label})")
12561 };
12562 self.pin_status(format!("✓ deploy reached Green: {env_name}{label_hint}"));
12563 } else if now >= deadline_at {
12564 self.watching_deploys.remove(&env_name);
12565 let label_hint = if target_label.is_empty() {
12566 String::new()
12567 } else {
12568 format!(" ({target_label})")
12569 };
12570 self.pin_error(format!(
12571 "deploy did not reach Green within {total_secs}s: {env_name}{label_hint} — status={status} health={health}"
12572 ));
12573 }
12574 }
12575
12576 let live: HashSet<String> =
12577 self.environments.iter().map(|e| e.name.clone()).collect();
12578 for e in &self.environments {
12579 let buf = self.history.entry(e.name.clone()).or_default();
12580 buf.push_back(e.health.clone());
12581 while buf.len() > HISTORY_CAP {
12582 buf.pop_front();
12583 }
12584 }
12585 self.history.retain(|k, _| live.contains(k));
12586
12587 self.arm_loading_linger();
12588 self.load_state = LoadState::Idle;
12589 self.loading_since = None;
12590 self.last_refresh = Some(chrono::Utc::now());
12591 self.consecutive_throttles = 0;
12594 self.throttle_until = None;
12595 if let Some((prev_status, prev_error)) = self.status_snapshot_at_refresh.take() {
12599 if !self.status_message_pinned && self.status_message == prev_status {
12603 self.status_message = None;
12604 }
12605 if self.error_message == prev_error {
12606 self.error_message = None;
12607 }
12608 } else if !self.status_message_pinned {
12609 self.status_message = None;
12610 self.error_message = None;
12611 }
12612 self.status_message_pinned = false;
12617 self.restore_or_clamp_selection();
12618 self.spawn_worker_queue_check();
12623 self.spawn_env_instance_counts();
12628 }
12629 Err(msg) => {
12630 tracing::error!(error = %msg, "refresh failed");
12631 self.arm_loading_linger();
12632 self.load_state = LoadState::Error;
12633 self.loading_since = None;
12634 self.status_snapshot_at_refresh = None;
12635 if is_throttling_error(&msg) {
12636 let backoff =
12637 throttle_backoff(self.refresh_interval, self.consecutive_throttles);
12638 self.consecutive_throttles = self.consecutive_throttles.saturating_add(1);
12639 self.throttle_until = Some(Instant::now() + backoff);
12640 self.error_message = Some(format!(
12641 "rate-limited by AWS — backing off {}s (^R to force)",
12642 backoff.as_secs().max(1)
12643 ));
12644 } else {
12645 self.error_message = Some(self.format_aws_error("refresh", &msg));
12646 }
12647 }
12648 }
12649 }
12650
12651 fn restore_or_clamp_selection(&mut self) {
12652 if self.cached_display.is_empty() {
12653 self.table_state.select(None);
12654 return;
12655 }
12656 let first_env_idx = self
12657 .cached_display
12658 .iter()
12659 .position(|r| matches!(r, DisplayRow::Env(_)))
12660 .unwrap_or(0);
12661 let pending = self.pending_select.take();
12662 if let Some(name) = pending {
12663 let pos = self.cached_display.iter().position(|r| match r {
12664 DisplayRow::Env(i) => self.environments[*i].name == name,
12665 DisplayRow::Separator => false,
12666 });
12667 if let Some(p) = pos {
12668 self.table_state.select(Some(p));
12669 return;
12670 }
12671 }
12672 let valid = self
12673 .table_state
12674 .selected()
12675 .is_some_and(|s| matches!(self.cached_display.get(s), Some(DisplayRow::Env(_))));
12676 if !valid {
12677 self.table_state.select(Some(first_env_idx));
12678 }
12679 }
12680
12681 fn format_aws_error(&self, op: &str, msg: &str) -> String {
12682 let lower = msg.to_lowercase();
12683 let sso_signals = [
12684 "expiredtoken",
12685 "expired token",
12686 "token has expired",
12687 "the security token included in the request is expired",
12688 "unable to load credentials",
12689 "no credentials in the property bag",
12690 "sso session has expired",
12691 ];
12692 if sso_signals.iter().any(|s| lower.contains(s)) {
12693 let profile = self
12694 .override_profile
12695 .clone()
12696 .or_else(|| self.context.profile.clone())
12697 .unwrap_or_else(|| "default".into());
12698 return format!(
12699 "credentials expired — run: aws sso login --profile {profile} (or refresh your creds, then press Ctrl-R)"
12700 );
12701 }
12702 format!("{op} failed: {msg}")
12703 }
12704}
12705
12706fn is_text_input(key: &KeyEvent) -> bool {
12707 let m = key.modifiers;
12709 !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
12710}
12711
12712#[derive(Debug, Clone, Copy)]
12713pub enum YankKind {
12714 Cname,
12715 Name,
12716}
12717
12718#[derive(Debug, Clone, Copy)]
12719pub enum DisplayRow {
12720 Env(usize),
12721 Separator,
12722}
12723
12724async fn collect_tail_logs(
12734 aws: Arc<AwsClient>,
12735 env_name: String,
12736 tx: mpsc::UnboundedSender<AppMsg>,
12737 gen: u64,
12738) -> std::result::Result<Vec<(String, String)>, String> {
12739 const POLL_ATTEMPTS: u32 = 12;
12740 const POLL_INTERVAL: Duration = Duration::from_secs(2);
12741
12742 aws.request_env_info_tail(&env_name)
12743 .await
12744 .map_err(|e| flatten_err("request_env_info_tail", e))?;
12745 let _ = tx.send(AppMsg::DetailLogsProgress {
12746 gen,
12747 env_name: env_name.clone(),
12748 stage: LogTailStage::Polling,
12749 attempt: 0,
12750 });
12751
12752 let mut urls: Vec<(String, String)> = Vec::new();
12753 for attempt in 1..=POLL_ATTEMPTS {
12754 tokio::time::sleep(POLL_INTERVAL).await;
12755 urls = aws
12756 .retrieve_env_info_tail(&env_name)
12757 .await
12758 .map_err(|e| flatten_err("retrieve_env_info_tail", e))?;
12759 if !urls.is_empty() {
12760 break;
12761 }
12762 let _ = tx.send(AppMsg::DetailLogsProgress {
12763 gen,
12764 env_name: env_name.clone(),
12765 stage: LogTailStage::Polling,
12766 attempt,
12767 });
12768 }
12769 if urls.is_empty() {
12770 return Err(format!(
12771 "no tail samples uploaded after {}s — instance role may lack s3:PutObject on the EB info bucket",
12772 POLL_ATTEMPTS as u64 * POLL_INTERVAL.as_secs()
12773 ));
12774 }
12775 let _ = tx.send(AppMsg::DetailLogsProgress {
12776 gen,
12777 env_name: env_name.clone(),
12778 stage: LogTailStage::Fetching,
12779 attempt: 0,
12780 });
12781
12782 let mut out = Vec::with_capacity(urls.len());
12783 for (instance_id, url) in urls {
12784 match AwsClient::fetch_url_text(&url).await {
12785 Ok(text) => out.push((instance_id, text)),
12786 Err(e) => out.push((instance_id, format!("(fetch failed: {e})"))),
12787 }
12788 }
12789 Ok(out)
12790}
12791
12792pub fn compute_traffic_warning(env: &Environment) -> Option<String> {
12798 let status_lower = env.status.to_lowercase();
12799 if status_lower.contains("updating") || status_lower.contains("launching") {
12800 return Some(format!("ACTIVE DEPLOY: status={}", env.status));
12801 }
12802 if status_lower.contains("terminating") {
12803 return Some(format!("env is {} already", env.status));
12804 }
12805 if let Some(updated) = env.updated {
12806 let dur = chrono::Utc::now().signed_duration_since(updated);
12807 if dur >= chrono::Duration::zero() && dur < chrono::Duration::minutes(5) {
12808 return Some(format!(
12809 "RECENT CHANGE: updated {}s ago",
12810 dur.num_seconds().max(0)
12811 ));
12812 }
12813 }
12814 if env.health.eq_ignore_ascii_case("Red") || env.health.eq_ignore_ascii_case("Severe") {
12815 return Some(format!("env is currently {}", env.health));
12816 }
12817 None
12818}
12819
12820pub(crate) fn compute_red_alerts(
12830 envs: &[crate::aws::Environment],
12831 worker_dlq_depths: &std::collections::HashMap<String, i64>,
12832) -> usize {
12833 envs.iter()
12834 .filter(|e| {
12835 let eb_red =
12836 e.health.eq_ignore_ascii_case("Red") || e.health.eq_ignore_ascii_case("Severe");
12837 let dlq_red = e.tier.eq_ignore_ascii_case("Worker")
12838 && worker_dlq_depths.get(&e.name).copied().unwrap_or(0) > 0;
12839 eb_red || dlq_red
12840 })
12841 .count()
12842}
12843
12844pub(crate) fn is_throttling_error(msg: &str) -> bool {
12845 let lower = msg.to_lowercase();
12846 [
12847 "throttling",
12848 "throttlingexception",
12849 "requestlimitexceeded",
12850 "too many requests",
12851 "rate exceeded",
12852 ]
12853 .iter()
12854 .any(|needle| lower.contains(needle))
12855}
12856
12857pub fn compute_loading_linger_target(
12866 loading_since: Option<Instant>,
12867 threshold: Duration,
12868 linger: Duration,
12869 now: Instant,
12870) -> Option<Instant> {
12871 let elapsed = loading_since.map(|t| now.duration_since(t))?;
12872 if elapsed >= threshold {
12873 Some(now + linger)
12874 } else {
12875 None
12876 }
12877}
12878
12879fn throttle_backoff(base: Duration, consecutive: u32) -> Duration {
12880 const MAX_BACKOFF: Duration = Duration::from_secs(300);
12881 let factor: u32 = 2u32.saturating_pow(consecutive.min(6).saturating_add(1));
12882 let scaled = base.saturating_mul(factor);
12883 scaled.min(MAX_BACKOFF)
12884}
12885
12886fn assign_app_colors<'a>(
12891 names: impl IntoIterator<Item = &'a str>,
12892 palette: &[ratatui::style::Color],
12893) -> HashMap<String, ratatui::style::Color> {
12894 let mut out: HashMap<String, ratatui::style::Color> = HashMap::new();
12895 if palette.is_empty() {
12896 return out;
12897 }
12898 for name in names {
12899 if !out.contains_key(name) {
12900 let idx = out.len() % palette.len();
12901 out.insert(name.to_string(), palette[idx]);
12902 }
12903 }
12904 out
12905}
12906
12907impl App {
12908 fn yank_event_at(&mut self, idx: usize) {
12909 let Some(ev) = self.event_panel.events.get(idx) else {
12910 self.event_panel.cursor = None;
12911 return;
12912 };
12913 let when = ev
12914 .at
12915 .map(|t| {
12916 t.with_timezone(&chrono::Local)
12917 .format("%Y-%m-%d %H:%M:%S")
12918 .to_string()
12919 })
12920 .unwrap_or_else(|| "—".into());
12921 let line = format!("{when} [{}] {} {}", ev.severity, ev.env, ev.message);
12922 match yank(&line) {
12923 Ok(()) => {
12924 self.status_message = Some(format!(
12925 "yanked event line ({} chars)",
12926 line.chars().count()
12927 ));
12928 }
12929 Err(e) => self.error_message = Some(format!("clipboard error: {e}")),
12930 }
12931 }
12932}
12933
12934pub fn previous_version_label(events: &[EbEvent], current: &str) -> Option<String> {
12943 events
12944 .iter()
12945 .filter_map(|e| e.version_label.as_deref())
12946 .filter(|v| !v.is_empty())
12947 .find(|v| *v != current)
12948 .map(|v| v.to_string())
12949}
12950
12951pub fn is_config_event(message: &str) -> bool {
12955 let m = message.to_ascii_lowercase();
12956 m.contains("version label")
12957 || m.contains("deploying")
12958 || m.contains("configuration")
12959 || m.contains("config setting")
12960}
12961
12962pub(crate) fn render_changes_overlay(env: &str, events: &[EbEvent]) -> String {
12966 let rows: Vec<&EbEvent> = events
12967 .iter()
12968 .filter(|e| is_config_event(&e.message))
12969 .collect();
12970 if rows.is_empty() {
12971 return format!(
12972 "Config change timeline — {env}\n\n\
12973 No deploy / config-change events in the recent window.\n\n\
12974 esc / q to close"
12975 );
12976 }
12977 let mut body = format!(
12978 "Config change timeline — {env}\n\
12979 {} change event(s), newest first.\n\n",
12980 rows.len()
12981 );
12982 for e in rows {
12983 let ts =
12984 e.at.map(|t| t.format("%Y-%m-%d %H:%M:%SZ").to_string())
12985 .unwrap_or_else(|| "—".into());
12986 let ver = e
12987 .version_label
12988 .as_deref()
12989 .map(|v| format!(" [{v}]"))
12990 .unwrap_or_default();
12991 body.push_str(&format!("{ts}{ver}\n {}\n\n", e.message));
12992 }
12993 body.push_str("esc / q to close");
12994 body
12995}
12996
12997#[derive(Debug, Clone, PartialEq)]
13003pub(crate) struct LineageRow {
13004 pub label: String,
13005 pub first_at: Option<chrono::DateTime<chrono::Utc>>,
13006 pub last_at: Option<chrono::DateTime<chrono::Utc>>,
13007}
13008
13009pub(crate) fn build_lineage(events: &[EbEvent]) -> Vec<LineageRow> {
13017 let mut oldest_first: Vec<&EbEvent> = events
13018 .iter()
13019 .filter(|e| {
13020 e.version_label
13021 .as_deref()
13022 .map(|v| !v.is_empty())
13023 .unwrap_or(false)
13024 })
13025 .collect();
13026 oldest_first.reverse();
13027 let mut rows: Vec<LineageRow> = Vec::new();
13028 for e in oldest_first {
13029 let label = e.version_label.clone().unwrap();
13031 match rows.last_mut() {
13032 Some(last) if last.label == label => {
13033 if let Some(t) = e.at {
13034 last.last_at = Some(t);
13035 }
13036 }
13037 _ => rows.push(LineageRow {
13038 label,
13039 first_at: e.at,
13040 last_at: e.at,
13041 }),
13042 }
13043 }
13044 rows.into_iter().rev().collect()
13045}
13046
13047pub(crate) fn format_lineage(env: &str, events: &[EbEvent]) -> String {
13053 let rows = build_lineage(events);
13054 if rows.is_empty() {
13055 return format!(
13056 "Deploy lineage — {env}\n\n\
13057 No deploys in the recent event window.\n\n\
13058 esc / q to close"
13059 );
13060 }
13061 let mut body = format!(
13062 "Deploy lineage — {env}\n\
13063 {} deploy(s), newest first. Δ = gap between deploy starts.\n\n",
13064 rows.len()
13065 );
13066 for (i, row) in rows.iter().enumerate() {
13067 let ts = row
13068 .first_at
13069 .map(|t| t.format("%Y-%m-%d %H:%M:%SZ").to_string())
13070 .unwrap_or_else(|| "—".into());
13071 body.push_str(&format!(" ▸ {ts} {}\n", row.label));
13072 if let (Some(f), Some(l)) = (row.first_at, row.last_at) {
13073 let span = l - f;
13074 if span.num_seconds() > 0 {
13075 body.push_str(&format!(
13076 " took {}\n",
13077 humanize_short_age(Duration::from_secs(span.num_seconds() as u64))
13078 ));
13079 }
13080 }
13081 if let Some(next) = rows.get(i + 1) {
13082 if let (Some(this), Some(prev)) = (row.first_at, next.first_at) {
13083 let gap = this - prev;
13084 if gap.num_seconds() > 0 {
13085 body.push_str(&format!(
13086 " Δ {} since previous deploy\n",
13087 humanize_short_age(Duration::from_secs(gap.num_seconds() as u64))
13088 ));
13089 }
13090 }
13091 }
13092 body.push('\n');
13093 }
13094 body.push_str("esc / q to close");
13095 body
13096}
13097
13098pub(crate) fn format_armed_rollbacks(
13103 armed: &std::collections::HashMap<String, ArmedWatchdog>,
13104 now: chrono::DateTime<chrono::Utc>,
13105) -> String {
13106 if armed.is_empty() {
13107 return "(no auto-rollbacks armed)\n\nesc / q to close".to_string();
13108 }
13109 let mut rows: Vec<&ArmedWatchdog> = armed.values().collect();
13110 rows.sort_by_key(|w| w.deadline_at);
13111 let mut body = String::new();
13112 body.push_str("ENV TARGET ARMED DEADLINE IN\n");
13113 body.push_str("─────────────────────────────────────────────────────────────────────────\n");
13114 for w in rows {
13115 let armed_ago = (now - w.armed_at).num_seconds().max(0) as u64;
13116 let remaining_secs = (w.deadline_at - now).num_seconds();
13117 let armed_str = humanize_short_age(Duration::from_secs(armed_ago));
13118 let remaining_str = if remaining_secs <= 0 {
13119 "fired / expired".to_string()
13120 } else {
13121 humanize_short_age(Duration::from_secs(remaining_secs as u64))
13122 };
13123 body.push_str(&format!(
13124 "{:<32} {:<17} {:>5} ago {}\n",
13125 truncate_armed_cell(&w.env_name, 32),
13126 truncate_armed_cell(&w.target_label, 17),
13127 armed_str,
13128 remaining_str,
13129 ));
13130 }
13131 body.push_str("\nesc / q to close");
13132 body
13133}
13134
13135pub(crate) fn soonest_armed_rollback(
13139 armed: &std::collections::HashMap<String, ArmedWatchdog>,
13140 now: chrono::DateTime<chrono::Utc>,
13141) -> Option<(String, String)> {
13142 let next = armed.values().min_by_key(|w| w.deadline_at)?;
13143 let remaining_secs = (next.deadline_at - now).num_seconds();
13144 let remaining_str = if remaining_secs <= 0 {
13145 "now".to_string()
13146 } else {
13147 humanize_short_age(Duration::from_secs(remaining_secs as u64))
13148 };
13149 Some((next.env_name.clone(), remaining_str))
13150}
13151
13152pub(crate) fn soonest_watching_deploy(
13157 watching: &std::collections::HashMap<String, WatchingDeploy>,
13158 now: chrono::DateTime<chrono::Utc>,
13159) -> Option<(String, String)> {
13160 let next = watching.values().min_by_key(|w| w.deadline_at)?;
13161 let remaining_secs = (next.deadline_at - now).num_seconds();
13162 let remaining_str = if remaining_secs <= 0 {
13163 "now".to_string()
13164 } else {
13165 humanize_short_age(Duration::from_secs(remaining_secs as u64))
13166 };
13167 Some((next.env_name.clone(), remaining_str))
13168}
13169
13170fn truncate_armed_cell(s: &str, n: usize) -> String {
13174 if s.chars().count() <= n {
13175 return s.to_string();
13176 }
13177 let mut out: String = s.chars().take(n.saturating_sub(1)).collect();
13178 out.push('…');
13179 out
13180}
13181
13182pub(crate) fn build_undo_entry(
13193 env_name: &str,
13194 original_summary: &str,
13195 to_set: &[(String, String, String)],
13196 to_remove: &[(String, String)],
13197 pre_write: &[(String, String, String)],
13198) -> UndoEntry {
13199 let lookup = |ns: &str, name: &str| -> Option<&String> {
13200 pre_write
13201 .iter()
13202 .find(|(n, k, _)| n == ns && k == name)
13203 .map(|(_, _, v)| v)
13204 };
13205 let mut reverse_set: Vec<(String, String, String)> = Vec::new();
13206 let mut reverse_remove: Vec<(String, String)> = Vec::new();
13207 for (ns, name, _) in to_set {
13211 match lookup(ns, name) {
13212 Some(prev) if !prev.is_empty() => {
13213 reverse_set.push((ns.clone(), name.clone(), prev.clone()));
13214 }
13215 _ => {
13216 reverse_remove.push((ns.clone(), name.clone()));
13217 }
13218 }
13219 }
13220 for (ns, name) in to_remove {
13225 if let Some(prev) = lookup(ns, name) {
13226 if !prev.is_empty() {
13227 reverse_set.push((ns.clone(), name.clone(), prev.clone()));
13228 }
13229 }
13230 }
13231 UndoEntry {
13232 env_name: env_name.to_string(),
13233 to_set: reverse_set,
13234 to_remove: reverse_remove,
13235 original_summary: original_summary.to_string(),
13236 captured_at: chrono::Utc::now(),
13237 }
13238}
13239
13240pub fn expand_command_alias(
13251 line: &str,
13252 aliases: &std::collections::HashMap<String, String>,
13253) -> String {
13254 let line = line.trim();
13255 if aliases.is_empty() || line.is_empty() {
13256 return line.to_string();
13257 }
13258 let mut parts = line.splitn(2, char::is_whitespace);
13259 let first = match parts.next() {
13260 Some(s) => s,
13261 None => return line.to_string(),
13262 };
13263 let Some(expansion) = aliases.get(first) else {
13264 return line.to_string();
13265 };
13266 match parts.next() {
13267 Some(rest) => format!("{expansion} {rest}"),
13268 None => expansion.clone(),
13269 }
13270}
13271
13272pub fn compute_unavailability_count(
13286 policy: &str,
13287 batch_size: i32,
13288 batch_size_type: &str,
13289 asg_max: i32,
13290) -> i32 {
13291 let asg_max = asg_max.max(1);
13292 match policy {
13293 p if p.eq_ignore_ascii_case("AllAtOnce") => asg_max,
13294 p if p.eq_ignore_ascii_case("Rolling") => {
13295 compute_batch_count(batch_size, batch_size_type, asg_max)
13296 }
13297 p if p.eq_ignore_ascii_case("RollingWithAdditionalBatch") => 0,
13300 p if p.eq_ignore_ascii_case("Immutable") => 0,
13301 p if p.eq_ignore_ascii_case("TrafficSplitting") => 0,
13302 _ => asg_max,
13306 }
13307}
13308
13309pub fn compute_batch_count(batch_size: i32, batch_size_type: &str, asg_max: i32) -> i32 {
13314 if batch_size_type.eq_ignore_ascii_case("Percentage") {
13315 let pct = batch_size.clamp(1, 100);
13316 let count = (asg_max * pct + 99) / 100;
13320 count.max(1).min(asg_max)
13321 } else {
13322 batch_size.max(1).min(asg_max)
13324 }
13325}
13326
13327pub fn format_unavailability_line(policy: &str, unavailable: i32, asg_max: i32) -> (String, bool) {
13331 let asg_max = asg_max.max(1);
13332 let caution = unavailable > 0;
13333 let plural = if unavailable == 1 {
13334 "instance"
13335 } else {
13336 "instances"
13337 };
13338 let body = if unavailable == 0 {
13339 format!("deploy plan: {policy} → no in-service unavailability")
13340 } else {
13341 format!("deploy plan: {policy} → max {unavailable}/{asg_max} {plural} unavailable")
13342 };
13343 (body, caution)
13344}
13345
13346pub fn extract_unavailability_inputs(
13351 opts: &[(String, String, String)],
13352) -> (String, i32, String, i32) {
13353 let get = |ns: &str, name: &str| -> Option<&String> {
13354 opts.iter()
13355 .find(|(n, k, _)| n == ns && k == name)
13356 .map(|(_, _, v)| v)
13357 };
13358 let policy = get("aws:elasticbeanstalk:command", "DeploymentPolicy")
13359 .cloned()
13360 .filter(|s| !s.is_empty())
13361 .unwrap_or_else(|| "AllAtOnce".to_string());
13362 let batch_size = get("aws:elasticbeanstalk:command", "BatchSize")
13363 .and_then(|s| s.parse::<i32>().ok())
13364 .unwrap_or(1);
13365 let batch_size_type = get("aws:elasticbeanstalk:command", "BatchSizeType")
13366 .cloned()
13367 .filter(|s| !s.is_empty())
13368 .unwrap_or_else(|| "Fixed".to_string());
13369 let asg_max = get("aws:autoscaling:asg", "MaxSize")
13370 .and_then(|s| s.parse::<i32>().ok())
13371 .unwrap_or(1);
13372 (policy, batch_size, batch_size_type, asg_max)
13373}
13374
13375pub fn build_health_check_probe_url(cname: &str, path: &str) -> String {
13384 let path = if path.starts_with('/') {
13385 path.to_string()
13386 } else if path.is_empty() {
13387 "/".to_string()
13388 } else {
13389 format!("/{path}")
13390 };
13391 format!("http://{cname}{path}")
13392}
13393
13394pub(crate) async fn run_health_check_probe(url: &str) -> Result<(), String> {
13401 use tokio::process::Command;
13402 let out = Command::new("curl")
13403 .args([
13404 "-s",
13405 "-o",
13406 "/dev/null",
13407 "-L",
13408 "--max-time",
13409 "2",
13410 "-w",
13411 "%{http_code}",
13412 "-I",
13413 ])
13414 .arg(url)
13415 .output()
13416 .await
13417 .map_err(|e| format!("could not invoke curl: {e}"))?;
13418 if !out.status.success() {
13419 let stderr = String::from_utf8_lossy(&out.stderr);
13423 let stderr = stderr.trim();
13424 if !stderr.is_empty() {
13425 return Err(stderr
13426 .lines()
13427 .next()
13428 .unwrap_or("transport error")
13429 .to_string());
13430 }
13431 return Err(format!("curl exit {}", out.status.code().unwrap_or(-1)));
13432 }
13433 let code_str = String::from_utf8_lossy(&out.stdout);
13434 let code: u16 = code_str
13435 .trim()
13436 .parse()
13437 .map_err(|_| format!("unparseable status `{}`", code_str.trim()))?;
13438 classify_health_check_status(code)
13439}
13440
13441pub(crate) fn classify_health_check_status(code: u16) -> Result<(), String> {
13444 match code {
13445 200..=299 => Ok(()),
13446 0 => Err("no response (transport error)".into()),
13447 300..=399 => Err(format!("HTTP {code} (redirect — curl was told to follow)")),
13448 400..=599 => Err(format!("HTTP {code}")),
13453 _ => Err(format!("HTTP {code}")),
13456 }
13457}
13458
13459pub fn deploy_settled_green(status: &str, health: &str) -> bool {
13474 status.eq_ignore_ascii_case("Ready")
13475 && (health.eq_ignore_ascii_case("Green") || health.eq_ignore_ascii_case("Ok"))
13476}
13477
13478pub fn humanize_short_age(d: Duration) -> String {
13479 let secs = d.as_secs();
13480 if secs < 60 {
13481 format!("{secs}s")
13482 } else if secs < 3600 {
13483 format!("{}m", secs / 60)
13484 } else if secs < 86_400 {
13485 format!("{}h", secs / 3600)
13486 } else {
13487 format!("{}d", secs / 86_400)
13488 }
13489}
13490
13491pub fn parse_tag_args(rest: &[&str]) -> Option<(String, String)> {
13496 let key = (*rest.first()?).to_string();
13497 if rest.len() < 2 {
13498 return None;
13499 }
13500 let value = rest[1..].join(" ");
13501 if key.is_empty() || value.is_empty() {
13502 return None;
13503 }
13504 Some((key, value))
13505}
13506
13507pub fn delta_toast_key(text: &str) -> Option<String> {
13512 let trimmed = text.trim_start();
13513 let mut chars = trimmed.chars();
13514 let first = chars.next()?;
13515 if first != '▲' && first != '▼' {
13516 return None;
13517 }
13518 let rest: String = chars.collect();
13519 let first_rest = rest.chars().next()?;
13521 if !first_rest.is_ascii_digit() {
13522 return None;
13523 }
13524 let bucket_start = rest.find(|c: char| !c.is_ascii_digit())?;
13525 let after_digits = &rest[bucket_start..];
13526 let bucket = after_digits.trim_start();
13527 if bucket.is_empty() || !bucket.starts_with(|c: char| c.is_ascii_alphabetic()) {
13528 return None;
13529 }
13530 let word: String = bucket
13531 .chars()
13532 .take_while(|c| c.is_ascii_alphabetic())
13533 .collect();
13534 Some(word)
13535}
13536
13537fn yank(text: &str) -> std::result::Result<(), String> {
13538 let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?;
13539 cb.set_text(text.to_string()).map_err(|e| e.to_string())
13540}
13541
13542#[derive(Copy, Clone, Debug)]
13551enum MultiSelectFlavour {
13552 Subnets,
13553 ElbSubnets,
13557 SecurityGroups,
13558}
13559
13560async fn load_multi_select(
13564 aws: Arc<crate::aws::AwsClient>,
13565 app_name: &str,
13566 env_name: &str,
13567 flavour: MultiSelectFlavour,
13568) -> Result<MultiSelectOptions, String> {
13569 let ctx = aws
13570 .fetch_env_vpc_context(app_name, env_name)
13571 .await
13572 .map_err(|e| flatten_err("fetch_env_vpc_context", e))?;
13573 let Some(vpc_id) = ctx.vpc_id.as_deref() else {
13574 return Err("env has no VPC id in its option settings — using account-default VPC?".into());
13575 };
13576 match flavour {
13577 MultiSelectFlavour::Subnets | MultiSelectFlavour::ElbSubnets => {
13578 let subnets = aws
13579 .list_subnets_in_vpc(vpc_id)
13580 .await
13581 .map_err(|e| flatten_err("list_subnets_in_vpc", e))?;
13582 let mut options = Vec::with_capacity(subnets.len());
13583 let mut annotations = Vec::with_capacity(subnets.len());
13584 for s in subnets {
13585 options.push(s.id.clone());
13586 let mut annot = format!("({} · {}", s.availability_zone, s.cidr_block);
13587 if let Some(name) = s.name_tag.as_ref().filter(|n| !n.is_empty()) {
13588 annot.push_str(" · ");
13589 annot.push_str(name);
13590 }
13591 annot.push(')');
13592 annotations.push(annot);
13593 }
13594 let initial = match flavour {
13595 MultiSelectFlavour::ElbSubnets => ctx.elb_subnets,
13596 _ => ctx.subnets,
13597 };
13598 Ok(MultiSelectOptions {
13599 options,
13600 annotations,
13601 initial,
13602 })
13603 }
13604 MultiSelectFlavour::SecurityGroups => {
13605 let groups = aws
13606 .list_security_groups_in_vpc(vpc_id)
13607 .await
13608 .map_err(|e| flatten_err("list_security_groups_in_vpc", e))?;
13609 let mut options = Vec::with_capacity(groups.len());
13610 let mut annotations = Vec::with_capacity(groups.len());
13611 for g in groups {
13612 options.push(g.id.clone());
13613 let desc_suffix = if g.description.is_empty() {
13614 String::new()
13615 } else {
13616 format!(" — {}", g.description)
13617 };
13618 annotations.push(format!("({}{desc_suffix})", g.group_name));
13619 }
13620 Ok(MultiSelectOptions {
13621 options,
13622 annotations,
13623 initial: ctx.security_groups,
13624 })
13625 }
13626 }
13627}
13628
13629async fn load_listener_certs(
13633 aws: Arc<crate::aws::AwsClient>,
13634 app_name: &str,
13635 env_name: &str,
13636 port: &str,
13637) -> Result<MultiSelectOptions, String> {
13638 let certs = aws
13639 .list_certificates()
13640 .await
13641 .map_err(|e| flatten_err("list_certificates", e))?;
13642 let listeners = aws
13643 .fetch_env_listeners(app_name, env_name)
13644 .await
13645 .map_err(|e| flatten_err("fetch_env_listeners", e))?;
13646 let initial: Vec<String> = listeners
13647 .iter()
13648 .find(|(p, opt, _)| p == port && opt == "SSLCertificateArns")
13649 .map(|(_, _, v)| {
13650 v.split(',')
13651 .map(|s| s.trim().to_string())
13652 .filter(|s| !s.is_empty())
13653 .collect()
13654 })
13655 .unwrap_or_default();
13656 let mut options = Vec::with_capacity(certs.len());
13657 let mut annotations = Vec::with_capacity(certs.len());
13658 for c in certs {
13659 options.push(c.arn);
13660 annotations.push(if c.domain.is_empty() {
13661 String::new()
13662 } else {
13663 format!("({})", c.domain)
13664 });
13665 }
13666 Ok(MultiSelectOptions {
13667 options,
13668 annotations,
13669 initial,
13670 })
13671}
13672
13673fn merge_app_latest_versions(prev: &[Application], next: &mut [Application]) {
13683 let by_name: std::collections::HashMap<
13684 &str,
13685 (&Option<String>, &Option<chrono::DateTime<chrono::Utc>>),
13686 > = prev
13687 .iter()
13688 .map(|a| {
13689 (
13690 a.name.as_str(),
13691 (&a.latest_version_label, &a.latest_version_created),
13692 )
13693 })
13694 .collect();
13695 for app in next.iter_mut() {
13696 let Some((label, created)) = by_name.get(app.name.as_str()) else {
13697 continue;
13698 };
13699 if app.latest_version_label.is_none() {
13700 app.latest_version_label = (*label).clone();
13701 }
13702 if app.latest_version_created.is_none() {
13703 app.latest_version_created = **created;
13704 }
13705 }
13706}
13707
13708pub(crate) fn redact_for_log(value: &str, on: bool) -> String {
13713 if !on || value.is_empty() || value == "—" {
13714 return value.to_string();
13715 }
13716 "▓".repeat(value.chars().count())
13717}
13718
13719#[derive(Debug, Clone, PartialEq, Eq)]
13725pub enum UpdateKind {
13726 Deploy { version_label: Option<String> },
13729 Config,
13731 Scale,
13733 Platform,
13735 Generic,
13738}
13739
13740pub fn classify_update_kind(events: &[crate::aws::Event]) -> UpdateKind {
13746 for e in events {
13747 let lower = e.message.to_lowercase();
13748 if lower.contains("version label") {
13754 return UpdateKind::Deploy {
13755 version_label: extract_quoted_after(&e.message, "version label"),
13756 };
13757 }
13758 if lower.contains("deploying") && lower.contains("version") {
13759 return UpdateKind::Deploy {
13760 version_label: extract_quoted_after(&e.message, "version"),
13761 };
13762 }
13763 if lower.contains("platform") && (lower.contains("updat") || lower.contains("upgrad")) {
13765 return UpdateKind::Platform;
13766 }
13767 if lower.contains("configuration") && lower.contains("updat") {
13769 return UpdateKind::Config;
13770 }
13771 if (lower.contains("adding") || lower.contains("removing")) && lower.contains("instance") {
13773 return UpdateKind::Scale;
13774 }
13775 }
13776 UpdateKind::Generic
13777}
13778
13779fn extract_quoted_after(msg: &str, needle: &str) -> Option<String> {
13785 let lower = msg.to_lowercase();
13786 let needle_lower = needle.to_lowercase();
13787 let after = lower.find(&needle_lower)? + needle_lower.len();
13788 let tail = msg.get(after..)?;
13789 let start = tail.find('\'')?;
13790 let body = &tail[start + 1..];
13791 let end = body.find('\'')?;
13792 Some(body[..end].to_string())
13793}
13794
13795fn flatten_err(op: &str, e: color_eyre::eyre::Report) -> String {
13796 tracing::error!(target: "ebman::aws", op = op, error = ?e, "aws call failed");
13797 flatten_err_to_string(&e)
13798}
13799
13800pub(crate) fn flatten_err_to_string(e: &color_eyre::eyre::Report) -> String {
13809 let display = e.to_string();
13810 let dbg_lower = format!("{e:?}").to_lowercase();
13811 const THROTTLING_TOKENS: &[&str] = &[
13814 "throttling",
13815 "throttlingexception",
13816 "requestlimitexceeded",
13817 "too many requests",
13818 "rate exceeded",
13819 ];
13820 if THROTTLING_TOKENS.iter().any(|t| dbg_lower.contains(t)) {
13821 return format!("ThrottlingException: {display}");
13822 }
13823 const ACCESS_TOKENS: &[&str] = &[
13827 "accessdenied",
13828 "accessdeniedexception",
13829 "unauthorizedoperation",
13830 "not authorized to perform",
13831 ];
13832 if ACCESS_TOKENS.iter().any(|t| dbg_lower.contains(t)) {
13833 return format!("AccessDenied: {display}");
13834 }
13835 const NOTFOUND_TOKENS: &[&str] = &[
13839 "resourcenotfoundexception",
13840 "nosuchentity",
13841 "nosuchbucket",
13842 "nosuchkey",
13843 "queuedoesnotexist",
13844 "environmentnotfound",
13845 "applicationversionnotfound",
13846 ];
13847 if NOTFOUND_TOKENS.iter().any(|t| dbg_lower.contains(t)) {
13848 return format!("NotFound: {display}");
13849 }
13850 const DEPENDENCY_TOKENS: &[&str] = &[
13852 "dependencyviolation",
13853 "resourceinuse",
13854 "operationinprogressexception",
13855 "invalidrequestexception",
13856 ];
13857 if DEPENDENCY_TOKENS.iter().any(|t| dbg_lower.contains(t)) {
13858 return format!("Conflict: {display}");
13859 }
13860 if dbg_lower.contains("expiredtoken") || dbg_lower.contains("tokenexpired") {
13864 return format!("ExpiredToken: {display}");
13865 }
13866 display
13867}
13868
13869fn parse_sort(raw: Option<&str>) -> (SortKey, bool) {
13870 let Some(s) = raw else {
13871 return (SortKey::App, false);
13872 };
13873 let (k, dir) = s.split_once(':').unwrap_or((s, "asc"));
13874 let key = SortKey::parse(k.trim()).unwrap_or(SortKey::App);
13875 let desc = dir.trim().eq_ignore_ascii_case("desc");
13876 (key, desc)
13877}
13878
13879fn health_rank(h: &str) -> u8 {
13880 match h.to_lowercase().as_str() {
13881 "green" | "ok" => 0,
13882 "grey" | "gray" | "info" | "no data" | "pending" => 1,
13883 "yellow" | "warning" => 2,
13884 "red" | "severe" | "degraded" => 3,
13885 _ => 4,
13886 }
13887}
13888
13889fn parse_toggle(arg: Option<&str>, current: bool) -> bool {
13890 match arg.map(str::to_ascii_lowercase).as_deref() {
13891 Some("on") | Some("true") | Some("yes") | Some("1") => true,
13892 Some("off") | Some("false") | Some("no") | Some("0") => false,
13893 _ => !current,
13894 }
13895}
13896
13897fn scroll_apply(current: u16, delta: i32) -> u16 {
13898 let next = current as i32 + delta;
13899 next.max(0) as u16
13900}
13901
13902fn build_palette_items(app: &App) -> Vec<PaletteItem> {
13910 let mut out: Vec<PaletteItem> = Vec::new();
13911
13912 for c in crate::commands::COMMANDS {
13917 match c.kind {
13918 crate::commands::CommandKind::ZeroArg => {
13919 out.push(PaletteItem {
13920 label: format!(":{}", c.name),
13921 detail: c.help.to_string(),
13922 kind_tag: "cmd",
13923 action: PaletteAction::RunCommand(c.name.to_string()),
13924 });
13925 }
13926 crate::commands::CommandKind::Prefill(prefix) => {
13927 out.push(PaletteItem {
13928 label: format!(":{}", prefix.trim_end()),
13929 detail: c.help.to_string(),
13930 kind_tag: "cmd",
13931 action: PaletteAction::PrefillCommand(prefix.to_string()),
13932 });
13933 }
13934 }
13935 }
13936
13937 for e in &app.environments {
13939 let alias = app
13940 .aliases
13941 .get(&e.name)
13942 .map(|a| format!(" ({a})"))
13943 .unwrap_or_default();
13944 out.push(PaletteItem {
13945 label: e.name.clone(),
13946 detail: format!("env in {}{alias} · {}", e.application, e.health),
13947 kind_tag: "env",
13948 action: PaletteAction::JumpEnv(e.name.clone()),
13949 });
13950 }
13951
13952 for name in app.saved_views.keys() {
13954 out.push(PaletteItem {
13955 label: format!("view: {name}"),
13956 detail: "load saved view".into(),
13957 kind_tag: "view",
13958 action: PaletteAction::LoadView(name.clone()),
13959 });
13960 }
13961
13962 for (name, plugin) in &app.plugins {
13964 out.push(PaletteItem {
13965 label: format!(":{name}"),
13966 detail: plugin
13967 .description
13968 .clone()
13969 .unwrap_or_else(|| format!("plugin: {}", plugin.template)),
13970 kind_tag: "plugin",
13971 action: PaletteAction::RunCommand(name.clone()),
13972 });
13973 }
13974
13975 out
13976}
13977
13978fn palette_score(needle: &str, label: &str, detail: &str) -> Option<isize> {
13982 if needle.is_empty() {
13983 return Some(0);
13984 }
13985 let l = label.to_lowercase();
13986 let d = detail.to_lowercase();
13987 if let Some(i) = l.find(needle) {
13988 return Some(i as isize);
13989 }
13990 if let Some(i) = d.find(needle) {
13991 return Some(1_000 + i as isize);
13992 }
13993 None
13994}
13995
13996fn bucket_delta<F>(
13997 prev: &HashMap<String, String>,
13998 next: &[Environment],
13999 accessor: F,
14000) -> Vec<(String, i32)>
14001where
14002 F: Fn(&Environment) -> String,
14003{
14004 let mut prev_counts: BTreeMap<String, i32> = BTreeMap::new();
14010 let mut next_counts: BTreeMap<String, i32> = BTreeMap::new();
14011 for e in next {
14012 if let Some(prev_bucket) = prev.get(&e.name) {
14013 *prev_counts.entry(prev_bucket.clone()).or_insert(0) += 1;
14014 *next_counts.entry(accessor(e)).or_insert(0) += 1;
14015 }
14016 }
14017 let mut keys: BTreeMap<String, ()> = BTreeMap::new();
14018 for k in prev_counts.keys().chain(next_counts.keys()) {
14019 keys.insert(k.clone(), ());
14020 }
14021 keys.into_keys()
14022 .filter_map(|k| {
14023 let p = *prev_counts.get(&k).unwrap_or(&0);
14024 let n = *next_counts.get(&k).unwrap_or(&0);
14025 let d = n - p;
14026 if d != 0 {
14027 Some((k, d))
14028 } else {
14029 None
14030 }
14031 })
14032 .collect()
14033}
14034
14035pub fn format_env_vars(vars: &[(String, String)]) -> String {
14039 if vars.is_empty() {
14040 return "(no env vars set)".into();
14041 }
14042 let key_width = vars
14043 .iter()
14044 .map(|(k, _)| k.chars().count())
14045 .max()
14046 .unwrap_or(0)
14047 .clamp(8, 40);
14048 let mut out = String::new();
14049 for (k, v) in vars {
14050 let rendered = if v.is_empty() {
14051 "\"\"".to_string()
14052 } else {
14053 v.clone()
14054 };
14055 out.push_str(&format!("{k:<key_width$} = {rendered}\n"));
14056 }
14057 out
14058}
14059
14060pub fn parse_metric_extra_args(args: &[&str]) -> (String, Vec<(String, String)>) {
14066 let mut stat: Option<String> = None;
14067 let mut dims: Vec<(String, String)> = Vec::new();
14068 for tok in args {
14069 if tok.contains('=') {
14070 for kv in tok.split(',') {
14071 if let Some((k, v)) = kv.split_once('=') {
14072 let k = k.trim();
14073 let v = v.trim();
14074 if !k.is_empty() && !v.is_empty() {
14075 dims.push((k.to_string(), v.to_string()));
14076 }
14077 }
14078 }
14079 } else if stat.is_none() {
14080 stat = Some(tok.to_string());
14081 }
14082 }
14083 (stat.unwrap_or_else(|| "Average".into()), dims)
14084}
14085
14086pub fn parse_s3_url(raw: &str) -> Option<(String, String)> {
14090 let rest = raw.strip_prefix("s3://")?;
14091 let (bucket, key) = rest.split_once('/')?;
14092 if bucket.is_empty() || key.is_empty() {
14093 return None;
14094 }
14095 Some((bucket.to_string(), key.to_string()))
14096}
14097
14098pub fn expand_tilde(path: &str) -> String {
14102 if let Some(rest) = path.strip_prefix("~/") {
14103 if let Some(home) = std::env::var_os("HOME") {
14104 let mut p = std::path::PathBuf::from(home);
14105 p.push(rest);
14106 return p.display().to_string();
14107 }
14108 }
14109 path.to_string()
14110}
14111
14112pub fn derive_version_label(path: &str, unix_ts: i64) -> String {
14117 let stem = std::path::Path::new(path)
14118 .file_stem()
14119 .and_then(|s| s.to_str())
14120 .unwrap_or("bundle");
14121 let sanitised: String = stem
14122 .chars()
14123 .map(|c| {
14124 if c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.' {
14125 c
14126 } else {
14127 '_'
14128 }
14129 })
14130 .collect();
14131 format!("{sanitised}_{unix_ts}")
14132}
14133
14134#[allow(clippy::too_many_arguments)]
14139fn finish_deploy_from_local(
14140 tx: &tokio::sync::mpsc::UnboundedSender<AppMsg>,
14141 gen: u64,
14142 env_name: String,
14143 label: String,
14144 summary: String,
14145 account: Option<&str>,
14146 profile: Option<&str>,
14147 region: &str,
14148 result: Result<(), String>,
14149) {
14150 let outcome = match &result {
14151 Ok(()) => format!(
14152 "stage=completed action=DeployFromLocal target={env_name} label={label} outcome=ok"
14153 ),
14154 Err(e) => format!(
14155 "stage=completed action=DeployFromLocal target={env_name} label={label} outcome=err err=\"{}\"",
14156 crate::audit::escape_value(e)
14157 ),
14158 };
14159 write_audit_line(account, profile, region, &outcome);
14160 let _ = tx.send(AppMsg::DeployFromLocal {
14161 gen,
14162 env_name,
14163 label,
14164 summary,
14165 result,
14166 });
14167}
14168
14169pub fn pick_default_log_group(groups: &[String]) -> Option<String> {
14175 const PRIORITIES: &[&str] = &[
14176 "/var/log/web.stdout.log",
14177 "/var/log/eb-engine.log",
14178 "/var/log/eb-hooks.log",
14179 "/var/log/nginx/access.log",
14180 ];
14181 for needle in PRIORITIES {
14182 if let Some(g) = groups.iter().find(|g| g.ends_with(needle)) {
14183 return Some(g.clone());
14184 }
14185 }
14186 groups.first().cloned()
14187}
14188
14189pub fn parse_named_arg<T: std::str::FromStr>(rest: &[&str], flag: &str) -> Option<T> {
14194 let pos = rest.iter().position(|s| *s == flag)?;
14195 rest.get(pos + 1).and_then(|v| v.parse().ok())
14196}
14197
14198pub fn format_org_accounts(
14222 accounts: &[crate::aws::OrgAccount],
14223 configured: &std::collections::HashMap<String, String>,
14224) -> String {
14225 if accounts.is_empty() {
14226 return "no accounts returned by organizations:ListAccounts\n\nesc / q to close".into();
14227 }
14228 let mut out = String::new();
14229 out.push_str(&format!(
14230 "Org accounts ({})\n────────────────────\n\n",
14231 accounts.len()
14232 ));
14233 let max_name = accounts
14234 .iter()
14235 .map(|a| a.name.len())
14236 .max()
14237 .unwrap_or(0)
14238 .min(28);
14239 for a in accounts {
14240 let switchable = configured
14241 .keys()
14242 .find(|n| {
14243 n.eq_ignore_ascii_case(&a.name)
14244 || n.eq_ignore_ascii_case(&a.id)
14245 || n.eq_ignore_ascii_case(&format!("acct-{}", a.id))
14246 })
14247 .cloned();
14248 let switch_hint = match switchable {
14249 Some(n) => format!(" :account {n}"),
14250 None => String::new(),
14251 };
14252 let status_marker = match a.status.as_str() {
14253 "ACTIVE" => "●",
14254 "SUSPENDED" => "⊘",
14255 _ => "○",
14256 };
14257 out.push_str(&format!(
14258 " {status_marker} {name:<width$} {id} [{status}]{switch_hint}\n",
14259 name = a.name,
14260 width = max_name,
14261 id = a.id,
14262 status = a.status,
14263 ));
14264 if let Some(email) = a.email.as_ref() {
14265 out.push_str(&format!(
14266 " {pad:<width$} ↳ {email}\n",
14267 pad = "",
14268 width = max_name,
14269 ));
14270 }
14271 }
14272 out.push('\n');
14273 out.push_str(
14274 "To switch into an account, add `accounts.NAME.role_arn = …` to config.toml\n\
14275 then use `:account NAME`. esc / q to close.",
14276 );
14277 out
14278}
14279
14280pub fn format_deploy_preview(
14281 env_name: &str,
14282 current_label: &str,
14283 candidate_label: &str,
14284 versions: &[crate::aws::AppVersion],
14285) -> String {
14286 let now = chrono::Utc::now();
14287 let humanize = |d: Option<chrono::DateTime<chrono::Utc>>| -> String {
14288 d.map(|t| {
14289 let dur = now.signed_duration_since(t);
14290 let secs = dur.num_seconds().max(0);
14291 if secs < 3600 {
14292 format!("{}m ago", secs / 60)
14293 } else if secs < 86_400 {
14294 format!("{}h ago", secs / 3600)
14295 } else {
14296 format!("{}d ago", secs / 86_400)
14297 }
14298 })
14299 .unwrap_or_else(|| "—".into())
14300 };
14301 let candidate = versions.iter().find(|v| v.label == candidate_label);
14302 let current = if current_label.is_empty() {
14303 None
14304 } else {
14305 versions.iter().find(|v| v.label == current_label)
14306 };
14307 let mut out = String::new();
14308 out.push_str(&format!("env: {env_name}\n"));
14309 out.push_str(&format!(
14310 "current: {}{}\n",
14311 if current_label.is_empty() {
14312 "(none deployed)".to_string()
14313 } else {
14314 current_label.to_string()
14315 },
14316 match current.and_then(|v| v.created) {
14317 Some(t) => format!(" ({})", humanize(Some(t))),
14318 None => String::new(),
14319 }
14320 ));
14321 out.push_str(&format!("candidate: {candidate_label}"));
14322 match candidate {
14323 Some(v) => {
14324 out.push_str(&format!(" ({})\n", humanize(v.created)));
14325 if !v.description.is_empty() {
14326 out.push_str(&format!("description: {}\n", v.description));
14327 }
14328 }
14329 None => {
14330 out.push_str("\n\n");
14331 out.push_str(&format!(
14332 "⚠ candidate label '{candidate_label}' not found in this app's version list — \
14333 deploy will fail. Run :versions to see available labels.\n"
14334 ));
14335 return out;
14336 }
14337 }
14338 if let (Some(cand), Some(curr)) = (
14342 candidate.and_then(|v| v.created),
14343 current.and_then(|v| v.created),
14344 ) {
14345 if cand < curr {
14346 let secs = curr.signed_duration_since(cand).num_seconds().max(0) as u32;
14347 let diff = if secs < 3600 {
14348 format!("{}m", secs / 60)
14349 } else if secs < 86_400 {
14350 format!("{}h", secs / 3600)
14351 } else {
14352 format!("{}d", secs / 86_400)
14353 };
14354 out.push('\n');
14355 out.push_str(&format!(
14356 "⚠ candidate is {diff} older than the currently-deployed version — \
14357 looks like a rollback. Confirm intent.\n"
14358 ));
14359 }
14360 }
14361 out.push_str("\nrun :deploy without --preview to dispatch, or :versions for the full list.\n");
14362 out
14363}
14364
14365pub fn format_app_versions(
14366 versions: &[crate::aws::AppVersion],
14367 deployed_label: Option<&str>,
14368 limit: usize,
14369) -> String {
14370 let mut out = String::new();
14371 let total = versions.len();
14372 let shown = total.min(limit);
14373 if total > limit {
14374 out.push_str(&format!(
14375 "showing {shown} of {total} (newest first; deploy older with `:deploy LABEL`)\n\n",
14376 ));
14377 }
14378 for v in versions.iter().take(limit) {
14379 let desc = v
14383 .description
14384 .strip_prefix("Application version created from ")
14385 .unwrap_or(&v.description);
14386 let marker = if deployed_label == Some(v.label.as_str()) {
14387 "▶ "
14388 } else {
14389 " "
14390 };
14391 let suffix = if deployed_label == Some(v.label.as_str()) {
14392 " ◀ deployed"
14393 } else {
14394 ""
14395 };
14396 if desc.is_empty() {
14397 out.push_str(&format!("{marker}{}{}\n", v.label, suffix));
14398 } else {
14399 out.push_str(&format!("{marker}{} {desc}{}\n", v.label, suffix));
14400 }
14401 }
14402 out.push('\n');
14403 out.push_str("Use `:deploy <label>` to ship one to the selected env.");
14404 out
14405}
14406
14407pub fn alarm_kind_to_metric(kind: &str) -> Option<(&'static str, &'static str, &'static str)> {
14413 match kind {
14414 "health" => Some(("EnvironmentHealth", "LessThanOrEqualToThreshold", "Maximum")),
14415 "4xx" | "req4xx" => Some(("ApplicationRequests4xx", "GreaterThanThreshold", "Sum")),
14416 "5xx" | "req5xx" => Some(("ApplicationRequests5xx", "GreaterThanThreshold", "Sum")),
14417 "latency" | "p90" => Some(("ApplicationLatencyP90", "GreaterThanThreshold", "Average")),
14418 _ => None,
14419 }
14420}
14421
14422pub fn format_template_settings(settings: &[(String, String, String)]) -> String {
14426 if settings.is_empty() {
14427 return "(no option settings)".into();
14428 }
14429 let key_width = settings
14430 .iter()
14431 .map(|(_, name, _)| name.chars().count())
14432 .max()
14433 .unwrap_or(0)
14434 .clamp(16, 40);
14435 let mut out = String::new();
14436 let mut prev_ns: Option<&str> = None;
14437 for (ns, name, value) in settings {
14438 if Some(ns.as_str()) != prev_ns {
14439 if prev_ns.is_some() {
14440 out.push('\n');
14441 }
14442 out.push_str(&format!("[{ns}]\n"));
14443 prev_ns = Some(ns.as_str());
14444 }
14445 let rendered = if value.is_empty() {
14446 "\"\"".to_string()
14447 } else {
14448 value.clone()
14449 };
14450 out.push_str(&format!(" {name:<key_width$} = {rendered}\n"));
14451 }
14452 out
14453}
14454
14455pub fn collect_saved_configs(apps: &[Application]) -> Vec<(String, String)> {
14460 let mut out: Vec<(String, String)> = apps
14461 .iter()
14462 .flat_map(|a| a.templates.iter().map(|t| (a.name.clone(), t.clone())))
14463 .collect();
14464 out.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
14465 out
14466}
14467
14468fn format_saved_configs(apps: &[Application]) -> String {
14469 if apps.is_empty() {
14470 return "no applications loaded — wait for first refresh or :region NAME".into();
14471 }
14472 let mut out = String::new();
14473 out.push_str("EB saved configurations (templates per application)\n");
14474 out.push_str("──────────────────────────────────────────────────\n\n");
14475 let mut any = false;
14476 for a in apps {
14477 if a.templates.is_empty() {
14478 continue;
14479 }
14480 any = true;
14481 out.push_str(&format!("Application: {}\n", a.name));
14482 for t in &a.templates {
14483 out.push_str(&format!(" ▸ {t}\n"));
14484 }
14485 out.push('\n');
14486 }
14487 if !any {
14488 out.push_str("no saved configuration templates in any application\n");
14489 }
14490 out
14491}
14492
14493fn diff_envs(left: &Environment, right: &Environment, redact_on: bool) -> String {
14494 let cn = |s: &str| {
14495 if redact_on {
14496 redact_block(s)
14497 } else {
14498 s.to_string()
14499 }
14500 };
14501 let updated = |e: &Environment| {
14502 e.updated
14503 .map(|u| u.to_rfc3339())
14504 .unwrap_or_else(|| "—".into())
14505 };
14506 let rows: Vec<(&str, String, String)> = vec![
14507 ("Name", left.name.clone(), right.name.clone()),
14508 (
14509 "Application",
14510 left.application.clone(),
14511 right.application.clone(),
14512 ),
14513 ("Tier", left.tier.clone(), right.tier.clone()),
14514 ("Status", left.status.clone(), right.status.clone()),
14515 ("Health", left.health.clone(), right.health.clone()),
14516 ("Platform", left.platform.clone(), right.platform.clone()),
14517 (
14518 "Version",
14519 left.version_label.clone(),
14520 right.version_label.clone(),
14521 ),
14522 ("CNAME", cn(&left.cname), cn(&right.cname)),
14523 ("Updated", updated(left), updated(right)),
14524 ];
14525
14526 let width: usize = 28;
14528 let truncate = |s: &str| -> String {
14529 if s.chars().count() > width {
14530 let mut t: String = s.chars().take(width.saturating_sub(1)).collect();
14531 t.push('…');
14532 t
14533 } else {
14534 s.to_string()
14535 }
14536 };
14537
14538 let left_label = truncate(&format!("◄ {}", left.name));
14539 let right_label = truncate(&format!("{} ►", right.name));
14540 let mut out = String::new();
14541 out.push_str(&format!(
14542 "{:<14} {:<width$} {}\n",
14543 "", left_label, right_label,
14544 ));
14545 out.push_str(&"─".repeat(14 + 4 + width + 4 + width));
14546 out.push('\n');
14547 for (field, l, r) in rows {
14548 let differs = l != r;
14549 let marker = if differs { "≠" } else { " " };
14550 out.push_str(&format!(
14551 "{marker} {:<12} {:<width$} {}\n",
14552 field,
14553 truncate(&l),
14554 truncate(&r),
14555 ));
14556 }
14557 out
14558}
14559
14560pub(crate) fn format_ssm_results(command: &str, rows: &[crate::aws::SsmRunResult]) -> String {
14567 if rows.is_empty() {
14568 return format!("ssm-run — `{command}`\n\nNo instances targeted.\n\nesc / q to close");
14569 }
14570 const MAX_LINES_PER_STREAM: usize = 50;
14571 const MAX_LINE_CHARS: usize = 200;
14572 let truncate_line = |line: &str| -> String {
14573 if line.chars().count() <= MAX_LINE_CHARS {
14574 line.to_string()
14575 } else {
14576 let mut out: String = line.chars().take(MAX_LINE_CHARS - 1).collect();
14577 out.push('…');
14578 out
14579 }
14580 };
14581 let truncate_block = |block: &str| -> String {
14582 let lines: Vec<&str> = block.lines().collect();
14583 if lines.len() <= MAX_LINES_PER_STREAM {
14584 return lines
14585 .iter()
14586 .map(|l| truncate_line(l))
14587 .collect::<Vec<_>>()
14588 .join("\n");
14589 }
14590 let head = lines
14591 .iter()
14592 .take(MAX_LINES_PER_STREAM)
14593 .map(|l| truncate_line(l))
14594 .collect::<Vec<_>>()
14595 .join("\n");
14596 format!(
14597 "{head}\n… ({} more lines truncated)",
14598 lines.len() - MAX_LINES_PER_STREAM
14599 )
14600 };
14601 let mut body = format!(
14602 "ssm-run — `{command}`\n\
14603 {} instance(s)\n\n",
14604 rows.len()
14605 );
14606 for r in rows {
14607 body.push_str(&format!(
14608 "─── {} [{}, exit={}] ───\n",
14609 r.instance_id, r.status, r.exit_code
14610 ));
14611 if r.stdout.is_empty() && r.stderr.is_empty() {
14612 body.push_str(" (no output)\n");
14613 }
14614 if !r.stdout.is_empty() {
14615 body.push_str("stdout:\n");
14616 body.push_str(&truncate_block(&r.stdout));
14617 body.push('\n');
14618 }
14619 if !r.stderr.is_empty() {
14620 body.push_str("stderr:\n");
14621 body.push_str(&truncate_block(&r.stderr));
14622 body.push('\n');
14623 }
14624 body.push('\n');
14625 }
14626 body.push_str("esc / q to close");
14627 body
14628}
14629
14630pub(crate) fn format_alarm_history(
14638 alarm_name: &str,
14639 entries: &[crate::aws::AlarmHistoryEntry],
14640) -> String {
14641 if entries.is_empty() {
14642 return format!(
14643 "Alarm history — {alarm_name}\n\n\
14644 No history items in the recent window.\n\
14645 (CloudWatch retains alarm history for 90 days.)\n\n\
14646 esc / q to close"
14647 );
14648 }
14649 let mut body = format!(
14650 "Alarm history — {alarm_name}\n\
14651 {} entries, newest first.\n\n",
14652 entries.len()
14653 );
14654 for e in entries {
14655 let ts =
14656 e.at.map(|t| t.format("%Y-%m-%d %H:%M:%SZ").to_string())
14657 .unwrap_or_else(|| "—".into());
14658 body.push_str(&format!("{ts} [{}]\n {}\n\n", e.kind, e.summary));
14659 }
14660 body.push_str("esc / q to close");
14661 body
14662}
14663
14664fn format_alarms(result: Result<Vec<CwAlarm>, String>) -> String {
14665 match result {
14666 Err(e) => format!("error fetching alarms: {e}"),
14667 Ok(alarms) if alarms.is_empty() => "no CloudWatch alarms reference this env".into(),
14668 Ok(alarms) => {
14669 let mut out = String::new();
14670 out.push_str(&format!("CloudWatch alarms ({})\n", alarms.len()));
14671 out.push_str("──────────────────────────────────────────\n\n");
14672 for a in alarms {
14673 out.push_str(&format!(
14674 "{:<10} {} ({}/{})\n",
14675 a.state, a.name, a.namespace, a.metric_name,
14676 ));
14677 if !a.state_reason.is_empty() {
14678 let lead = " ↳ ";
14683 let cont = " ";
14684 out.push_str(&wrap_with_hanging_indent(&a.state_reason, 100, lead, cont));
14685 out.push('\n');
14686 }
14687 out.push('\n');
14688 }
14689 out
14690 }
14691 }
14692}
14693
14694pub fn wrap_with_hanging_indent(text: &str, width: usize, lead: &str, cont: &str) -> String {
14700 if text.is_empty() {
14701 return lead.to_string();
14702 }
14703 let body_width = width.saturating_sub(lead.chars().count()).max(1);
14704 let mut out = String::new();
14705 let mut first = true;
14706 let mut current = String::new();
14707 let prefix = |first: bool| if first { lead } else { cont };
14708 for word in text.split_whitespace() {
14709 if word.chars().count() > body_width {
14711 if !current.is_empty() {
14712 out.push_str(prefix(first));
14713 out.push_str(¤t);
14714 out.push('\n');
14715 first = false;
14716 current.clear();
14717 }
14718 let mut chars = word.chars();
14719 loop {
14720 let chunk: String = (&mut chars).take(body_width).collect();
14721 if chunk.is_empty() {
14722 break;
14723 }
14724 out.push_str(prefix(first));
14725 out.push_str(&chunk);
14726 out.push('\n');
14727 first = false;
14728 }
14729 continue;
14730 }
14731 let candidate_len = if current.is_empty() {
14732 word.chars().count()
14733 } else {
14734 current.chars().count() + 1 + word.chars().count()
14735 };
14736 if candidate_len > body_width {
14737 out.push_str(prefix(first));
14738 out.push_str(¤t);
14739 out.push('\n');
14740 first = false;
14741 current.clear();
14742 }
14743 if !current.is_empty() {
14744 current.push(' ');
14745 }
14746 current.push_str(word);
14747 }
14748 if !current.is_empty() {
14749 out.push_str(prefix(first));
14750 out.push_str(¤t);
14751 out.push('\n');
14752 }
14753 out.pop(); out
14755}
14756
14757fn encode_view(app: &App) -> String {
14758 let mut parts: Vec<String> = Vec::new();
14759 if !app.filter.is_empty() {
14760 parts.push(format!("filter={}", app.filter));
14761 }
14762 parts.push(format!(
14763 "sort={}:{}",
14764 app.sort_key.label(),
14765 if app.sort_desc { "desc" } else { "asc" }
14766 ));
14767 parts.push(format!("grouped={}", app.grouped));
14768 let scope = match app.scope {
14769 Scope::Envs => "envs",
14770 Scope::Apps => "apps",
14771 };
14772 parts.push(format!("scope={scope}"));
14773 parts.join(";")
14774}
14775
14776pub fn encode_filter_only_view(filter: &str) -> String {
14786 format!("filter={filter}")
14787}
14788
14789pub fn view_filter_value(encoded: &str) -> &str {
14795 for part in encoded.split(';') {
14796 if let Some(rest) = part.trim().strip_prefix("filter=") {
14797 return rest;
14798 }
14799 }
14800 ""
14801}
14802
14803fn apply_view(app: &mut App, snap: &str) {
14804 let mut new_filter = String::new();
14805 for part in snap.split(';') {
14806 let Some((k, v)) = part.split_once('=') else {
14807 continue;
14808 };
14809 match k.trim() {
14810 "filter" => new_filter = v.trim().to_string(),
14811 "sort" => {
14812 let (key, desc) = parse_sort(Some(v.trim()));
14813 app.sort_key = key;
14814 app.sort_desc = desc;
14815 }
14816 "grouped" => app.grouped = v.trim().eq_ignore_ascii_case("true"),
14817 "scope" => {
14818 app.scope = match v.trim() {
14819 "apps" => Scope::Apps,
14820 _ => Scope::Envs,
14821 };
14822 }
14823 _ => {}
14824 }
14825 }
14826 app.filter = new_filter;
14827 app.resort_envs(); }
14829
14830pub fn instance_hourly_usd(instance_type: &str) -> Option<f64> {
14834 match instance_type {
14837 "t2.nano" => Some(0.0058),
14839 "t2.micro" => Some(0.0116),
14840 "t2.small" => Some(0.023),
14841 "t2.medium" => Some(0.0464),
14842 "t2.large" => Some(0.0928),
14843 "t3.nano" => Some(0.0052),
14844 "t3.micro" => Some(0.0104),
14845 "t3.small" => Some(0.0208),
14846 "t3.medium" => Some(0.0416),
14847 "t3.large" => Some(0.0832),
14848 "t3.xlarge" => Some(0.1664),
14849 "t3.2xlarge" => Some(0.3328),
14850 "t3a.nano" => Some(0.0047),
14851 "t3a.micro" => Some(0.0094),
14852 "t3a.small" => Some(0.0188),
14853 "t3a.medium" => Some(0.0376),
14854 "t3a.large" => Some(0.0752),
14855 "t4g.nano" => Some(0.0042),
14856 "t4g.micro" => Some(0.0084),
14857 "t4g.small" => Some(0.0168),
14858 "t4g.medium" => Some(0.0336),
14859 "t4g.large" => Some(0.0672),
14860 "m5.large" => Some(0.096),
14862 "m5.xlarge" => Some(0.192),
14863 "m5.2xlarge" => Some(0.384),
14864 "m5.4xlarge" => Some(0.768),
14865 "m6i.large" => Some(0.096),
14866 "m6i.xlarge" => Some(0.192),
14867 "m6i.2xlarge" => Some(0.384),
14868 "m6g.large" => Some(0.077),
14869 "m6g.xlarge" => Some(0.154),
14870 "c5.large" => Some(0.085),
14872 "c5.xlarge" => Some(0.17),
14873 "c5.2xlarge" => Some(0.34),
14874 "c6i.large" => Some(0.085),
14875 "c6i.xlarge" => Some(0.17),
14876 "r5.large" => Some(0.126),
14878 "r5.xlarge" => Some(0.252),
14879 "r6i.large" => Some(0.126),
14880 _ => None,
14881 }
14882}
14883
14884pub fn estimate_cost(instances: &[Instance]) -> (f64, usize) {
14887 let mut total = 0.0;
14888 let mut missing = 0;
14889 for i in instances {
14890 match instance_hourly_usd(&i.instance_type) {
14891 Some(p) => total += p,
14892 None => missing += 1,
14893 }
14894 }
14895 (total, missing)
14896}
14897
14898fn build_describe_cli(env_name: &str, region: &str, profile: Option<&str>) -> String {
14899 let env_q = shell_quote(env_name);
14900 let mut out = format!(
14901 "aws elasticbeanstalk describe-environments --environment-names {env_q} --region {region}"
14902 );
14903 if let Some(p) = profile {
14904 out.push_str(&format!(" --profile {}", shell_quote(p)));
14905 }
14906 out
14907}
14908
14909fn shell_quote(s: &str) -> String {
14910 if s.chars()
14911 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/'))
14912 {
14913 s.to_string()
14914 } else {
14915 let escaped = s.replace('\'', "'\\''");
14917 format!("'{escaped}'")
14918 }
14919}
14920
14921fn md_escape(s: &str) -> String {
14922 s.replace('\\', "\\\\").replace('|', "\\|")
14925}
14926
14927fn write_audit_entry(
14928 account: Option<&str>,
14929 profile: Option<&str>,
14930 region: &str,
14931 action: Action,
14932 env: &str,
14933 swap_with: Option<&str>,
14934) {
14935 let target = match swap_with {
14936 Some(other) => format!("{env} ↔ {other}"),
14937 None => env.to_string(),
14938 };
14939 let detail = format!("stage=dispatched action={action:?} target={target}");
14940 write_audit_line(account, profile, region, &detail);
14941}
14942
14943fn write_audit_outcome(
14947 account: Option<&str>,
14948 profile: Option<&str>,
14949 region: &str,
14950 action: Action,
14951 env: &str,
14952 result: Result<(), &str>,
14953) {
14954 let outcome = match result {
14963 Ok(()) => "outcome=ok".to_string(),
14964 Err(e) => format!("outcome=err err=\"{}\"", crate::audit::escape_value(e)),
14965 };
14966 let detail = format!("stage=completed action={action:?} target={env} {outcome}");
14967 write_audit_line(account, profile, region, &detail);
14968}
14969
14970const AUDIT_LOG_MAX_BYTES: u64 = 1 << 20;
14974
14975fn write_audit_line(account: Option<&str>, profile: Option<&str>, region: &str, detail: &str) {
14976 let dir = crate::util::cache_dir();
14977 if std::fs::create_dir_all(&dir).is_err() {
14978 return;
14979 }
14980 let path = dir.join("audit.log");
14981 rotate_if_oversize(&path, AUDIT_LOG_MAX_BYTES);
14982 let when = chrono::Utc::now().to_rfc3339();
14983 let line = format!(
14984 "{when}\taccount={}\tprofile={}\tregion={}\t{detail}\n",
14985 account.unwrap_or("-"),
14986 profile.unwrap_or("-"),
14987 region,
14988 );
14989 use std::io::Write;
14990 if let Ok(mut f) = std::fs::OpenOptions::new()
14991 .create(true)
14992 .append(true)
14993 .open(&path)
14994 {
14995 let _ = f.write_all(line.as_bytes());
14996 }
14997 if let Some(url) = NOTIFY_WEBHOOK_URL.get().and_then(|o| o.as_deref()) {
15003 fire_audit_webhook(url, account, profile, region, detail, &when);
15004 }
15005}
15006
15007pub(crate) static NOTIFY_WEBHOOK_URL: std::sync::OnceLock<Option<String>> =
15014 std::sync::OnceLock::new();
15015
15016pub(crate) fn build_audit_webhook_body(
15022 account: Option<&str>,
15023 profile: Option<&str>,
15024 region: &str,
15025 detail: &str,
15026 when: &str,
15027) -> String {
15028 let text = format!(
15029 "[ebman] {} account={} profile={} region={} {}",
15030 when,
15031 account.unwrap_or("-"),
15032 profile.unwrap_or("-"),
15033 region,
15034 detail,
15035 );
15036 format!(
15037 "{{\"text\":\"{}\",\"at\":\"{}\",\"account\":\"{}\",\"profile\":\"{}\",\"region\":\"{}\",\"detail\":\"{}\"}}",
15038 json_escape(&text),
15039 json_escape(when),
15040 json_escape(account.unwrap_or("")),
15041 json_escape(profile.unwrap_or("")),
15042 json_escape(region),
15043 json_escape(detail),
15044 )
15045}
15046
15047fn fire_audit_webhook(
15053 url: &str,
15054 account: Option<&str>,
15055 profile: Option<&str>,
15056 region: &str,
15057 detail: &str,
15058 when: &str,
15059) {
15060 let body = build_audit_webhook_body(account, profile, region, detail, when);
15061 let url = url.to_string();
15062 if tokio::runtime::Handle::try_current().is_err() {
15068 return;
15069 }
15070 tokio::spawn(async move {
15071 use tokio::process::Command;
15072 let result = Command::new("curl")
15073 .args([
15074 "-s",
15075 "-S",
15076 "-X",
15077 "POST",
15078 "-H",
15079 "Content-Type: application/json",
15080 "--max-time",
15081 "10",
15082 "--data-binary",
15083 "@-",
15084 ])
15085 .arg(&url)
15086 .stdin(std::process::Stdio::piped())
15087 .stdout(std::process::Stdio::null())
15088 .stderr(std::process::Stdio::piped())
15089 .spawn();
15090 let Ok(mut child) = result else {
15091 tracing::warn!(
15092 target: "ebman::notify",
15093 url = %url,
15094 "audit webhook: could not spawn curl"
15095 );
15096 return;
15097 };
15098 if let Some(mut stdin) = child.stdin.take() {
15099 use tokio::io::AsyncWriteExt;
15100 let _ = stdin.write_all(body.as_bytes()).await;
15101 let _ = stdin.shutdown().await;
15102 }
15103 match child.wait_with_output().await {
15104 Ok(out) if out.status.success() => {}
15105 Ok(out) => {
15106 tracing::warn!(
15107 target: "ebman::notify",
15108 url = %url,
15109 status = ?out.status.code(),
15110 stderr = %String::from_utf8_lossy(&out.stderr).trim(),
15111 "audit webhook returned non-zero"
15112 );
15113 }
15114 Err(e) => {
15115 tracing::warn!(
15116 target: "ebman::notify",
15117 url = %url,
15118 error = %e,
15119 "audit webhook curl exited with error"
15120 );
15121 }
15122 }
15123 });
15124}
15125
15126fn rotate_if_oversize(path: &std::path::Path, max_bytes: u64) {
15131 let Ok(meta) = std::fs::metadata(path) else {
15132 return;
15133 };
15134 if meta.len() <= max_bytes {
15135 return;
15136 }
15137 let backup = {
15138 let mut name = path
15139 .file_name()
15140 .map(|s| s.to_os_string())
15141 .unwrap_or_default();
15142 name.push(".1");
15143 path.with_file_name(name)
15144 };
15145 let _ = std::fs::rename(path, backup);
15146}
15147
15148#[derive(Clone)]
15159pub struct PendingDispatch {
15160 pub deadline: Instant,
15161 pub label: String,
15165 pub target: String,
15168 pub kind: PendingDispatchKind,
15169}
15170
15171#[allow(clippy::large_enum_variant)]
15177#[derive(Clone)]
15178pub enum PendingDispatchKind {
15179 Single { modal: ConfirmModal },
15183 BatchAction {
15186 action: Action,
15187 env_names: Vec<String>,
15188 },
15189 BatchDeploy {
15191 env_names: Vec<String>,
15192 version_label: String,
15193 },
15194 BatchTag {
15199 envs_with_arns: Vec<(String, String)>,
15200 key: String,
15201 value: Option<String>,
15202 },
15203 BatchSetOption {
15205 env_names: Vec<String>,
15206 namespace: String,
15207 option_name: String,
15208 value: String,
15209 },
15210}
15211
15212pub const UNDO_WINDOW: Duration = Duration::from_secs(5);
15217
15218#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15223pub enum AppsActionItem {
15224 Drill,
15225 BatchRebuild,
15226 BatchRestart,
15227 BatchDeploy,
15228 OpenInConsole,
15229}
15230
15231impl AppsActionItem {
15232 pub fn label(self) -> &'static str {
15233 match self {
15234 Self::Drill => "Drill into envs",
15235 Self::BatchRebuild => "Rebuild all envs in app",
15236 Self::BatchRestart => "Restart all envs in app",
15237 Self::BatchDeploy => "Deploy version label to all envs",
15238 Self::OpenInConsole => "Open application in AWS console",
15239 }
15240 }
15241}
15242
15243pub const APPS_ACTION_ITEMS: &[AppsActionItem] = &[
15247 AppsActionItem::Drill,
15248 AppsActionItem::BatchRebuild,
15249 AppsActionItem::BatchRestart,
15250 AppsActionItem::BatchDeploy,
15251 AppsActionItem::OpenInConsole,
15252];
15253
15254#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
15259pub struct AppRollup {
15260 pub env_count: usize,
15261 pub red_count: usize,
15262 pub updating_count: usize,
15263 pub worker_dlq_alerts: usize,
15264}
15265
15266pub fn app_rollup(
15272 envs: &[crate::aws::Environment],
15273 app_name: &str,
15274 dlq_depths: &HashMap<String, i64>,
15275) -> AppRollup {
15276 let mut out = AppRollup::default();
15277 for e in envs.iter().filter(|e| e.application == app_name) {
15278 out.env_count += 1;
15279 if matches!(
15281 e.health.to_lowercase().as_str(),
15282 "red" | "severe" | "degraded"
15283 ) {
15284 out.red_count += 1;
15285 }
15286 if e.status.eq_ignore_ascii_case("Updating")
15287 || e.status.eq_ignore_ascii_case("Launching")
15288 || e.status.eq_ignore_ascii_case("Terminating")
15289 {
15290 out.updating_count += 1;
15291 }
15292 if e.tier.eq_ignore_ascii_case("Worker")
15293 && dlq_depths.get(&e.name).copied().unwrap_or(0) > 0
15294 {
15295 out.worker_dlq_alerts += 1;
15296 }
15297 }
15298 out
15299}
15300
15301pub(crate) fn build_env_edit_body(env_name: &str, vars: &[(String, String)]) -> String {
15308 let mut out = String::new();
15309 out.push_str(&format!("# ebman env-var editor — {env_name}\n"));
15310 out.push_str("#\n");
15311 out.push_str("# Lines that look like KEY=VALUE are interpreted as env vars.\n");
15312 out.push_str("# Lines starting with # are comments.\n");
15313 out.push_str("# Blank lines are ignored.\n");
15314 out.push_str("#\n");
15315 out.push_str("# Save and quit to apply changes. Saving an unchanged file is a clean\n");
15316 out.push_str("# no-op. To reference a Secrets Manager value, store the ARN here\n");
15317 out.push_str("# (e.g. `DB_PASSWORD_SECRET_ARN=arn:aws:secretsmanager:...`) and have\n");
15318 out.push_str("# your app's bootstrap call GetSecretValue at runtime — EB does not\n");
15319 out.push_str("# resolve secretsmanager:// references natively.\n\n");
15320 let mut sorted: Vec<&(String, String)> = vars.iter().collect();
15321 sorted.sort_by(|a, b| a.0.cmp(&b.0));
15322 for (k, v) in sorted {
15323 out.push_str(&format!("{k}={v}\n"));
15324 }
15325 out
15326}
15327
15328pub(crate) fn parse_env_edit_body(text: &str) -> std::collections::BTreeMap<String, String> {
15336 let mut out = std::collections::BTreeMap::new();
15337 for raw in text.lines() {
15338 let trimmed = raw.trim_start();
15339 if trimmed.is_empty() || trimmed.starts_with('#') {
15340 continue;
15341 }
15342 let Some((key, value)) = trimmed.split_once('=') else {
15343 continue;
15344 };
15345 let key = key.trim();
15346 if key.is_empty() || key.chars().any(char::is_whitespace) {
15347 continue;
15348 }
15349 let value = value.trim_end_matches('\r').trim_end_matches('\n');
15353 out.insert(key.to_string(), value.to_string());
15354 }
15355 out
15356}
15357
15358pub(crate) type OptionSet = Vec<(String, String, String)>;
15368pub(crate) type OptionRemove = Vec<(String, String)>;
15370
15371pub(crate) fn diff_env_vars(
15372 namespace: &str,
15373 original: &std::collections::BTreeMap<String, String>,
15374 edited: &std::collections::BTreeMap<String, String>,
15375) -> (OptionSet, OptionRemove) {
15376 let mut to_set: OptionSet = Vec::new();
15377 let mut to_remove: OptionRemove = Vec::new();
15378 for (k, v) in edited {
15381 match original.get(k) {
15382 Some(prev) if prev == v => continue,
15383 _ => to_set.push((namespace.to_string(), k.clone(), v.clone())),
15384 }
15385 }
15386 for k in original.keys() {
15388 if !edited.contains_key(k) {
15389 to_remove.push((namespace.to_string(), k.clone()));
15390 }
15391 }
15392 (to_set, to_remove)
15393}
15394
15395pub(crate) fn parse_access_denied(msg: &str) -> Option<(String, String)> {
15411 let user_prefix = "User: ";
15412 let action_prefix = "is not authorized to perform:";
15413 let user_start = msg.find(user_prefix)? + user_prefix.len();
15414 let user_end = msg[user_start..]
15415 .find(|c: char| c.is_whitespace())
15416 .map(|i| user_start + i)?;
15417 let principal_raw = &msg[user_start..user_end];
15418 let action_start = msg.find(action_prefix)? + action_prefix.len();
15419 let action_rest = msg[action_start..].trim_start();
15420 let action_end = action_rest
15421 .find(|c: char| c.is_whitespace() || c == ',')
15422 .unwrap_or(action_rest.len());
15423 let action = action_rest[..action_end].to_string();
15424 let principal = if let Some(rest) = principal_raw.strip_prefix("arn:aws:sts::") {
15425 let parts: Vec<&str> = rest.splitn(2, ':').collect();
15427 let account = parts.first()?;
15428 let role_part = parts.get(1)?;
15429 let role_name = role_part.strip_prefix("assumed-role/")?.split('/').next()?;
15430 format!("arn:aws:iam::{account}:role/{role_name}")
15431 } else {
15432 principal_raw.to_string()
15433 };
15434 Some((principal, action))
15435}
15436
15437pub(crate) fn render_explain_overlay(principal: &str, rows: &[crate::aws::IamSimResult]) -> String {
15443 let mut out = String::new();
15444 out.push_str(&format!("IAM diagnosis for {principal}\n"));
15445 out.push_str("═══════════════════════════════════════════════════\n\n");
15446 if rows.is_empty() {
15447 out.push_str("(no evaluation results returned)\n\nesc / q to close");
15448 return out;
15449 }
15450 for (idx, r) in rows.iter().enumerate() {
15451 if idx > 0 {
15452 out.push('\n');
15453 }
15454 out.push_str(&format!("Action: {}\n", r.action));
15455 if !r.resource.is_empty() {
15456 out.push_str(&format!("Resource: {}\n", r.resource));
15457 }
15458 let (mark, label) = match r.decision.as_str() {
15459 "allowed" => ("✓", "allowed"),
15460 "explicitDeny" => ("✗", "explicitDeny — a policy *denies* this action"),
15461 "implicitDeny" => ("✗", "implicitDeny — no policy allows this action"),
15462 other => ("?", other),
15463 };
15464 out.push_str(&format!("Decision: {mark} {label}\n"));
15465 if r.blocked_by_scp {
15466 out.push_str(" ⚠ also blocked by an Organizations SCP at the org level\n");
15467 }
15468 if r.blocked_by_boundary {
15469 out.push_str(" ⚠ also blocked by the role's permission boundary\n");
15470 }
15471 if !r.matched_statements.is_empty() {
15472 out.push_str("Matched statements:\n");
15473 for s in &r.matched_statements {
15474 out.push_str(&format!(" ▸ {s}\n"));
15475 }
15476 }
15477 if !r.missing_context.is_empty() {
15478 out.push_str("Missing context keys (conditions unsatisfied):\n");
15479 for c in &r.missing_context {
15480 out.push_str(&format!(" ▸ {c}\n"));
15481 }
15482 }
15483 if r.decision == "implicitDeny" {
15484 out.push_str(&format!(
15485 "\nTo allow, add this statement to one of the role's policies:\n\
15486 \n\
15487 {{\n\
15488 \x20\x20\"Effect\": \"Allow\",\n\
15489 \x20\x20\"Action\": \"{}\",\n\
15490 \x20\x20\"Resource\": \"*\"\n\
15491 }}\n",
15492 r.action
15493 ));
15494 } else if r.decision == "explicitDeny" {
15495 out.push_str(
15496 "\nAn explicit Deny in the matched statement(s) above is\n\
15497 overriding any Allow. Remove or scope down the Deny to\n\
15498 unblock — explicit Deny always wins.\n",
15499 );
15500 }
15501 }
15502 out.push_str("\nesc / q to close");
15503 out
15504}
15505
15506pub(crate) fn render_env_resources_tree(
15530 res: &crate::aws::EnvResources,
15531 env_name: &str,
15532 tier: &str,
15533) -> String {
15534 let mut out = String::new();
15535 out.push_str(&format!("Resources for {env_name} ({tier})\n"));
15536 out.push_str("═══════════════════════════════════════\n\n");
15537
15538 let mut sections: Vec<(String, Vec<String>)> = Vec::new();
15542
15543 if !res.asgs.is_empty() {
15544 let mut lines: Vec<String> = Vec::new();
15545 let n_asgs = res.asgs.len();
15546 for (asg_idx, asg) in res.asgs.iter().enumerate() {
15547 let last_asg = asg_idx + 1 == n_asgs;
15548 let asg_prefix = if last_asg { "└─" } else { "├─" };
15549 lines.push(format!(" {asg_prefix} {asg}"));
15550 if asg_idx == 0 && !res.instances.is_empty() {
15553 let n_inst = res.instances.len();
15554 let cont = if last_asg { " " } else { "│ " };
15555 for (i, id) in res.instances.iter().enumerate() {
15556 let last_inst = i + 1 == n_inst;
15557 let glyph = if last_inst { "└─" } else { "├─" };
15558 lines.push(format!(" {cont} {glyph} {id}"));
15559 }
15560 }
15561 }
15562 sections.push((format!("Auto-scaling groups ({})", res.asgs.len()), lines));
15563 } else if !res.instances.is_empty() {
15564 let mut lines: Vec<String> = Vec::new();
15565 let n = res.instances.len();
15566 for (i, id) in res.instances.iter().enumerate() {
15567 let last = i + 1 == n;
15568 let glyph = if last { "└─" } else { "├─" };
15569 lines.push(format!(" {glyph} {id}"));
15570 }
15571 sections.push((format!("Instances ({n}) — orphan (no ASG attached)"), lines));
15572 }
15573
15574 if !res.launch_templates.is_empty() {
15575 let mut lines: Vec<String> = Vec::new();
15576 let n = res.launch_templates.len();
15577 for (i, t) in res.launch_templates.iter().enumerate() {
15578 let glyph = if i + 1 == n { "└─" } else { "├─" };
15579 lines.push(format!(" {glyph} {t}"));
15580 }
15581 sections.push((format!("Launch templates ({n})"), lines));
15582 }
15583 if !res.launch_configs.is_empty() {
15584 let mut lines: Vec<String> = Vec::new();
15585 let n = res.launch_configs.len();
15586 for (i, lc) in res.launch_configs.iter().enumerate() {
15587 let glyph = if i + 1 == n { "└─" } else { "├─" };
15588 lines.push(format!(" {glyph} {lc}"));
15589 }
15590 sections.push((format!("Launch configurations ({n})"), lines));
15591 }
15592 if !res.load_balancers.is_empty() {
15593 let mut lines: Vec<String> = Vec::new();
15594 let n = res.load_balancers.len();
15595 for (i, lb) in res.load_balancers.iter().enumerate() {
15596 let glyph = if i + 1 == n { "└─" } else { "├─" };
15597 lines.push(format!(" {glyph} {lb}"));
15598 }
15599 sections.push((format!("Load balancers ({n})"), lines));
15600 }
15601 if !res.triggers.is_empty() {
15602 let mut lines: Vec<String> = Vec::new();
15603 let n = res.triggers.len();
15604 for (i, t) in res.triggers.iter().enumerate() {
15605 let glyph = if i + 1 == n { "└─" } else { "├─" };
15606 lines.push(format!(" {glyph} {t}"));
15607 }
15608 sections.push((format!("Triggers ({n})"), lines));
15609 }
15610 if !res.queues.is_empty() {
15611 let mut lines: Vec<String> = Vec::new();
15612 let n = res.queues.len();
15613 for (i, q) in res.queues.iter().enumerate() {
15614 let last = i + 1 == n;
15615 let glyph = if last { "└─" } else { "├─" };
15616 lines.push(format!(" {glyph} {}", q.name));
15617 if !q.url.is_empty() {
15618 let url_prefix = if last { " " } else { " │ " };
15619 lines.push(format!("{url_prefix}{}", q.url));
15620 }
15621 }
15622 sections.push((format!("Queues ({n})"), lines));
15623 }
15624
15625 if sections.is_empty() {
15626 out.push_str(" (no resources reported — env may still be launching)\n");
15627 } else {
15628 let n_sections = sections.len();
15629 for (idx, (label, lines)) in sections.iter().enumerate() {
15630 let last_section = idx + 1 == n_sections;
15631 let section_glyph = if last_section { "└─" } else { "├─" };
15632 out.push_str(&format!("{section_glyph} {label}\n"));
15633 let prefix = if last_section { " " } else { "│ " };
15634 for line in lines {
15635 out.push_str(&format!("{prefix}{line}\n"));
15636 }
15637 if !last_section {
15638 out.push_str("│\n");
15639 }
15640 }
15641 }
15642
15643 out.push_str("\nesc / q to close");
15644 out
15645}
15646
15647pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
15658 let a_bytes = a.as_bytes();
15659 let b_bytes = b.as_bytes();
15660 if a_bytes.is_empty() {
15661 return b_bytes.len();
15662 }
15663 if b_bytes.is_empty() {
15664 return a_bytes.len();
15665 }
15666 let (short, long) = if a_bytes.len() < b_bytes.len() {
15670 (a_bytes, b_bytes)
15671 } else {
15672 (b_bytes, a_bytes)
15673 };
15674 let mut prev: Vec<usize> = (0..=short.len()).collect();
15675 let mut curr: Vec<usize> = vec![0; short.len() + 1];
15676 for (i, lc) in long.iter().enumerate() {
15677 curr[0] = i + 1;
15678 for (j, sc) in short.iter().enumerate() {
15679 let cost = if lc == sc { 0 } else { 1 };
15680 curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
15681 }
15682 std::mem::swap(&mut prev, &mut curr);
15683 }
15684 prev[short.len()]
15685}
15686
15687pub(crate) fn suggest_command(input: &str) -> Option<String> {
15696 let threshold = if input.len() <= 3 { 1 } else { 2 };
15697 let mut best: Option<(usize, String)> = None;
15698 for name in crate::commands::all_names() {
15699 let d = edit_distance(input, name);
15700 if d <= threshold && best.as_ref().is_none_or(|(bd, _)| d < *bd) {
15701 best = Some((d, name.to_string()));
15702 }
15703 }
15704 best.map(|(_, name)| name)
15705}
15706
15707pub(crate) fn completion_candidates(prefix: &str) -> Vec<String> {
15719 let mut names: Vec<String> = crate::commands::all_names()
15720 .into_iter()
15721 .filter(|n| n.starts_with(prefix))
15722 .map(String::from)
15723 .collect();
15724 names.sort();
15725 names.dedup();
15726 names
15727}
15728
15729#[derive(Debug, Clone, PartialEq, Eq)]
15746pub struct ConfigDiff {
15747 pub namespace: String,
15748 pub name: String,
15749 pub left: Option<String>,
15750 pub right: Option<String>,
15751}
15752
15753pub fn diff_config_options(
15760 left: &[crate::aws::ConfigOption],
15761 right: &[crate::aws::ConfigOption],
15762) -> Vec<ConfigDiff> {
15763 use std::collections::{BTreeMap, BTreeSet};
15764 let norm = |v: &Option<String>| v.clone().filter(|s| !s.is_empty());
15765 let to_map = |opts: &[crate::aws::ConfigOption]| -> BTreeMap<(String, String), Option<String>> {
15766 opts.iter()
15767 .map(|o| ((o.namespace.clone(), o.name.clone()), norm(&o.value)))
15768 .collect()
15769 };
15770 let lmap = to_map(left);
15771 let rmap = to_map(right);
15772 let mut keys: BTreeSet<(String, String)> = lmap.keys().cloned().collect();
15773 keys.extend(rmap.keys().cloned());
15774 keys.into_iter()
15775 .filter_map(|k| {
15776 let l = lmap.get(&k).cloned().flatten();
15777 let r = rmap.get(&k).cloned().flatten();
15778 if l == r {
15779 None
15780 } else {
15781 Some(ConfigDiff {
15782 namespace: k.0,
15783 name: k.1,
15784 left: l,
15785 right: r,
15786 })
15787 }
15788 })
15789 .collect()
15790}
15791
15792pub(crate) fn render_config_diff_overlay(
15795 left_env: &str,
15796 right_env: &str,
15797 diffs: &[ConfigDiff],
15798) -> String {
15799 if diffs.is_empty() {
15800 return format!(
15801 "Config diff — {left_env} ↔ {right_env}\n\n\
15802 ✓ identical: every operator-set option-setting matches.\n\n\
15803 esc / q to close"
15804 );
15805 }
15806 let mut body = format!(
15807 "Config diff — {left_env} ↔ {right_env}\n\
15808 {n} option-setting(s) differ. L = {left_env} R = {right_env}\n\
15809 (unset = at the platform default)\n\n",
15810 n = diffs.len()
15811 );
15812 let mut current_ns: Option<&str> = None;
15813 let show = |v: &Option<String>| v.clone().unwrap_or_else(|| "(unset)".into());
15814 for d in diffs {
15815 if Some(d.namespace.as_str()) != current_ns {
15816 if current_ns.is_some() {
15817 body.push('\n');
15818 }
15819 body.push_str(&format!("── {} ──\n", d.namespace));
15820 current_ns = Some(d.namespace.as_str());
15821 }
15822 body.push_str(&format!(
15823 " {}\n L: {}\n R: {}\n",
15824 d.name,
15825 show(&d.left),
15826 show(&d.right),
15827 ));
15828 }
15829 body.push_str("\nesc / q to close");
15830 body
15831}
15832
15833pub(crate) fn render_options_overlay(
15834 rows: &[crate::aws::ConfigOption],
15835 filter_ns: Option<&str>,
15836 env_name: &str,
15837) -> String {
15838 let filtered: Vec<&crate::aws::ConfigOption> = rows
15839 .iter()
15840 .filter(|r| filter_ns.is_none_or(|ns| r.namespace == ns))
15841 .collect();
15842 if filtered.is_empty() {
15843 return match filter_ns {
15844 Some(ns) => format!(
15845 "No options found for namespace '{ns}' on env '{env_name}'.\n\n\
15846 Spelling? Try `:options` (no arg) to see the full list of\n\
15847 namespaces available for this env's platform.\n\n\
15848 esc / q to close"
15849 ),
15850 None => format!(
15851 "No configuration options returned for env '{env_name}'.\n\n\
15852 This usually means the env's platform doesn't expose an option\n\
15853 vocabulary (custom platform or stale solution-stack). Try\n\
15854 `:set-option` directly if you know what you want to change.\n\n\
15855 esc / q to close"
15856 ),
15857 };
15858 }
15859 let mut max_name_per_ns: std::collections::HashMap<&str, usize> =
15862 std::collections::HashMap::new();
15863 for r in &filtered {
15864 let e = max_name_per_ns.entry(r.namespace.as_str()).or_insert(0);
15865 *e = (*e).max(r.name.chars().count()).min(38);
15866 }
15867
15868 let user_set = filtered.iter().filter(|r| r.value.is_some()).count();
15869 let mut body = String::new();
15870 body.push_str(&format!(
15871 "Configuration vocabulary for {env_name}\n\
15872 {user_set}/{total} options are operator-set; the rest are at default.\n\n\
15873 ▸ = operator-set • = default severity warns when changing rolls instances\n\n",
15874 total = filtered.len()
15875 ));
15876
15877 let mut current_ns: Option<&str> = None;
15878 for r in &filtered {
15879 if Some(r.namespace.as_str()) != current_ns {
15880 if current_ns.is_some() {
15881 body.push('\n');
15882 }
15883 body.push_str(&format!("── {} ──\n", r.namespace));
15884 current_ns = Some(r.namespace.as_str());
15885 }
15886 let marker = if r.value.is_some() { "▸" } else { "•" };
15887 let name_width = max_name_per_ns
15888 .get(r.namespace.as_str())
15889 .copied()
15890 .unwrap_or(20);
15891 let name_padded = if r.name.chars().count() < name_width {
15892 format!("{name:<width$}", name = r.name, width = name_width)
15893 } else {
15894 r.name.clone()
15895 };
15896 let value_str = match &r.value {
15897 Some(v) => format!(" = {v}"),
15898 None => String::new(),
15899 };
15900 let mut meta: Vec<String> = Vec::new();
15903 if let Some(d) = &r.default_value {
15904 if !d.is_empty() {
15905 meta.push(format!("default: {d}"));
15906 }
15907 }
15908 if !r.value_type.is_empty() && r.value_type != "Scalar" {
15909 meta.push(format!("type: {}", r.value_type));
15912 }
15913 if let Some(s) = &r.change_severity {
15914 if s != "NoInterruption" && s != "Unknown" {
15915 meta.push(format!("severity: {s}"));
15916 }
15917 }
15918 match (r.min_value, r.max_value) {
15919 (Some(min), Some(max)) => meta.push(format!("range: {min}-{max}")),
15920 (Some(min), None) => meta.push(format!("min: {min}")),
15921 (None, Some(max)) => meta.push(format!("max: {max}")),
15922 (None, None) => {}
15923 }
15924 if let Some(maxlen) = r.max_length {
15925 meta.push(format!("max_len: {maxlen}"));
15926 }
15927 if !r.value_options.is_empty() {
15928 let preview: Vec<&str> = r.value_options.iter().take(5).map(String::as_str).collect();
15929 let more = r.value_options.len().saturating_sub(5);
15930 let suffix = if more > 0 {
15931 format!(", … +{more}")
15932 } else {
15933 String::new()
15934 };
15935 meta.push(format!("oneof: {}{suffix}", preview.join(", ")));
15936 }
15937 let meta_str = if meta.is_empty() {
15938 String::new()
15939 } else {
15940 format!(" ({})", meta.join(", "))
15941 };
15942 body.push_str(&format!(" {marker} {name_padded}{value_str}{meta_str}\n"));
15943 }
15944 body.push_str(
15945 "\n`:set-option NAMESPACE NAME VALUE` to change a setting.\n\
15946 `:options NAMESPACE` to filter to one family.\n\
15947 esc / q to close",
15948 );
15949 body
15950}
15951
15952pub(crate) fn render_secrets_overlay(
15957 rows: &[crate::aws::SecretSummary],
15958 filter: Option<&str>,
15959) -> String {
15960 if rows.is_empty() {
15961 return match filter {
15962 Some(f) => format!(
15963 "No secrets matching '{f}'.\n\n\
15964 `:secrets` (no arg) to see everything in this region.\n\
15965 Secrets Manager is region-scoped — switch with `:region` first if needed.\n\n\
15966 esc / q to close"
15967 ),
15968 None => "No Secrets Manager secrets in this region.\n\n\
15969 Either none have been created, or the caller is missing\n\
15970 `secretsmanager:ListSecrets`. Try `:explain :secrets` to check.\n\n\
15971 esc / q to close"
15972 .to_string(),
15973 };
15974 }
15975 let now = chrono::Utc::now();
15976 let mut body = String::new();
15977 body.push_str(&match filter {
15978 Some(f) => format!(
15979 "Secrets Manager — {n} matching '{f}'\n\
15980 Sorted by last-changed (newest first). Values not shown — use `:secret NAME`.\n\n",
15981 n = rows.len()
15982 ),
15983 None => format!(
15984 "Secrets Manager — {n} secrets\n\
15985 Sorted by last-changed (newest first). Values not shown — use `:secret NAME`.\n\n",
15986 n = rows.len()
15987 ),
15988 });
15989 for r in rows {
15990 body.push_str(&format!("▸ {}\n", r.name));
15991 if !r.arn.is_empty() {
15992 body.push_str(&format!(" arn: {}\n", r.arn));
15993 }
15994 if let Some(d) = &r.description {
15995 body.push_str(&format!(" desc: {d}\n"));
15996 }
15997 let changed = r.last_changed.map(|t| format_age(now, t));
15998 let rotated = r.last_rotated.map(|t| format_age(now, t));
15999 match (changed, rotated) {
16000 (Some(c), Some(r)) => {
16001 body.push_str(&format!(" changed: {c} rotated: {r}\n"));
16002 }
16003 (Some(c), None) => {
16004 body.push_str(&format!(" changed: {c} rotated: never\n"));
16005 }
16006 (None, Some(r)) => {
16007 body.push_str(&format!(" rotated: {r}\n"));
16008 }
16009 (None, None) => {}
16010 }
16011 if let Some(k) = &r.kms_key_id {
16012 body.push_str(&format!(" kms: {k}\n"));
16013 }
16014 body.push('\n');
16015 }
16016 body.push_str(
16017 "y to yank an ARN (select first) · `:secret NAME` to read the value\n\
16018 esc / q to close",
16019 );
16020 body
16021}
16022
16023pub(crate) fn render_secret_value_overlay(name: &str, value: &str, redact: bool) -> String {
16030 let mut body = String::new();
16031 body.push_str(&format!("Secret — {name}\n\n"));
16032 if redact {
16033 body.push_str(&format!(
16034 "value: <redacted; {} chars, fingerprint {}>\n\
16035 Run `:redact off` then re-fetch if you need the cleartext.\n\n\
16036 esc / q to close",
16037 value.chars().count(),
16038 short_fingerprint(value),
16039 ));
16040 return body;
16041 }
16042 let pretty = try_pretty_json(value);
16044 body.push_str("value:\n");
16045 body.push_str(&pretty);
16046 if !pretty.ends_with('\n') {
16047 body.push('\n');
16048 }
16049 body.push_str("\ny to yank the value · esc / q to close");
16050 body
16051}
16052
16053fn short_fingerprint(s: &str) -> String {
16059 let mut h: u32 = 0x811C_9DC5;
16060 for b in s.as_bytes() {
16061 h ^= *b as u32;
16062 h = h.wrapping_mul(0x0100_0193);
16063 }
16064 format!("{h:08x}")
16065}
16066
16067fn try_pretty_json(s: &str) -> String {
16071 let trimmed = s.trim();
16072 if !(trimmed.starts_with('{') || trimmed.starts_with('[')) {
16073 return s.to_string();
16074 }
16075 let mut out = String::with_capacity(s.len() + 32);
16079 let mut depth: usize = 0;
16080 let mut in_str = false;
16081 let mut escape = false;
16082 let mut chars = trimmed.chars().peekable();
16083 while let Some(c) = chars.next() {
16084 if in_str {
16085 out.push(c);
16086 if escape {
16087 escape = false;
16088 } else if c == '\\' {
16089 escape = true;
16090 } else if c == '"' {
16091 in_str = false;
16092 }
16093 continue;
16094 }
16095 match c {
16096 '"' => {
16097 in_str = true;
16098 out.push(c);
16099 }
16100 '{' | '[' => {
16101 out.push(c);
16102 if matches!(chars.peek(), Some('}') | Some(']')) {
16106 if let Some(close) = chars.next() {
16107 out.push(close);
16108 }
16109 continue;
16110 }
16111 depth += 1;
16112 out.push('\n');
16113 out.push_str(&" ".repeat(depth));
16114 }
16115 '}' | ']' => {
16116 depth = depth.saturating_sub(1);
16117 out.push('\n');
16118 out.push_str(&" ".repeat(depth));
16119 out.push(c);
16120 }
16121 ',' => {
16122 out.push(c);
16123 out.push('\n');
16124 out.push_str(&" ".repeat(depth));
16125 }
16126 ':' => {
16127 out.push(c);
16128 out.push(' ');
16129 }
16130 ' ' | '\n' | '\t' | '\r' => {} _ => out.push(c),
16132 }
16133 }
16134 out
16135}
16136
16137fn format_age(now: chrono::DateTime<chrono::Utc>, t: chrono::DateTime<chrono::Utc>) -> String {
16140 let d = now.signed_duration_since(t);
16141 let secs = d.num_seconds().max(0);
16142 if secs < 60 {
16143 return format!("{secs}s ago");
16144 }
16145 let mins = secs / 60;
16146 if mins < 60 {
16147 return format!("{mins}m ago");
16148 }
16149 let hrs = mins / 60;
16150 if hrs < 48 {
16151 return format!("{hrs}h ago");
16152 }
16153 let days = hrs / 24;
16154 if days < 60 {
16155 return format!("{days}d ago");
16156 }
16157 let months = days / 30;
16158 if months < 24 {
16159 return format!("~{months}mo ago");
16160 }
16161 format!("~{}y ago", days / 365)
16162}
16163
16164fn console_url(region: &str, app_name: &str, env_name: &str) -> String {
16165 let app = urlencode(app_name);
16166 let env = urlencode(env_name);
16167 format!(
16168 "https://{region}.console.aws.amazon.com/elasticbeanstalk/home?region={region}#/environment/dashboard?applicationName={app}&environmentName={env}"
16169 )
16170}
16171
16172fn urlencode(s: &str) -> String {
16173 let mut out = String::with_capacity(s.len());
16177 for c in s.chars() {
16178 if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
16179 out.push(c);
16180 } else {
16181 for b in c.to_string().bytes() {
16182 out.push_str(&format!("%{b:02X}"));
16183 }
16184 }
16185 }
16186 out
16187}
16188
16189fn open_url(url: &str) -> std::result::Result<(), String> {
16190 #[cfg(target_os = "macos")]
16191 let cmd = "open";
16192 #[cfg(all(unix, not(target_os = "macos")))]
16193 let cmd = "xdg-open";
16194 #[cfg(target_os = "windows")]
16195 let cmd = "explorer";
16196
16197 #[cfg(not(any(unix, target_os = "windows")))]
16198 {
16199 let _ = url;
16200 return Err("don't know how to open a URL on this platform".into());
16201 }
16202 #[cfg(any(unix, target_os = "windows"))]
16203 {
16204 std::process::Command::new(cmd)
16205 .arg(url)
16206 .stdout(std::process::Stdio::null())
16207 .stderr(std::process::Stdio::null())
16208 .spawn()
16209 .map(|_| ())
16210 .map_err(|e| e.to_string())
16211 }
16212}
16213
16214fn describe_env(e: &Environment) -> String {
16215 let updated = e
16216 .updated
16217 .map(|u| u.to_rfc3339())
16218 .unwrap_or_else(|| "null".into());
16219 format!(
16220 "{{\n \"name\": \"{}\",\n \"application\": \"{}\",\n \"tier\": \"{}\",\n \"status\": \"{}\",\n \"health\": \"{}\",\n \"platform\": \"{}\",\n \"version_label\": \"{}\",\n \"cname\": \"{}\",\n \"updated\": {}\n}}",
16221 json_escape(&e.name),
16222 json_escape(&e.application),
16223 json_escape(&e.tier),
16224 json_escape(&e.status),
16225 json_escape(&e.health),
16226 json_escape(&e.platform),
16227 json_escape(&e.version_label),
16228 json_escape(&e.cname),
16229 if updated == "null" { updated } else { format!("\"{updated}\"") },
16230 )
16231}
16232
16233fn json_escape(s: &str) -> String {
16234 let mut out = String::with_capacity(s.len());
16235 for c in s.chars() {
16236 match c {
16237 '"' => out.push_str("\\\""),
16238 '\\' => out.push_str("\\\\"),
16239 '\n' => out.push_str("\\n"),
16240 '\r' => out.push_str("\\r"),
16241 '\t' => out.push_str("\\t"),
16242 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
16243 c => out.push(c),
16244 }
16245 }
16246 out
16247}
16248
16249fn redact_block(value: &str) -> String {
16250 if value.is_empty() {
16251 return value.to_string();
16252 }
16253 "▓".repeat(value.chars().count())
16254}
16255
16256#[cfg(test)]
16257mod tests {
16258 use super::*;
16259
16260 #[test]
16261 fn loading_linger_target_none_when_no_load() {
16262 let now = Instant::now();
16263 assert!(compute_loading_linger_target(
16264 None,
16265 Duration::from_millis(300),
16266 Duration::from_millis(500),
16267 now,
16268 )
16269 .is_none());
16270 }
16271
16272 #[test]
16273 fn loading_linger_target_none_when_under_threshold() {
16274 let now = Instant::now();
16275 let started = now - Duration::from_millis(100);
16277 assert!(compute_loading_linger_target(
16278 Some(started),
16279 Duration::from_millis(300),
16280 Duration::from_millis(500),
16281 now,
16282 )
16283 .is_none());
16284 }
16285
16286 #[test]
16287 fn loading_linger_target_arms_past_threshold() {
16288 let now = Instant::now();
16289 let started = now - Duration::from_millis(400);
16290 let until = compute_loading_linger_target(
16291 Some(started),
16292 Duration::from_millis(300),
16293 Duration::from_millis(500),
16294 now,
16295 )
16296 .expect("should arm linger past threshold");
16297 let target_delta = until.duration_since(now);
16300 assert!(
16301 target_delta >= Duration::from_millis(495)
16302 && target_delta <= Duration::from_millis(505),
16303 "linger target should be ~500ms in the future, got {target_delta:?}"
16304 );
16305 }
16306
16307 #[test]
16308 fn sort_key_cycle_matches_ui_column_order() {
16309 let order = [
16310 SortKey::Name,
16311 SortKey::App,
16312 SortKey::Status,
16313 SortKey::Health,
16314 SortKey::Version,
16315 SortKey::Age,
16316 ];
16317 let mut cur = order[0];
16318 for expected in order.iter().skip(1).chain(std::iter::once(&order[0])) {
16319 cur = cur.next();
16320 assert_eq!(cur, *expected);
16321 }
16322 }
16323
16324 #[test]
16325 fn sort_key_parse_roundtrip() {
16326 for k in [
16327 SortKey::Name,
16328 SortKey::App,
16329 SortKey::Status,
16330 SortKey::Health,
16331 SortKey::Version,
16332 SortKey::Age,
16333 ] {
16334 assert_eq!(SortKey::parse(k.label()), Some(k));
16335 }
16336 assert_eq!(SortKey::parse("bogus"), None);
16337 }
16338
16339 #[test]
16340 fn parse_sort_handles_directions() {
16341 assert_eq!(parse_sort(Some("app:desc")), (SortKey::App, true));
16342 assert_eq!(parse_sort(Some("name:asc")), (SortKey::Name, false));
16343 assert_eq!(parse_sort(Some("name")), (SortKey::Name, false));
16344 assert_eq!(parse_sort(Some("bogus:desc")), (SortKey::App, true)); assert_eq!(parse_sort(None), (SortKey::App, false));
16346 }
16347
16348 #[test]
16349 fn parse_toggle_explicit_and_default() {
16350 assert!(parse_toggle(Some("on"), false));
16351 assert!(parse_toggle(Some("yes"), false));
16352 assert!(parse_toggle(Some("1"), false));
16353 assert!(!parse_toggle(Some("off"), true));
16354 assert!(!parse_toggle(Some("no"), true));
16355 assert!(parse_toggle(None, false));
16357 assert!(!parse_toggle(None, true));
16358 assert!(parse_toggle(Some("maybe"), false));
16360 }
16361
16362 #[test]
16363 fn health_rank_orders_severities() {
16364 assert!(health_rank("green") < health_rank("grey"));
16365 assert!(health_rank("grey") < health_rank("yellow"));
16366 assert!(health_rank("yellow") < health_rank("red"));
16367 assert_eq!(health_rank("ok"), health_rank("Green"));
16368 }
16369
16370 #[test]
16371 fn scroll_apply_clamps_at_zero() {
16372 assert_eq!(scroll_apply(0, -1), 0);
16373 assert_eq!(scroll_apply(0, 0), 0);
16374 assert_eq!(scroll_apply(0, 1), 1);
16375 assert_eq!(scroll_apply(5, -10), 0);
16376 assert_eq!(scroll_apply(5, 3), 8);
16377 }
16378
16379 #[test]
16380 fn redact_block_preserves_length() {
16381 assert_eq!(redact_block(""), "");
16382 assert_eq!(redact_block("hello").chars().count(), 5);
16383 assert_eq!(redact_block("über-café").chars().count(), 9);
16384 }
16385
16386 #[test]
16387 fn scope_next_alternates() {
16388 assert_eq!(Scope::Envs.next(), Scope::Apps);
16389 assert_eq!(Scope::Apps.next(), Scope::Envs);
16390 }
16391
16392 #[test]
16393 fn action_destructive_only_for_terminate() {
16394 assert!(Action::Terminate.destructive());
16395 assert!(!Action::Rebuild.destructive());
16396 assert!(!Action::RestartAppServer.destructive());
16397 assert!(!Action::SwapCnames.destructive());
16398 }
16399
16400 #[test]
16401 fn scope_prev_is_inverse_of_next() {
16402 assert_eq!(Scope::Envs.next(), Scope::Apps);
16403 assert_eq!(Scope::Envs.prev(), Scope::Apps);
16404 assert_eq!(Scope::Apps.next().next(), Scope::Apps);
16405 assert_eq!(Scope::Envs.prev().prev(), Scope::Envs);
16406 }
16407
16408 #[test]
16409 fn view_mode_labels() {
16410 assert_eq!(ViewMode::Default.label(), "default");
16411 assert_eq!(ViewMode::Compact.label(), "compact");
16412 assert_eq!(ViewMode::Spacious.label(), "spacious");
16413 }
16414
16415 #[test]
16416 fn console_url_includes_region_app_env() {
16417 let url = console_url("us-east-1", "myapp", "myenv");
16418 assert!(url.contains("us-east-1.console.aws.amazon.com"));
16419 assert!(url.contains("region=us-east-1"));
16420 assert!(url.contains("applicationName=myapp"));
16421 assert!(url.contains("environmentName=myenv"));
16422 }
16423
16424 #[test]
16425 fn console_url_encodes_special_chars() {
16426 let url = console_url("us-east-1", "my app", "env/with?slash");
16428 assert!(url.contains("applicationName=my%20app"));
16429 assert!(url.contains("environmentName=env%2Fwith%3Fslash"));
16430 }
16431
16432 #[test]
16433 fn urlencode_keeps_safe_chars() {
16434 assert_eq!(urlencode("hello-world_1.0"), "hello-world_1.0");
16435 assert_eq!(urlencode("a b"), "a%20b");
16436 assert_eq!(urlencode("a/b?c=d"), "a%2Fb%3Fc%3Dd");
16437 assert!(urlencode("café").starts_with("caf"));
16439 }
16440
16441 #[test]
16442 fn json_escape_handles_quotes_and_controls() {
16443 assert_eq!(json_escape("hello"), "hello");
16444 assert_eq!(json_escape(r#"he said "hi""#), r#"he said \"hi\""#);
16445 assert_eq!(json_escape("line\nbreak"), "line\\nbreak");
16446 assert_eq!(json_escape("\\path"), "\\\\path");
16447 let out = json_escape("\u{0001}");
16449 assert_eq!(out, "\\u0001");
16450 }
16451
16452 #[test]
16453 fn build_describe_cli_no_profile() {
16454 let cmd = build_describe_cli("my-env", "eu-west-2", None);
16455 assert_eq!(
16456 cmd,
16457 "aws elasticbeanstalk describe-environments --environment-names my-env --region eu-west-2"
16458 );
16459 }
16460
16461 #[test]
16462 fn build_describe_cli_with_profile_and_special_chars() {
16463 let cmd = build_describe_cli("my env!", "eu-west-2", Some("prod"));
16464 assert!(cmd.contains("--environment-names 'my env!'"));
16465 assert!(cmd.contains("--profile prod"));
16466 }
16467
16468 fn fake_env_with(
16469 name: &str,
16470 status: &str,
16471 health: &str,
16472 updated_minutes_ago: Option<i64>,
16473 ) -> Environment {
16474 let updated =
16475 updated_minutes_ago.map(|m| chrono::Utc::now() - chrono::Duration::minutes(m));
16476 Environment {
16477 name: name.into(),
16478 application: "app".into(),
16479 status: status.into(),
16480 health: health.into(),
16481 platform: "Java 17".into(),
16482 solution_stack: String::new(),
16483 tier: "Web".into(),
16484 cname: "x.elb".into(),
16485 version_label: "v1".into(),
16486 arn: None,
16487 updated,
16488 id: None,
16489 region: None,
16490 }
16491 }
16492
16493 #[test]
16494 fn app_rollup_counts_envs_red_and_updating() {
16495 let envs = vec![
16496 crate::aws::Environment {
16497 name: "prod".into(),
16498 application: "foo".into(),
16499 status: "Ready".into(),
16500 health: "Green".into(),
16501 platform: "Java 17".into(),
16502 solution_stack: String::new(),
16503 tier: "WebServer".into(),
16504 cname: String::new(),
16505 version_label: String::new(),
16506 arn: None,
16507 updated: None,
16508 id: None,
16509 region: None,
16510 },
16511 crate::aws::Environment {
16512 name: "staging".into(),
16513 application: "foo".into(),
16514 status: "Updating".into(),
16515 health: "Red".into(),
16516 platform: "Java 17".into(),
16517 solution_stack: String::new(),
16518 tier: "WebServer".into(),
16519 cname: String::new(),
16520 version_label: String::new(),
16521 arn: None,
16522 updated: None,
16523 id: None,
16524 region: None,
16525 },
16526 crate::aws::Environment {
16527 name: "other-app".into(),
16528 application: "bar".into(),
16529 status: "Ready".into(),
16530 health: "Green".into(),
16531 platform: "Java 17".into(),
16532 solution_stack: String::new(),
16533 tier: "WebServer".into(),
16534 cname: String::new(),
16535 version_label: String::new(),
16536 arn: None,
16537 updated: None,
16538 id: None,
16539 region: None,
16540 },
16541 ];
16542 let dlq: HashMap<String, i64> = HashMap::new();
16543 let r = super::app_rollup(&envs, "foo", &dlq);
16544 assert_eq!(r.env_count, 2, "foo has 2 envs (prod + staging)");
16545 assert_eq!(r.red_count, 1, "staging is Red");
16546 assert_eq!(r.updating_count, 1, "staging is Updating");
16547 assert_eq!(r.worker_dlq_alerts, 0, "no worker envs in foo");
16548 }
16549
16550 #[test]
16551 fn app_rollup_worker_dlq_alert_counts() {
16552 let envs = vec![crate::aws::Environment {
16553 name: "worker-prod".into(),
16554 application: "wapp".into(),
16555 status: "Ready".into(),
16556 health: "Green".into(),
16557 platform: "Java 17".into(),
16558 solution_stack: String::new(),
16559 tier: "Worker".into(),
16560 cname: String::new(),
16561 version_label: String::new(),
16562 arn: None,
16563 updated: None,
16564 id: None,
16565 region: None,
16566 }];
16567 let mut dlq: HashMap<String, i64> = HashMap::new();
16568 dlq.insert("worker-prod".into(), 7);
16569 let r = super::app_rollup(&envs, "wapp", &dlq);
16570 assert_eq!(r.env_count, 1);
16572 assert_eq!(r.red_count, 0, "EB health stays Green");
16573 assert_eq!(
16574 r.worker_dlq_alerts, 1,
16575 "worker env with DLQ depth > 0 counts as alerting"
16576 );
16577 }
16578
16579 #[test]
16580 fn app_rollup_empty_for_unknown_app() {
16581 let envs: Vec<crate::aws::Environment> = vec![];
16582 let dlq: HashMap<String, i64> = HashMap::new();
16583 let r = super::app_rollup(&envs, "nope", &dlq);
16584 assert_eq!(r, super::AppRollup::default());
16585 }
16586
16587 fn opt(
16588 ns: &str,
16589 name: &str,
16590 value: Option<&str>,
16591 default: Option<&str>,
16592 ) -> crate::aws::ConfigOption {
16593 crate::aws::ConfigOption {
16594 namespace: ns.into(),
16595 name: name.into(),
16596 value: value.map(String::from),
16597 default_value: default.map(String::from),
16598 value_type: "Scalar".into(),
16599 value_options: vec![],
16600 change_severity: None,
16601 user_defined: Some(true),
16602 min_value: None,
16603 max_value: None,
16604 max_length: None,
16605 }
16606 }
16607
16608 #[test]
16609 fn diff_config_options_reports_only_differences() {
16610 let left = vec![
16611 opt("aws:autoscaling:asg", "MinSize", Some("2"), None),
16612 opt("aws:autoscaling:asg", "MaxSize", Some("4"), None),
16613 opt(
16614 "aws:elasticbeanstalk:application:environment",
16615 "LOG",
16616 Some("info"),
16617 None,
16618 ),
16619 opt("aws:foo", "Same", Some("x"), None),
16620 ];
16621 let right = vec![
16622 opt("aws:autoscaling:asg", "MinSize", Some("3"), None), opt("aws:autoscaling:asg", "MaxSize", Some("4"), None), opt(
16625 "aws:elasticbeanstalk:application:environment",
16626 "LOG",
16627 None,
16628 None,
16629 ), opt("aws:foo", "Same", Some("x"), None), ];
16632 let diffs = super::diff_config_options(&left, &right);
16633 assert_eq!(diffs.len(), 2, "got {diffs:?}");
16634 let min = diffs.iter().find(|d| d.name == "MinSize").unwrap();
16635 assert_eq!(min.left.as_deref(), Some("2"));
16636 assert_eq!(min.right.as_deref(), Some("3"));
16637 let log = diffs.iter().find(|d| d.name == "LOG").unwrap();
16638 assert_eq!(log.left.as_deref(), Some("info"));
16639 assert_eq!(log.right, None);
16640 }
16641
16642 #[test]
16643 fn diff_config_options_treats_empty_string_as_unset() {
16644 let left = vec![opt("aws:foo", "Bar", Some(""), None)];
16647 let right = vec![opt("aws:foo", "Bar", None, None)];
16648 assert!(super::diff_config_options(&left, &right).is_empty());
16649 }
16650
16651 #[test]
16652 fn render_config_diff_overlay_states() {
16653 let body = super::render_config_diff_overlay("staging", "prod", &[]);
16655 assert!(body.contains("identical"));
16656 let diffs = vec![super::ConfigDiff {
16658 namespace: "aws:autoscaling:asg".into(),
16659 name: "MinSize".into(),
16660 left: Some("2".into()),
16661 right: None,
16662 }];
16663 let body = super::render_config_diff_overlay("staging", "prod", &diffs);
16664 assert!(body.contains("aws:autoscaling:asg"));
16665 assert!(body.contains("MinSize"));
16666 assert!(body.contains("L: 2"));
16667 assert!(body.contains("R: (unset)"));
16668 }
16669
16670 #[test]
16671 fn build_env_edit_body_sorts_keys_and_emits_header() {
16672 let vars = vec![
16673 ("LOG_LEVEL".into(), "info".into()),
16674 ("DB_HOST".into(), "db.example".into()),
16675 ("DB_PORT".into(), "5432".into()),
16676 ];
16677 let body = super::build_env_edit_body("prod", &vars);
16678 assert!(body.starts_with("# ebman env-var editor — prod\n"));
16680 assert!(body.contains("Secrets Manager"));
16681 let db_host_pos = body.find("DB_HOST=").expect("DB_HOST line");
16683 let db_port_pos = body.find("DB_PORT=").expect("DB_PORT line");
16684 let log_pos = body.find("LOG_LEVEL=").expect("LOG_LEVEL line");
16685 assert!(db_host_pos < db_port_pos && db_port_pos < log_pos);
16686 }
16687
16688 #[test]
16689 fn parse_env_edit_body_round_trip() {
16690 let vars = vec![
16691 ("LOG_LEVEL".into(), "info".into()),
16692 (
16693 "DB_URL".into(),
16694 "postgres://user:pass@host:5432/db?sslmode=require".into(),
16695 ),
16696 ];
16697 let body = super::build_env_edit_body("env", &vars);
16698 let parsed = super::parse_env_edit_body(&body);
16699 assert_eq!(parsed.get("LOG_LEVEL").map(String::as_str), Some("info"));
16700 assert_eq!(
16703 parsed.get("DB_URL").map(String::as_str),
16704 Some("postgres://user:pass@host:5432/db?sslmode=require")
16705 );
16706 }
16707
16708 #[test]
16709 fn parse_env_edit_body_skips_comments_and_blanks() {
16710 let body = "# comment\n\nDB_HOST=localhost\n # indented comment\n\nLOG=debug\n";
16711 let parsed = super::parse_env_edit_body(body);
16712 assert_eq!(parsed.len(), 2);
16713 assert_eq!(parsed.get("DB_HOST").map(String::as_str), Some("localhost"));
16714 assert_eq!(parsed.get("LOG").map(String::as_str), Some("debug"));
16715 }
16716
16717 #[test]
16718 fn parse_env_edit_body_drops_invalid_keys() {
16719 let body = "= no-key\n KEY WITH SPACES=foo\nGOOD=val\n";
16720 let parsed = super::parse_env_edit_body(body);
16721 assert_eq!(parsed.len(), 1);
16722 assert!(parsed.contains_key("GOOD"));
16723 }
16724
16725 #[test]
16726 fn diff_env_vars_produces_set_and_remove_lists() {
16727 let mut original = std::collections::BTreeMap::new();
16728 original.insert("KEEP".into(), "same".into());
16729 original.insert("CHANGE".into(), "old".into());
16730 original.insert("DROP".into(), "going".into());
16731 let mut edited = std::collections::BTreeMap::new();
16732 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);
16737 let set_keys: std::collections::BTreeSet<&str> =
16739 to_set.iter().map(|(_, k, _)| k.as_str()).collect();
16740 assert_eq!(
16741 set_keys,
16742 ["CHANGE", "NEW"]
16743 .into_iter()
16744 .collect::<std::collections::BTreeSet<_>>(),
16745 "to_set should include changed + added keys"
16746 );
16747 assert!(
16748 !set_keys.contains("KEEP"),
16749 "unchanged key must not re-dispatch"
16750 );
16751 assert_eq!(to_remove.len(), 1);
16753 assert_eq!(to_remove[0].1, "DROP");
16754 }
16755
16756 #[test]
16757 fn diff_env_vars_empty_when_unchanged() {
16758 let mut original = std::collections::BTreeMap::new();
16759 original.insert("A".into(), "1".into());
16760 original.insert("B".into(), "2".into());
16761 let edited = original.clone();
16762 let (to_set, to_remove) = super::diff_env_vars("ns", &original, &edited);
16763 assert!(to_set.is_empty());
16764 assert!(to_remove.is_empty());
16765 }
16766
16767 #[test]
16768 fn parse_access_denied_handles_assumed_role() {
16769 let msg = "User: arn:aws:sts::123456789012:assumed-role/EbmanReadOnly/session-abc \
16770 is not authorized to perform: elasticbeanstalk:RebuildEnvironment \
16771 on resource: arn:aws:elasticbeanstalk:eu-west-2:123:environment/foo/bar";
16772 let parsed = super::parse_access_denied(msg);
16773 assert_eq!(
16774 parsed,
16775 Some((
16776 "arn:aws:iam::123456789012:role/EbmanReadOnly".into(),
16777 "elasticbeanstalk:RebuildEnvironment".into()
16778 )),
16779 "assumed-role should be rewritten to the role ARN"
16780 );
16781 }
16782
16783 #[test]
16784 fn parse_access_denied_handles_iam_user() {
16785 let msg = "User: arn:aws:iam::123456789012:user/alice is not authorized to \
16786 perform: s3:GetObject on resource: arn:aws:s3:::bucket/key";
16787 let parsed = super::parse_access_denied(msg);
16788 assert_eq!(
16789 parsed,
16790 Some((
16791 "arn:aws:iam::123456789012:user/alice".into(),
16792 "s3:GetObject".into()
16793 )),
16794 "IAM-user ARN should pass through unchanged"
16795 );
16796 }
16797
16798 #[test]
16799 fn parse_access_denied_returns_none_on_unrelated_error() {
16800 assert_eq!(
16801 super::parse_access_denied("ThrottlingException: rate exceeded"),
16802 None
16803 );
16804 assert_eq!(super::parse_access_denied("random garbage text"), None);
16805 }
16806
16807 #[test]
16808 fn render_explain_overlay_marks_decisions_and_suggests_fix() {
16809 let rows = vec![
16810 crate::aws::IamSimResult {
16811 action: "elasticbeanstalk:RebuildEnvironment".into(),
16812 resource: "*".into(),
16813 decision: "implicitDeny".into(),
16814 matched_statements: vec![],
16815 missing_context: vec![],
16816 blocked_by_scp: false,
16817 blocked_by_boundary: false,
16818 },
16819 crate::aws::IamSimResult {
16820 action: "ec2:DescribeInstances".into(),
16821 resource: "*".into(),
16822 decision: "allowed".into(),
16823 matched_statements: vec![
16824 "arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess @ 0:0".into()
16825 ],
16826 missing_context: vec![],
16827 blocked_by_scp: false,
16828 blocked_by_boundary: false,
16829 },
16830 ];
16831 let body = super::render_explain_overlay("arn:aws:iam::123:role/EbmanReadOnly", &rows);
16832 assert!(body.contains("Action: elasticbeanstalk:RebuildEnvironment"));
16834 assert!(body.contains("✗ implicitDeny"));
16835 assert!(body.contains("Action: ec2:DescribeInstances"));
16836 assert!(body.contains("✓ allowed"));
16837 assert!(body.contains("\"Effect\": \"Allow\""));
16839 assert!(body.contains("\"Action\": \"elasticbeanstalk:RebuildEnvironment\""));
16840 assert!(body.matches("To allow, add this statement").count() == 1);
16842 assert!(body.contains("AmazonEC2ReadOnlyAccess"));
16844 }
16845
16846 #[test]
16847 fn render_explain_overlay_flags_scp_and_boundary_blockers() {
16848 let rows = vec![crate::aws::IamSimResult {
16849 action: "ec2:TerminateInstances".into(),
16850 resource: "*".into(),
16851 decision: "explicitDeny".into(),
16852 matched_statements: vec!["org-scp/SCPDenyTerminate @ 0:0".into()],
16853 missing_context: vec![],
16854 blocked_by_scp: true,
16855 blocked_by_boundary: true,
16856 }];
16857 let body = super::render_explain_overlay("arn:aws:iam::123:role/X", &rows);
16858 assert!(body.contains("Organizations SCP"));
16859 assert!(body.contains("permission boundary"));
16860 assert!(body.contains("explicit Deny always wins"));
16863 assert!(!body.contains("\"Effect\": \"Allow\""));
16864 }
16865
16866 fn empty_resources() -> crate::aws::EnvResources {
16867 crate::aws::EnvResources::default()
16868 }
16869
16870 #[test]
16871 fn render_env_resources_tree_shows_asg_with_nested_instances() {
16872 let mut res = empty_resources();
16873 res.asgs = vec!["awseb-AWSEBAutoScalingGroup-XYZ".into()];
16874 res.instances = vec!["i-0abc".into(), "i-0def".into(), "i-0ghi".into()];
16875 let body = super::render_env_resources_tree(&res, "prod-api", "Web");
16876 assert!(body.contains("Auto-scaling groups (1)"));
16878 assert!(body.contains("└─ awseb-AWSEBAutoScalingGroup-XYZ"));
16880 assert!(body.contains("├─ i-0abc"));
16882 assert!(body.contains("├─ i-0def"));
16883 assert!(body.contains("└─ i-0ghi"));
16884 }
16885
16886 #[test]
16887 fn render_env_resources_tree_skips_empty_sections() {
16888 let mut res = empty_resources();
16889 res.asgs = vec!["asg-1".into()];
16890 let body = super::render_env_resources_tree(&res, "small-env", "Web");
16892 assert!(body.contains("Auto-scaling groups (1)"));
16893 assert!(!body.contains("Load balancers"));
16896 assert!(!body.contains("Launch configurations"));
16897 assert!(!body.contains("Queues"));
16898 }
16899
16900 #[test]
16901 fn render_env_resources_tree_marks_orphan_instances_when_no_asg() {
16902 let mut res = empty_resources();
16903 res.instances = vec!["i-stranded".into()];
16904 let body = super::render_env_resources_tree(&res, "env", "Web");
16905 assert!(body.contains("orphan (no ASG attached)"));
16906 assert!(body.contains("i-stranded"));
16907 }
16908
16909 #[test]
16910 fn render_env_resources_tree_renders_queue_urls_inline() {
16911 let mut res = empty_resources();
16912 res.queues = vec![
16913 crate::aws::EnvResourceQueue {
16914 name: "WorkerQueue".into(),
16915 url: "https://sqs.eu-west-2.amazonaws.com/123/main".into(),
16916 },
16917 crate::aws::EnvResourceQueue {
16918 name: "WorkerDeadLetterQueue".into(),
16919 url: "https://sqs.eu-west-2.amazonaws.com/123/dlq".into(),
16920 },
16921 ];
16922 let body = super::render_env_resources_tree(&res, "worker-prod", "Worker");
16923 assert!(body.contains("├─ WorkerQueue"));
16924 assert!(body.contains("https://sqs.eu-west-2.amazonaws.com/123/main"));
16925 assert!(body.contains("└─ WorkerDeadLetterQueue"));
16926 assert!(body.contains("https://sqs.eu-west-2.amazonaws.com/123/dlq"));
16927 }
16928
16929 #[test]
16930 fn render_env_resources_tree_handles_zero_resources() {
16931 let res = empty_resources();
16932 let body = super::render_env_resources_tree(&res, "fresh-env", "Web");
16933 assert!(body.contains("(no resources reported"));
16934 }
16935
16936 #[tokio::test]
16937 async fn first_run_hint_dismisses_on_first_key() {
16938 let mut app = test_app();
16939 app.first_run_hint = true;
16940 press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE);
16941 assert!(
16942 !app.first_run_hint,
16943 "first key event should clear first_run_hint"
16944 );
16945 }
16946
16947 #[tokio::test]
16948 async fn first_run_hint_stays_false_for_subsequent_launches() {
16949 let app = test_app();
16954 assert!(
16955 !app.first_run_hint,
16956 "test harness must default first_run_hint=false (state.toml presumed present)"
16957 );
16958 }
16959
16960 #[test]
16961 fn edit_distance_basic_cases() {
16962 assert_eq!(super::edit_distance("", ""), 0);
16963 assert_eq!(super::edit_distance("abc", ""), 3);
16964 assert_eq!(super::edit_distance("", "abc"), 3);
16965 assert_eq!(super::edit_distance("kitten", "sitting"), 3);
16966 assert_eq!(super::edit_distance("restart", "restart"), 0);
16967 assert_eq!(super::edit_distance("restrt", "restart"), 1);
16968 assert_eq!(super::edit_distance("rebild", "rebuild"), 1);
16969 assert_eq!(super::edit_distance("scal", "scale"), 1);
16970 }
16971
16972 #[test]
16973 fn suggest_command_catches_one_char_typos() {
16974 assert_eq!(super::suggest_command("restrt").as_deref(), Some("restart"));
16976 assert_eq!(super::suggest_command("rebild").as_deref(), Some("rebuild"));
16978 assert_eq!(super::suggest_command("scal").as_deref(), Some("scale"));
16980 }
16981
16982 #[test]
16983 fn suggest_command_returns_none_when_too_far() {
16984 assert_eq!(super::suggest_command("zzzzzz"), None);
16986 }
16987
16988 #[test]
16989 fn suggest_command_threshold_is_strict_for_short_input() {
16990 let suggestion = super::suggest_command("zz");
16994 assert!(
16995 suggestion.is_none(),
16996 "2-char typo should require distance ≤ 1; got {suggestion:?}"
16997 );
16998 }
16999
17000 #[test]
17001 fn completion_candidates_filters_by_prefix() {
17002 let c = super::completion_candidates("ba");
17003 assert!(
17004 c.iter().any(|s| s == "batch-rebuild"),
17005 "expected batch-rebuild among ba-prefixed candidates; got {c:?}"
17006 );
17007 assert!(
17008 c.iter().all(|s| s.starts_with("ba")),
17009 "every candidate must start with the prefix; got {c:?}"
17010 );
17011 assert_eq!(
17012 c.clone(),
17013 {
17014 let mut sorted = c.clone();
17015 sorted.sort();
17016 sorted
17017 },
17018 "candidates must be alphabetically sorted"
17019 );
17020 }
17021
17022 #[test]
17023 fn completion_candidates_with_empty_prefix_returns_full_list() {
17024 let c = super::completion_candidates("");
17025 assert!(
17028 c.len() > 50,
17029 "expected the full command list; got {} entries",
17030 c.len()
17031 );
17032 assert!(c.iter().any(|s| s == "why"));
17033 assert!(c.iter().any(|s| s == "rebuild"));
17034 }
17035
17036 #[tokio::test]
17037 async fn tab_in_command_mode_cycles_through_matches() {
17038 let mut app = test_app();
17039 app.mode = Mode::Command;
17040 app.command_input = "bat".into();
17041 press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
17043 let first = app.command_input.clone();
17044 assert!(
17045 first.starts_with("bat"),
17046 "Tab should keep the bat-prefix; got {first:?}"
17047 );
17048 assert!(
17049 crate::commands::all_names().contains(&first.as_str()),
17050 "Tab should expand to a real command name; got {first:?}"
17051 );
17052 press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
17054 let second = app.command_input.clone();
17055 assert_ne!(first, second, "second Tab should advance the cycle");
17056 }
17057
17058 #[tokio::test]
17059 async fn typing_in_command_mode_breaks_the_completion_cycle() {
17060 let mut app = test_app();
17061 app.mode = Mode::Command;
17062 app.command_input = "re".into();
17063 press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
17064 assert!(app.completion.origin.is_some());
17065 press(&mut app, KeyCode::Char('s'), KeyModifiers::NONE);
17067 assert!(
17068 app.completion.origin.is_none(),
17069 "typing must reset the completion origin"
17070 );
17071 }
17072
17073 #[tokio::test]
17074 async fn shift_tab_cycles_backward() {
17075 let mut app = test_app();
17076 app.mode = Mode::Command;
17077 app.command_input = "ba".into();
17078 press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
17079 let forward = app.command_input.clone();
17080 press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
17081 press(&mut app, KeyCode::BackTab, KeyModifiers::NONE);
17082 assert_eq!(
17084 app.command_input, forward,
17085 "Tab Tab BackTab should land on the first match"
17086 );
17087 }
17088
17089 #[test]
17090 fn render_options_overlay_groups_by_namespace_and_marks_set_vs_default() {
17091 let rows = vec![
17092 opt("aws:autoscaling:asg", "MinSize", Some("2"), Some("1")),
17093 opt("aws:autoscaling:asg", "MaxSize", None, Some("4")),
17094 opt(
17095 "aws:elasticbeanstalk:command",
17096 "DeploymentPolicy",
17097 Some("Rolling"),
17098 Some("AllAtOnce"),
17099 ),
17100 ];
17101 let body = super::render_options_overlay(&rows, None, "uflexi-prod");
17102 assert!(body.contains("── aws:autoscaling:asg ──"));
17104 assert!(body.contains("── aws:elasticbeanstalk:command ──"));
17105 assert!(body.contains("▸ MinSize"));
17107 assert!(body.contains("• MaxSize"));
17108 assert!(body.contains("▸ DeploymentPolicy"));
17109 assert!(body.contains("default: 1"));
17111 assert!(body.contains("default: 4"));
17112 assert!(body.contains("2/3 options are operator-set"));
17114 }
17115
17116 #[test]
17117 fn render_options_overlay_filters_to_namespace_when_given() {
17118 let rows = vec![
17119 opt("aws:autoscaling:asg", "MinSize", Some("2"), None),
17120 opt(
17121 "aws:elasticbeanstalk:command",
17122 "DeploymentPolicy",
17123 Some("Rolling"),
17124 None,
17125 ),
17126 ];
17127 let body = super::render_options_overlay(&rows, Some("aws:autoscaling:asg"), "uflexi-prod");
17128 assert!(body.contains("MinSize"));
17129 assert!(!body.contains("DeploymentPolicy"));
17130 }
17131
17132 #[test]
17133 fn render_options_overlay_handles_unknown_namespace() {
17134 let rows = vec![opt("aws:autoscaling:asg", "MinSize", Some("2"), None)];
17135 let body = super::render_options_overlay(&rows, Some("aws:bogus:ns"), "uflexi-prod");
17136 assert!(body.contains("No options found"));
17137 assert!(body.contains("aws:bogus:ns"));
17138 }
17139
17140 #[test]
17141 fn render_secrets_overlay_empty_with_filter_explains_region_scope() {
17142 let body = super::render_secrets_overlay(&[], Some("prod-db"));
17143 assert!(body.contains("No secrets matching 'prod-db'"));
17144 assert!(body.contains("region-scoped"));
17145 }
17146
17147 #[test]
17148 fn render_secrets_overlay_empty_no_filter_hints_at_iam() {
17149 let body = super::render_secrets_overlay(&[], None);
17150 assert!(body.contains("No Secrets Manager secrets"));
17151 assert!(body.contains("ListSecrets"));
17152 }
17153
17154 #[test]
17155 fn render_secrets_overlay_lists_metadata_only() {
17156 let now = chrono::Utc::now();
17157 let rows = vec![crate::aws::SecretSummary {
17158 name: "prod/db/password".into(),
17159 arn: "arn:aws:secretsmanager:us-east-1:123456789012:secret:prod/db/password-AbCdEf"
17160 .into(),
17161 description: Some("RDS master".into()),
17162 last_changed: Some(now - chrono::Duration::days(3)),
17163 last_rotated: Some(now - chrono::Duration::days(30)),
17164 kms_key_id: Some("alias/aws/secretsmanager".into()),
17165 }];
17166 let body = super::render_secrets_overlay(&rows, None);
17167 assert!(body.contains("prod/db/password"));
17168 assert!(body.contains("RDS master"));
17169 assert!(body.contains("arn:aws:secretsmanager"));
17170 assert!(body.contains("changed:"));
17171 assert!(body.contains("rotated:"));
17172 assert!(body.contains("alias/aws/secretsmanager"));
17173 assert!(!body.to_lowercase().contains("password:"));
17175 }
17176
17177 #[test]
17178 fn render_secrets_overlay_marks_never_rotated() {
17179 let now = chrono::Utc::now();
17180 let rows = vec![crate::aws::SecretSummary {
17181 name: "api-key".into(),
17182 arn: "arn:aws:secretsmanager:us-east-1:1:secret:api-key-x".into(),
17183 description: None,
17184 last_changed: Some(now - chrono::Duration::hours(2)),
17185 last_rotated: None,
17186 kms_key_id: None,
17187 }];
17188 let body = super::render_secrets_overlay(&rows, None);
17189 assert!(body.contains("rotated: never"));
17190 }
17191
17192 #[test]
17193 fn render_secret_value_overlay_redacts_when_redact_on() {
17194 let body = super::render_secret_value_overlay("api-key", "hunter2", true);
17195 assert!(body.contains("<redacted; 7 chars"));
17196 assert!(body.contains("fingerprint"));
17197 assert!(!body.contains("hunter2"));
17198 assert!(body.contains(":redact off"));
17199 }
17200
17201 #[test]
17202 fn render_secret_value_overlay_shows_value_when_redact_off() {
17203 let body = super::render_secret_value_overlay("api-key", "hunter2", false);
17204 assert!(body.contains("hunter2"));
17205 assert!(body.contains("yank"));
17206 }
17207
17208 #[test]
17209 fn render_secret_value_overlay_pretty_prints_json() {
17210 let body = super::render_secret_value_overlay(
17211 "prod/db",
17212 r#"{"USERNAME":"app","PASSWORD":"x"}"#,
17213 false,
17214 );
17215 assert!(body.contains("USERNAME"));
17217 assert!(body.contains("PASSWORD"));
17218 assert!(
17219 body.matches('\n').count() >= 4,
17220 "should pretty-print: {body}"
17221 );
17222 }
17223
17224 #[test]
17225 fn render_secret_value_overlay_leaves_non_json_alone() {
17226 let body = super::render_secret_value_overlay("flat", "ABC-DEF-GHI", false);
17227 assert!(body.contains("ABC-DEF-GHI"));
17228 }
17229
17230 #[test]
17231 fn short_fingerprint_is_stable_and_diffs() {
17232 let a = super::short_fingerprint("hunter2");
17233 let b = super::short_fingerprint("hunter2");
17234 let c = super::short_fingerprint("hunter3");
17235 assert_eq!(a, b);
17236 assert_ne!(a, c);
17237 assert_eq!(a.len(), 8);
17238 }
17239
17240 #[test]
17241 fn try_pretty_json_passes_through_non_json() {
17242 assert_eq!(super::try_pretty_json("just a string"), "just a string");
17243 assert_eq!(super::try_pretty_json(""), "");
17244 }
17245
17246 #[test]
17247 fn try_pretty_json_indents_objects() {
17248 let pretty = super::try_pretty_json(r#"{"a":1,"b":2}"#);
17249 let lines: Vec<&str> = pretty.lines().collect();
17250 assert!(lines.len() >= 4, "lines={lines:?}");
17251 assert!(lines.iter().any(|l| l.contains("\"a\": 1")));
17252 assert!(lines.iter().any(|l| l.contains("\"b\": 2")));
17253 }
17254
17255 #[test]
17256 fn try_pretty_json_emits_empty_containers_inline() {
17257 assert_eq!(super::try_pretty_json("{}"), "{}");
17259 assert_eq!(super::try_pretty_json("[]"), "[]");
17260 let pretty = super::try_pretty_json(r#"{"a":{}}"#);
17263 assert!(pretty.contains("\"a\": {}"), "got: {pretty}");
17264 }
17265
17266 #[test]
17267 fn try_pretty_json_preserves_strings_with_braces() {
17268 let pretty = super::try_pretty_json(r#"{"msg":"hello {world}"}"#);
17270 assert!(pretty.contains("hello {world}"));
17271 }
17272
17273 #[test]
17274 fn format_age_buckets() {
17275 let now = chrono::Utc::now();
17276 assert!(super::format_age(now, now).ends_with("s ago"));
17277 assert!(super::format_age(now, now - chrono::Duration::seconds(120)).ends_with("m ago"));
17278 assert!(super::format_age(now, now - chrono::Duration::hours(5)).ends_with("h ago"));
17279 assert!(super::format_age(now, now - chrono::Duration::days(10)).ends_with("d ago"));
17280 let body = super::format_age(now, now - chrono::Duration::days(120));
17281 assert!(body.starts_with('~') && body.contains("mo"));
17282 }
17283
17284 #[test]
17285 fn render_options_overlay_truncates_long_value_options_list() {
17286 let mut row = opt("aws:foo", "Enum", Some("a"), None);
17287 row.value_options = (0..20).map(|i| format!("v{i}")).collect();
17288 let rows = vec![row];
17289 let body = super::render_options_overlay(&rows, None, "env");
17290 assert!(body.contains("oneof: v0, v1, v2, v3, v4, … +15"));
17291 }
17292
17293 #[test]
17294 fn flatten_err_marks_access_denied() {
17295 let e = color_eyre::eyre::eyre!("operation failed")
17296 .wrap_err("AccessDeniedException: User: arn:aws:sts::1234 is not authorized");
17297 let out = super::flatten_err_to_string(&e);
17298 assert!(out.starts_with("AccessDenied:"), "got: {out}");
17299 }
17300
17301 #[test]
17302 fn flatten_err_marks_not_found() {
17303 let e = color_eyre::eyre::eyre!("operation failed")
17304 .wrap_err("ResourceNotFoundException: alarm 'foo' does not exist");
17305 let out = super::flatten_err_to_string(&e);
17306 assert!(out.starts_with("NotFound:"), "got: {out}");
17307 }
17308
17309 #[test]
17310 fn flatten_err_marks_dependency_violation() {
17311 let e = color_eyre::eyre::eyre!("operation failed")
17312 .wrap_err("DependencyViolation: resource still has dependencies");
17313 let out = super::flatten_err_to_string(&e);
17314 assert!(out.starts_with("Conflict:"), "got: {out}");
17315 }
17316
17317 #[test]
17318 fn flatten_err_marks_expired_token() {
17319 let e = color_eyre::eyre::eyre!("operation failed")
17320 .wrap_err("ExpiredToken: session credentials expired");
17321 let out = super::flatten_err_to_string(&e);
17322 assert!(out.starts_with("ExpiredToken:"), "got: {out}");
17323 }
17324
17325 #[test]
17326 fn flatten_err_passes_unknown_through_unchanged() {
17327 let e = color_eyre::eyre::eyre!("some other failure");
17328 let out = super::flatten_err_to_string(&e);
17329 assert!(
17330 !out.contains(":"),
17331 "expected no classification prefix; got: {out}"
17332 );
17333 }
17334
17335 #[test]
17336 fn traffic_warning_flags_updating() {
17337 let e = fake_env_with("prod", "Updating", "Yellow", Some(20));
17338 assert!(super::compute_traffic_warning(&e)
17339 .unwrap()
17340 .contains("ACTIVE DEPLOY"));
17341 }
17342
17343 #[test]
17344 fn traffic_warning_flags_recent_change() {
17345 let e = fake_env_with("prod", "Ready", "Green", Some(2));
17346 assert!(super::compute_traffic_warning(&e)
17347 .unwrap()
17348 .contains("RECENT CHANGE"));
17349 }
17350
17351 #[test]
17352 fn traffic_warning_silent_on_quiet_env() {
17353 let e = fake_env_with("prod", "Ready", "Green", Some(60));
17354 assert!(super::compute_traffic_warning(&e).is_none());
17355 }
17356
17357 #[test]
17358 fn traffic_warning_flags_red_health() {
17359 let e = fake_env_with("prod", "Ready", "Red", Some(120));
17360 assert!(super::compute_traffic_warning(&e).unwrap().contains("Red"));
17361 }
17362
17363 #[test]
17364 fn is_throttling_error_matches_common_aws_strings() {
17365 assert!(is_throttling_error("ThrottlingException: Rate exceeded"));
17366 assert!(is_throttling_error(
17367 "service error: ThrottlingException — please slow down"
17368 ));
17369 assert!(is_throttling_error("RequestLimitExceeded"));
17370 assert!(is_throttling_error("HTTP 429 Too Many Requests"));
17371 assert!(is_throttling_error("rate exceeded for this account"));
17372 assert!(!is_throttling_error("EnvironmentNotFound"));
17374 assert!(!is_throttling_error("AccessDenied"));
17375 assert!(!is_throttling_error(""));
17376 }
17377
17378 #[test]
17379 fn throttle_backoff_grows_then_caps() {
17380 let base = Duration::from_secs(15);
17381 let b0 = throttle_backoff(base, 0);
17382 let b1 = throttle_backoff(base, 1);
17383 let b2 = throttle_backoff(base, 2);
17384 assert_eq!(b0, Duration::from_secs(30));
17386 assert_eq!(b1, Duration::from_secs(60));
17387 assert_eq!(b2, Duration::from_secs(120));
17388 let bn = throttle_backoff(base, 30);
17390 assert_eq!(bn, Duration::from_secs(300));
17391 }
17392
17393 #[test]
17394 fn throttle_backoff_handles_overflow_safely() {
17395 let base = Duration::MAX;
17397 let b = throttle_backoff(base, 5);
17398 assert_eq!(b, Duration::from_secs(300));
17399 }
17400
17401 #[test]
17402 fn delta_toast_key_extracts_bucket_for_delta_shapes() {
17403 assert_eq!(super::delta_toast_key("▲2 Red").as_deref(), Some("Red"));
17404 assert_eq!(
17405 super::delta_toast_key("▼1 Yellow").as_deref(),
17406 Some("Yellow")
17407 );
17408 assert_eq!(
17410 super::delta_toast_key(" ▲10 Green").as_deref(),
17411 Some("Green")
17412 );
17413 }
17414
17415 #[test]
17416 fn format_app_versions_marks_deployed_and_shows_total_when_truncated() {
17417 use crate::aws::AppVersion;
17418 let mk = |label: &str, desc: &str| AppVersion {
17419 label: label.into(),
17420 description: desc.into(),
17421 created: None,
17422 };
17423 let versions: Vec<AppVersion> = (1..=30)
17424 .map(|i| {
17425 mk(
17426 &format!("build-{i}"),
17427 &format!("Application version created from https://example.com/build/{i}"),
17428 )
17429 })
17430 .rev()
17431 .collect();
17432 let out = super::format_app_versions(&versions, Some("build-5"), 20);
17436 assert!(out.contains("showing 20 of 30"));
17437 assert!(!out.contains("◀ deployed"));
17438 assert!(out.contains("https://example.com/build/"));
17440 assert!(!out.contains("Application version created from "));
17441 }
17442
17443 #[test]
17444 fn format_app_versions_marks_deployed_when_present() {
17445 use crate::aws::AppVersion;
17446 let versions = vec![
17447 AppVersion {
17448 label: "build-3".into(),
17449 description: String::new(),
17450 created: None,
17451 },
17452 AppVersion {
17453 label: "build-2".into(),
17454 description: String::new(),
17455 created: None,
17456 },
17457 ];
17458 let out = super::format_app_versions(&versions, Some("build-2"), 20);
17459 assert!(out.contains("◀ deployed"));
17460 assert!(!out.contains("showing "));
17462 }
17463
17464 #[test]
17465 fn wrap_with_hanging_indent_first_line_keeps_lead_marker() {
17466 let out = super::wrap_with_hanging_indent(
17467 "Threshold Crossed: alarm details continue",
17468 30,
17469 " ↳ ",
17470 " ",
17471 );
17472 let lines: Vec<&str> = out.lines().collect();
17473 assert!(lines[0].starts_with(" ↳ "));
17474 if lines.len() > 1 {
17476 assert!(lines[1].starts_with(" "));
17477 }
17478 }
17479
17480 #[test]
17481 fn wrap_with_hanging_indent_hard_breaks_oversize_words() {
17482 let big_word = "x".repeat(50);
17484 let out = super::wrap_with_hanging_indent(&big_word, 20, " ", " ");
17485 let lines: Vec<&str> = out.lines().collect();
17486 assert!(lines.len() >= 3);
17487 }
17488
17489 #[test]
17490 fn parse_s3_url_extracts_bucket_and_key() {
17491 let (b, k) = super::parse_s3_url("s3://my-bucket/path/to/bundle.zip").unwrap();
17492 assert_eq!(b, "my-bucket");
17493 assert_eq!(k, "path/to/bundle.zip");
17494 }
17495
17496 #[test]
17497 fn parse_s3_url_rejects_malformed() {
17498 assert!(super::parse_s3_url("/local/path.zip").is_none());
17499 assert!(super::parse_s3_url("s3://").is_none());
17500 assert!(super::parse_s3_url("s3://bucket").is_none());
17501 assert!(super::parse_s3_url("s3://bucket/").is_none());
17502 assert!(super::parse_s3_url("s3:///key").is_none());
17503 }
17504
17505 #[test]
17506 fn parse_metric_extra_args_defaults_to_average() {
17507 let (stat, dims) = super::parse_metric_extra_args(&[]);
17508 assert_eq!(stat, "Average");
17509 assert!(dims.is_empty());
17510 }
17511
17512 #[test]
17513 fn parse_metric_extra_args_picks_stat_first() {
17514 let (stat, dims) = super::parse_metric_extra_args(&["Sum"]);
17515 assert_eq!(stat, "Sum");
17516 assert!(dims.is_empty());
17517 }
17518
17519 #[test]
17520 fn parse_metric_extra_args_picks_dims_when_present() {
17521 let (stat, dims) = super::parse_metric_extra_args(&["InstanceId=i-abc"]);
17522 assert_eq!(stat, "Average");
17523 assert_eq!(dims, vec![("InstanceId".into(), "i-abc".into())]);
17524 }
17525
17526 #[test]
17527 fn parse_metric_extra_args_supports_both_in_any_order() {
17528 let (stat, dims) = super::parse_metric_extra_args(&["Sum", "InstanceId=i-abc,Tier=web"]);
17529 assert_eq!(stat, "Sum");
17530 assert_eq!(
17531 dims,
17532 vec![
17533 ("InstanceId".into(), "i-abc".into()),
17534 ("Tier".into(), "web".into()),
17535 ]
17536 );
17537 let (stat, dims) = super::parse_metric_extra_args(&["InstanceId=i-abc", "Sum"]);
17539 assert_eq!(stat, "Sum");
17540 assert_eq!(dims, vec![("InstanceId".into(), "i-abc".into())]);
17541 }
17542
17543 #[test]
17544 fn derive_version_label_uses_filename_stem_and_timestamp() {
17545 let l = super::derive_version_label("./build.zip", 1684512345);
17546 assert_eq!(l, "build_1684512345");
17547 let l = super::derive_version_label("/tmp/myapp-2.1.0.zip", 42);
17548 assert_eq!(l, "myapp-2.1.0_42");
17549 }
17550
17551 #[test]
17552 fn derive_version_label_sanitises_disallowed_chars() {
17553 let l = super::derive_version_label("/tmp/build with spaces & specials!.zip", 1);
17557 assert_eq!(l, "build_with_spaces___specials__1");
17558 }
17559
17560 #[test]
17561 fn derive_version_label_falls_back_to_bundle_on_pathological_input() {
17562 let l = super::derive_version_label("/", 9);
17564 assert_eq!(l, "bundle_9");
17565 }
17566
17567 #[test]
17568 fn expand_tilde_only_replaces_leading() {
17569 let prev = std::env::var_os("HOME");
17571 unsafe {
17573 std::env::set_var("HOME", "/Users/tester");
17574 }
17575 assert_eq!(super::expand_tilde("~/foo/bar"), "/Users/tester/foo/bar");
17576 assert_eq!(super::expand_tilde("/abs/path"), "/abs/path");
17578 assert_eq!(super::expand_tilde("~tom/foo"), "~tom/foo");
17580 assert_eq!(super::expand_tilde("/foo/~/bar"), "/foo/~/bar");
17582 if let Some(v) = prev {
17583 unsafe {
17584 std::env::set_var("HOME", v);
17585 }
17586 } else {
17587 unsafe {
17588 std::env::remove_var("HOME");
17589 }
17590 }
17591 }
17592
17593 #[test]
17594 fn pick_default_log_group_prefers_web_stdout() {
17595 let groups: Vec<String> = vec![
17596 "/aws/elasticbeanstalk/myenv/var/log/eb-engine.log".into(),
17597 "/aws/elasticbeanstalk/myenv/var/log/web.stdout.log".into(),
17598 "/aws/elasticbeanstalk/myenv/var/log/nginx/access.log".into(),
17599 ];
17600 assert_eq!(
17601 super::pick_default_log_group(&groups).as_deref(),
17602 Some("/aws/elasticbeanstalk/myenv/var/log/web.stdout.log")
17603 );
17604 }
17605
17606 #[test]
17607 fn pick_default_log_group_falls_back_to_first() {
17608 let groups: Vec<String> = vec!["/aws/elasticbeanstalk/myenv/var/log/custom.log".into()];
17609 assert_eq!(
17610 super::pick_default_log_group(&groups).as_deref(),
17611 Some("/aws/elasticbeanstalk/myenv/var/log/custom.log")
17612 );
17613 assert_eq!(super::pick_default_log_group(&[]), None);
17615 }
17616
17617 #[test]
17618 fn pick_default_log_group_prefers_engine_log_when_stdout_absent() {
17619 let groups: Vec<String> = vec![
17620 "/aws/elasticbeanstalk/myenv/var/log/nginx/access.log".into(),
17621 "/aws/elasticbeanstalk/myenv/var/log/eb-engine.log".into(),
17622 ];
17623 assert_eq!(
17624 super::pick_default_log_group(&groups).as_deref(),
17625 Some("/aws/elasticbeanstalk/myenv/var/log/eb-engine.log")
17626 );
17627 }
17628
17629 #[test]
17630 fn format_env_vars_aligns_on_equals() {
17631 let vars = vec![
17632 ("DEBUG".into(), "1".into()),
17633 ("DATABASE_URL".into(), "postgres://x".into()),
17634 ];
17635 let out = super::format_env_vars(&vars);
17636 assert!(out.contains("DEBUG"));
17637 assert!(out.contains("= 1"));
17638 assert!(out.contains("DATABASE_URL"));
17639 let vars = vec![("EMPTY".into(), "".into())];
17640 assert!(super::format_env_vars(&vars).contains("\"\""));
17641 }
17642
17643 #[test]
17644 fn format_env_vars_handles_empty_input() {
17645 assert_eq!(super::format_env_vars(&[]), "(no env vars set)");
17646 }
17647
17648 #[test]
17649 fn parse_named_arg_picks_up_value_after_flag() {
17650 let rest: Vec<&str> = vec!["on", "--retention", "14"];
17651 assert_eq!(
17652 super::parse_named_arg::<i32>(&rest, "--retention"),
17653 Some(14)
17654 );
17655 assert_eq!(super::parse_named_arg::<i32>(&["on"], "--retention"), None);
17657 assert_eq!(
17659 super::parse_named_arg::<i32>(&["on", "--retention"], "--retention"),
17660 None
17661 );
17662 assert_eq!(
17664 super::parse_named_arg::<i32>(&["on", "--retention", "abc"], "--retention"),
17665 None
17666 );
17667 }
17668
17669 #[test]
17670 fn alarm_kind_to_metric_covers_known_kinds() {
17671 use crate::app::alarm_kind_to_metric;
17672 let (m, op, _) = alarm_kind_to_metric("health").unwrap();
17673 assert_eq!(m, "EnvironmentHealth");
17674 assert_eq!(op, "LessThanOrEqualToThreshold");
17676 let (m, op, _) = alarm_kind_to_metric("5xx").unwrap();
17677 assert_eq!(m, "ApplicationRequests5xx");
17678 assert_eq!(op, "GreaterThanThreshold");
17679 assert_eq!(alarm_kind_to_metric("req5xx"), alarm_kind_to_metric("5xx"));
17681 assert_eq!(alarm_kind_to_metric("p90"), alarm_kind_to_metric("latency"));
17682 assert!(alarm_kind_to_metric("cpu").is_none());
17684 assert!(alarm_kind_to_metric("").is_none());
17685 }
17686
17687 #[test]
17688 fn format_template_settings_groups_by_namespace() {
17689 let s = vec![
17690 (
17691 "aws:elasticbeanstalk:environment".into(),
17692 "EnvironmentType".into(),
17693 "LoadBalanced".into(),
17694 ),
17695 ("aws:autoscaling:asg".into(), "MinSize".into(), "2".into()),
17696 ("aws:autoscaling:asg".into(), "MaxSize".into(), "8".into()),
17697 ];
17698 let out = super::format_template_settings(&s);
17699 assert!(out.contains("[aws:autoscaling:asg]"));
17700 assert!(out.contains("[aws:elasticbeanstalk:environment]"));
17701 assert!(out.contains("MinSize"));
17702 assert!(out.contains("= 2"));
17703 let s = vec![(
17706 "aws:elasticbeanstalk:application:environment".into(),
17707 "DEBUG".into(),
17708 String::new(),
17709 )];
17710 assert!(super::format_template_settings(&s).contains("DEBUG"));
17711 assert!(super::format_template_settings(&s).contains("\"\""));
17712 }
17713
17714 #[test]
17715 fn format_template_settings_handles_empty_input() {
17716 assert_eq!(super::format_template_settings(&[]), "(no option settings)");
17717 }
17718
17719 #[test]
17720 fn action_labels_are_distinct_and_non_empty() {
17721 use crate::app::Action;
17725 use std::collections::HashSet;
17726 let all = [
17727 Action::Rebuild,
17728 Action::RestartAppServer,
17729 Action::SwapCnames,
17730 Action::Terminate,
17731 Action::Deploy,
17732 Action::UpgradePlatform,
17733 Action::Clone,
17734 Action::Scale,
17735 Action::AbortUpdate,
17736 Action::ConfigSave,
17737 Action::ConfigDelete,
17738 Action::ConfigApply,
17739 Action::TerminateInstance,
17740 ];
17741 let mut labels = HashSet::new();
17742 for a in all {
17743 let l = a.label();
17744 assert!(!l.is_empty(), "{a:?} has empty label");
17745 assert!(labels.insert(l), "{a:?} reuses label {l:?}");
17746 }
17747 }
17748
17749 #[test]
17750 fn collect_saved_configs_flattens_and_sorts_stably() {
17751 use crate::aws::Application;
17752 let app = |name: &str, templates: Vec<String>| Application {
17753 name: name.into(),
17754 description: String::new(),
17755 date_created: None,
17756 date_updated: None,
17757 version_count: 0,
17758 templates,
17759 latest_version_label: None,
17760 latest_version_created: None,
17761 };
17762 let apps = vec![
17763 app("beta", vec!["prod".into(), "canary".into()]),
17764 app("alpha", vec![]),
17765 app("alpha", vec!["staging".into()]),
17766 ];
17767 let out = super::collect_saved_configs(&apps);
17768 assert_eq!(
17769 out,
17770 vec![
17771 ("alpha".into(), "staging".into()),
17772 ("beta".into(), "canary".into()),
17773 ("beta".into(), "prod".into()),
17774 ]
17775 );
17776 }
17777
17778 #[test]
17779 fn collect_saved_configs_empty_when_no_templates() {
17780 use crate::aws::Application;
17781 let apps = vec![Application {
17782 name: "alpha".into(),
17783 description: String::new(),
17784 date_created: None,
17785 date_updated: None,
17786 version_count: 0,
17787 templates: vec![],
17788 latest_version_label: None,
17789 latest_version_created: None,
17790 }];
17791 assert!(super::collect_saved_configs(&apps).is_empty());
17792 }
17793
17794 #[test]
17795 fn merge_app_latest_versions_carries_previous_values_by_name() {
17796 use crate::aws::Application;
17797 let mk = |name: &str,
17798 label: Option<&str>,
17799 created: Option<chrono::DateTime<chrono::Utc>>|
17800 -> Application {
17801 Application {
17802 name: name.into(),
17803 description: String::new(),
17804 date_created: None,
17805 date_updated: None,
17806 version_count: 0,
17807 templates: vec![],
17808 latest_version_label: label.map(|s| s.into()),
17809 latest_version_created: created,
17810 }
17811 };
17812 let t0 = chrono::Utc::now();
17813 let prev = vec![
17814 mk("alpha", Some("build-1"), Some(t0)),
17815 mk("beta", Some("build-9"), Some(t0)),
17816 ];
17817 let mut next = vec![
17819 mk("alpha", None, None),
17820 mk("beta", None, None),
17821 mk("gamma", None, None),
17822 ];
17823 super::merge_app_latest_versions(&prev, &mut next);
17824 assert_eq!(next[0].latest_version_label.as_deref(), Some("build-1"));
17825 assert_eq!(next[0].latest_version_created, Some(t0));
17826 assert_eq!(next[1].latest_version_label.as_deref(), Some("build-9"));
17827 assert_eq!(next[2].latest_version_label, None);
17829 assert_eq!(next[2].latest_version_created, None);
17830 }
17831
17832 #[test]
17833 fn merge_app_latest_versions_does_not_overwrite_already_populated_slots() {
17834 use crate::aws::Application;
17838 let mk = |name: &str, label: Option<&str>| -> Application {
17839 Application {
17840 name: name.into(),
17841 description: String::new(),
17842 date_created: None,
17843 date_updated: None,
17844 version_count: 0,
17845 templates: vec![],
17846 latest_version_label: label.map(|s| s.into()),
17847 latest_version_created: None,
17848 }
17849 };
17850 let prev = vec![mk("alpha", Some("OLD"))];
17851 let mut next = vec![mk("alpha", Some("NEW"))];
17852 super::merge_app_latest_versions(&prev, &mut next);
17853 assert_eq!(next[0].latest_version_label.as_deref(), Some("NEW"));
17854 }
17855
17856 #[test]
17857 fn merge_app_latest_versions_handles_app_disappearance() {
17858 use crate::aws::Application;
17861 let mk = |name: &str, label: Option<&str>| -> Application {
17862 Application {
17863 name: name.into(),
17864 description: String::new(),
17865 date_created: None,
17866 date_updated: None,
17867 version_count: 0,
17868 templates: vec![],
17869 latest_version_label: label.map(|s| s.into()),
17870 latest_version_created: None,
17871 }
17872 };
17873 let prev = vec![mk("alpha", Some("build-old")), mk("beta", Some("build-2"))];
17874 let mut next = vec![mk("beta", None)];
17875 super::merge_app_latest_versions(&prev, &mut next);
17876 assert_eq!(next.len(), 1);
17877 assert_eq!(next[0].latest_version_label.as_deref(), Some("build-2"));
17878 }
17879
17880 #[test]
17881 fn format_org_accounts_includes_switch_hint_when_configured() {
17882 use crate::aws::OrgAccount;
17883 let accounts = vec![
17884 OrgAccount {
17885 id: "111122223333".into(),
17886 name: "prod".into(),
17887 email: Some("prod@example.com".into()),
17888 status: "ACTIVE".into(),
17889 },
17890 OrgAccount {
17891 id: "444455556666".into(),
17892 name: "sandbox".into(),
17893 email: None,
17894 status: "SUSPENDED".into(),
17895 },
17896 ];
17897 let mut configured = std::collections::HashMap::new();
17898 configured.insert("prod".to_string(), "prod".to_string());
17899 let body = super::format_org_accounts(&accounts, &configured);
17900 assert!(body.contains("● prod"));
17901 assert!(body.contains("⊘ sandbox"));
17902 assert!(body.contains("prod@example.com"));
17903 assert!(body.contains(":account prod"));
17905 assert!(!body.contains(":account sandbox"));
17906 }
17907
17908 #[test]
17909 fn format_org_accounts_empty_returns_hint() {
17910 let body = super::format_org_accounts(&[], &std::collections::HashMap::new());
17911 assert!(body.contains("no accounts returned"));
17912 }
17913
17914 #[test]
17915 fn format_org_accounts_matches_id_when_named_by_id() {
17916 use crate::aws::OrgAccount;
17917 let accounts = vec![OrgAccount {
17918 id: "111122223333".into(),
17919 name: "prod".into(),
17920 email: None,
17921 status: "ACTIVE".into(),
17922 }];
17923 let mut configured = std::collections::HashMap::new();
17926 configured.insert("111122223333".to_string(), "111122223333".to_string());
17927 let body = super::format_org_accounts(&accounts, &configured);
17928 assert!(body.contains(":account 111122223333"));
17929 }
17930
17931 #[test]
17932 fn format_deploy_preview_happy_path() {
17933 use crate::aws::AppVersion;
17934 let now = chrono::Utc::now();
17935 let versions = vec![
17936 AppVersion {
17937 label: "build-142".into(),
17938 description: "fix: idempotent retries".into(),
17939 created: Some(now - chrono::Duration::hours(2)),
17940 },
17941 AppVersion {
17942 label: "build-141".into(),
17943 description: "feat: /metrics endpoint".into(),
17944 created: Some(now - chrono::Duration::days(1)),
17945 },
17946 ];
17947 let body = super::format_deploy_preview("uflexi-prod", "build-141", "build-142", &versions);
17948 assert!(body.contains("env: uflexi-prod"));
17949 assert!(body.contains("current: build-141"));
17950 assert!(body.contains("candidate: build-142"));
17951 assert!(body.contains("fix: idempotent retries"));
17952 assert!(!body.contains("rollback"));
17954 }
17955
17956 #[test]
17957 fn format_deploy_preview_rollback_warning_fires_when_older() {
17958 use crate::aws::AppVersion;
17959 let now = chrono::Utc::now();
17960 let versions = vec![
17961 AppVersion {
17962 label: "build-old".into(),
17963 description: String::new(),
17964 created: Some(now - chrono::Duration::days(7)),
17965 },
17966 AppVersion {
17967 label: "build-new".into(),
17968 description: String::new(),
17969 created: Some(now - chrono::Duration::hours(1)),
17970 },
17971 ];
17972 let body = super::format_deploy_preview("uflexi-prod", "build-new", "build-old", &versions);
17974 assert!(
17975 body.contains("rollback"),
17976 "expected rollback warning, got: {body}"
17977 );
17978 }
17979
17980 #[test]
17981 fn format_deploy_preview_unknown_label_calls_out_the_gap() {
17982 use crate::aws::AppVersion;
17983 let versions = vec![AppVersion {
17984 label: "build-141".into(),
17985 description: String::new(),
17986 created: Some(chrono::Utc::now()),
17987 }];
17988 let body = super::format_deploy_preview(
17989 "uflexi-prod",
17990 "build-141",
17991 "build-DOES-NOT-EXIST",
17992 &versions,
17993 );
17994 assert!(body.contains("not found"));
17995 assert!(body.contains("build-DOES-NOT-EXIST"));
17996 }
17997
17998 fn make_event(msg: &str) -> crate::aws::Event {
17999 crate::aws::Event {
18000 at: Some(chrono::Utc::now()),
18001 env: "uflexi-prod".into(),
18002 application: "uflexi".into(),
18003 message: msg.into(),
18004 severity: "INFO".into(),
18005 version_label: None,
18006 }
18007 }
18008
18009 #[test]
18010 fn previous_version_label_finds_prior_deploy() {
18011 let ev = |vl: Option<&str>| crate::aws::Event {
18012 at: None,
18013 env: "e".into(),
18014 application: "a".into(),
18015 message: String::new(),
18016 severity: "INFO".into(),
18017 version_label: vl.map(String::from),
18018 };
18019 let events = vec![
18022 ev(Some("build-3")),
18023 ev(None),
18024 ev(Some("build-3")),
18025 ev(Some("build-2")),
18026 ev(Some("build-1")),
18027 ];
18028 assert_eq!(
18029 super::previous_version_label(&events, "build-3"),
18030 Some("build-2".into())
18031 );
18032 let only_current = vec![ev(Some("build-3")), ev(None), ev(Some("build-3"))];
18034 assert_eq!(
18035 super::previous_version_label(&only_current, "build-3"),
18036 None
18037 );
18038 assert_eq!(
18040 super::previous_version_label(&[ev(None), ev(None)], "build-3"),
18041 None
18042 );
18043 assert_eq!(super::previous_version_label(&[], "build-3"), None);
18045 assert_eq!(
18047 super::previous_version_label(&[ev(Some("")), ev(Some("build-1"))], "build-3"),
18048 Some("build-1".into())
18049 );
18050 }
18051
18052 #[test]
18053 fn is_config_event_keeps_deploys_and_config_changes() {
18054 assert!(super::is_config_event(
18055 "Updating environment uflexi-prod to use version label 'build-9'."
18056 ));
18057 assert!(super::is_config_event(
18058 "Deploying new version to instance(s)."
18059 ));
18060 assert!(super::is_config_event(
18061 "Updating environment uflexi-prod's configuration settings."
18062 ));
18063 assert!(!super::is_config_event(
18065 "Environment health transitioned from Ok to Severe."
18066 ));
18067 assert!(!super::is_config_event(
18068 "Added instance 'i-abc' to environment."
18069 ));
18070 }
18071
18072 #[test]
18073 fn render_changes_overlay_states() {
18074 let ev = |msg: &str, vl: Option<&str>| crate::aws::Event {
18075 at: None,
18076 env: "e".into(),
18077 application: "a".into(),
18078 message: msg.into(),
18079 severity: "INFO".into(),
18080 version_label: vl.map(String::from),
18081 };
18082 let noise = vec![ev("Environment health transitioned to Ok.", None)];
18084 assert!(super::render_changes_overlay("prod", &noise).contains("No deploy"));
18085 let evs = vec![
18087 ev("Deploying new version to instance(s).", Some("build-9")),
18088 ev("Environment health transitioned to Ok.", None),
18089 ];
18090 let body = super::render_changes_overlay("prod", &evs);
18091 assert!(body.contains("Deploying new version"));
18092 assert!(body.contains("[build-9]"));
18093 assert!(!body.contains("health transitioned"));
18094 }
18095
18096 #[test]
18097 fn build_lineage_collapses_consecutive_same_label_events() {
18098 use chrono::TimeZone;
18103 let ts = |y, mo, d, h, mi| chrono::Utc.with_ymd_and_hms(y, mo, d, h, mi, 0).unwrap();
18104 let mk = |t, vl: &str| crate::aws::Event {
18105 at: Some(t),
18106 env: "e".into(),
18107 application: "a".into(),
18108 message: "deploy event".into(),
18109 severity: "INFO".into(),
18110 version_label: Some(vl.into()),
18111 };
18112 let evs = vec![
18114 mk(ts(2026, 5, 24, 12, 7), "build-9"),
18115 mk(ts(2026, 5, 24, 12, 5), "build-9"),
18116 mk(ts(2026, 5, 24, 12, 0), "build-9"),
18117 mk(ts(2026, 5, 24, 11, 3), "build-8"),
18118 mk(ts(2026, 5, 24, 11, 0), "build-8"),
18119 ];
18120 let rows = super::build_lineage(&evs);
18121 assert_eq!(rows.len(), 2, "expected 2 distinct deploys, got {rows:?}");
18122 assert_eq!(rows[0].label, "build-9");
18124 assert_eq!(rows[1].label, "build-8");
18125 assert_eq!(rows[0].first_at, Some(ts(2026, 5, 24, 12, 0)));
18127 assert_eq!(rows[0].last_at, Some(ts(2026, 5, 24, 12, 7)));
18128 assert_eq!(rows[1].first_at, Some(ts(2026, 5, 24, 11, 0)));
18129 assert_eq!(rows[1].last_at, Some(ts(2026, 5, 24, 11, 3)));
18130 }
18131
18132 #[test]
18133 fn build_lineage_drops_events_without_version_label() {
18134 let ev = |vl: Option<&str>| crate::aws::Event {
18137 at: None,
18138 env: "e".into(),
18139 application: "a".into(),
18140 message: "noise".into(),
18141 severity: "INFO".into(),
18142 version_label: vl.map(String::from),
18143 };
18144 let evs = vec![ev(None), ev(Some("")), ev(None)];
18145 assert!(super::build_lineage(&evs).is_empty());
18146 }
18147
18148 #[test]
18149 fn format_lineage_shows_gap_and_span_between_deploys() {
18150 use chrono::TimeZone;
18154 let ts = |h, mi| chrono::Utc.with_ymd_and_hms(2026, 5, 24, h, mi, 0).unwrap();
18155 let mk = |t, vl: &str| crate::aws::Event {
18156 at: Some(t),
18157 env: "e".into(),
18158 application: "a".into(),
18159 message: "deploy event".into(),
18160 severity: "INFO".into(),
18161 version_label: Some(vl.into()),
18162 };
18163 assert!(super::format_lineage("prod", &[]).contains("No deploys"));
18165 let evs = vec![
18168 mk(ts(12, 5), "build-9"),
18169 mk(ts(12, 0), "build-9"),
18170 mk(ts(10, 0), "build-8"),
18171 ];
18172 let body = super::format_lineage("prod", &evs);
18173 let p9 = body.find("build-9").expect("build-9 row");
18175 let p8 = body.find("build-8").expect("build-8 row");
18176 assert!(p9 < p8, "build-9 should come before build-8 (newest first)");
18177 assert!(
18179 body.contains("took"),
18180 "expected `took` span line, got:\n{body}"
18181 );
18182 assert!(
18184 body.contains("Δ"),
18185 "expected `Δ since previous` line, got:\n{body}"
18186 );
18187 }
18188
18189 #[test]
18190 fn classify_update_kind_deploy_extracts_label() {
18191 let evs = vec![make_event(
18192 "Updating environment uflexi-prod to use version label 'build-142'.",
18193 )];
18194 match super::classify_update_kind(&evs) {
18195 super::UpdateKind::Deploy { version_label } => {
18196 assert_eq!(version_label.as_deref(), Some("build-142"));
18197 }
18198 other => panic!("expected Deploy, got {other:?}"),
18199 }
18200 }
18201
18202 #[test]
18203 fn classify_update_kind_deploy_without_label_still_classifies() {
18204 let evs = vec![make_event("Deploying new version to instance i-abc123.")];
18205 match super::classify_update_kind(&evs) {
18206 super::UpdateKind::Deploy { version_label } => {
18207 assert!(version_label.is_none());
18210 }
18211 other => panic!("expected Deploy, got {other:?}"),
18212 }
18213 }
18214
18215 #[test]
18216 fn classify_update_kind_platform_update() {
18217 let evs = vec![make_event(
18218 "Updating environment to use platform 'arn:aws:elasticbeanstalk:…:platform/Corretto 17'.",
18219 )];
18220 assert_eq!(
18224 super::classify_update_kind(&evs),
18225 super::UpdateKind::Platform
18226 );
18227 }
18228
18229 #[test]
18230 fn classify_update_kind_config_change() {
18231 let evs = vec![make_event("Updating environment configuration completed.")];
18232 assert_eq!(super::classify_update_kind(&evs), super::UpdateKind::Config);
18233 }
18234
18235 #[test]
18236 fn classify_update_kind_scale_event() {
18237 let evs = vec![make_event("Adding instance 'i-abc123' to environment.")];
18238 assert_eq!(super::classify_update_kind(&evs), super::UpdateKind::Scale);
18239 }
18240
18241 #[test]
18242 fn classify_update_kind_unknown_message_falls_through_to_generic() {
18243 let evs = vec![make_event("Something cryptic happened.")];
18244 assert_eq!(
18245 super::classify_update_kind(&evs),
18246 super::UpdateKind::Generic
18247 );
18248 }
18249
18250 #[test]
18251 fn classify_update_kind_picks_most_recent_match() {
18252 let evs = vec![
18255 make_event("Updating environment to use version label 'build-99'."),
18256 make_event("Adding instance 'i-old' to environment."),
18257 ];
18258 match super::classify_update_kind(&evs) {
18259 super::UpdateKind::Deploy { version_label } => {
18260 assert_eq!(version_label.as_deref(), Some("build-99"));
18261 }
18262 other => panic!("expected Deploy from newest match, got {other:?}"),
18263 }
18264 }
18265
18266 #[test]
18267 fn classify_update_kind_empty_events_is_generic() {
18268 assert_eq!(super::classify_update_kind(&[]), super::UpdateKind::Generic);
18269 }
18270
18271 #[test]
18272 fn compute_red_alerts_counts_eb_red_and_worker_dlq() {
18273 use crate::aws::Environment;
18274 let mk = |name: &str, tier: &str, health: &str| Environment {
18275 name: name.into(),
18276 application: "uflexi".into(),
18277 status: "Ready".into(),
18278 health: health.into(),
18279 platform: "Java 17".into(),
18280 solution_stack: String::new(),
18281 tier: tier.into(),
18282 cname: String::new(),
18283 version_label: String::new(),
18284 arn: None,
18285 updated: None,
18286 id: None,
18287 region: None,
18288 };
18289 let envs = vec![
18290 mk("web-prod", "Web", "Green"),
18291 mk("web-red", "Web", "Red"),
18292 mk("worker-green-dlq", "Worker", "Green"),
18293 mk("worker-clean", "Worker", "Green"),
18294 mk("worker-red", "Worker", "Severe"),
18295 ];
18296 let mut dlq = std::collections::HashMap::new();
18297 dlq.insert("worker-green-dlq".to_string(), 3);
18298 dlq.insert("worker-clean".to_string(), 0);
18299 assert_eq!(super::compute_red_alerts(&envs, &dlq), 3);
18301 }
18302
18303 #[test]
18304 fn compute_red_alerts_ignores_dlq_for_web_tier() {
18305 use crate::aws::Environment;
18306 let env = Environment {
18307 name: "web-prod".into(),
18308 application: "uflexi".into(),
18309 status: "Ready".into(),
18310 health: "Green".into(),
18311 platform: "Java 17".into(),
18312 solution_stack: String::new(),
18313 tier: "Web".into(),
18314 cname: String::new(),
18315 version_label: String::new(),
18316 arn: None,
18317 updated: None,
18318 id: None,
18319 region: None,
18320 };
18321 let mut dlq = std::collections::HashMap::new();
18325 dlq.insert("web-prod".to_string(), 99);
18326 assert_eq!(super::compute_red_alerts(&[env], &dlq), 0);
18327 }
18328
18329 #[test]
18330 fn compute_red_alerts_zero_dlq_is_not_alert_worthy() {
18331 use crate::aws::Environment;
18332 let env = Environment {
18333 name: "worker-clean".into(),
18334 application: "uflexi".into(),
18335 status: "Ready".into(),
18336 health: "Green".into(),
18337 platform: "Java 17".into(),
18338 solution_stack: String::new(),
18339 tier: "Worker".into(),
18340 cname: String::new(),
18341 version_label: String::new(),
18342 arn: None,
18343 updated: None,
18344 id: None,
18345 region: None,
18346 };
18347 let mut dlq = std::collections::HashMap::new();
18348 dlq.insert("worker-clean".to_string(), 0);
18349 assert_eq!(super::compute_red_alerts(&[env], &dlq), 0);
18350 }
18351
18352 #[test]
18353 fn redact_for_log_preserves_length_with_block_chars() {
18354 assert_eq!(super::redact_for_log("540847557034", true), "▓".repeat(12));
18355 assert_eq!(super::redact_for_log("540847557034", false), "540847557034");
18356 assert_eq!(super::redact_for_log("—", true), "—");
18359 assert_eq!(super::redact_for_log("", true), "");
18360 }
18361
18362 #[test]
18363 fn parse_tag_args_happy_path() {
18364 let v: Vec<&str> = vec!["Owner", "platform-team"];
18365 let (k, v) = super::parse_tag_args(&v).unwrap();
18366 assert_eq!(k, "Owner");
18367 assert_eq!(v, "platform-team");
18368 }
18369
18370 #[test]
18371 fn parse_tag_args_joins_value_tokens_with_spaces() {
18372 let v: Vec<&str> = vec!["Description", "owned", "by", "platform"];
18373 let (k, v) = super::parse_tag_args(&v).unwrap();
18374 assert_eq!(k, "Description");
18375 assert_eq!(v, "owned by platform");
18376 }
18377
18378 #[test]
18379 fn parse_tag_args_rejects_missing_value() {
18380 let v: Vec<&str> = vec!["Owner"];
18382 assert!(super::parse_tag_args(&v).is_none());
18383 let v: Vec<&str> = vec![];
18385 assert!(super::parse_tag_args(&v).is_none());
18386 }
18387
18388 #[test]
18389 fn delta_toast_key_returns_none_for_non_delta_text() {
18390 assert_eq!(super::delta_toast_key("refreshing…"), None);
18391 assert_eq!(super::delta_toast_key(""), None);
18392 assert_eq!(super::delta_toast_key("▲"), None);
18393 assert_eq!(super::delta_toast_key("▲ Red"), None);
18395 assert_eq!(super::delta_toast_key("▲5 "), None);
18397 }
18398
18399 #[test]
18400 fn assign_app_colors_stable_first_appearance() {
18401 use ratatui::style::Color;
18402 let palette = vec![Color::Red, Color::Green, Color::Blue];
18403 let names = ["app-a", "app-b", "app-a", "app-c", "app-b"];
18404 let m = assign_app_colors(names.iter().copied(), &palette);
18405 assert_eq!(m.get("app-a").copied(), Some(Color::Red));
18406 assert_eq!(m.get("app-b").copied(), Some(Color::Green));
18407 assert_eq!(m.get("app-c").copied(), Some(Color::Blue));
18408 assert_eq!(m.len(), 3);
18409 }
18410
18411 #[test]
18412 fn assign_app_colors_wraps_when_palette_exhausted() {
18413 use ratatui::style::Color;
18414 let palette = vec![Color::Red, Color::Green];
18415 let names = ["a", "b", "c", "d"];
18416 let m = assign_app_colors(names.iter().copied(), &palette);
18417 assert_eq!(m.get("a").copied(), Some(Color::Red));
18418 assert_eq!(m.get("b").copied(), Some(Color::Green));
18419 assert_eq!(m.get("c").copied(), Some(Color::Red));
18421 assert_eq!(m.get("d").copied(), Some(Color::Green));
18422 }
18423
18424 #[test]
18425 fn assign_app_colors_empty_palette_yields_empty_map() {
18426 let m = assign_app_colors(["a", "b"].iter().copied(), &[]);
18427 assert!(m.is_empty());
18428 }
18429
18430 #[test]
18431 fn rotate_if_oversize_renames_when_too_big() {
18432 let dir = std::env::temp_dir().join(format!("ebman-rotate-{}", std::process::id()));
18433 let _ = std::fs::create_dir_all(&dir);
18434 let path = dir.join("audit.log");
18435 let backup = dir.join("audit.log.1");
18436 let _ = std::fs::remove_file(&path);
18437 let _ = std::fs::remove_file(&backup);
18438 std::fs::write(&path, vec![b'x'; 100]).unwrap();
18440 rotate_if_oversize(&path, 50);
18441 assert!(!path.exists(), "current file should have been renamed");
18442 assert!(backup.exists(), "rotated backup should now exist");
18443 let _ = std::fs::remove_file(&backup);
18444 let _ = std::fs::remove_dir(&dir);
18445 }
18446
18447 #[test]
18448 fn rotate_if_oversize_leaves_small_files_alone() {
18449 let dir = std::env::temp_dir().join(format!("ebman-rotate-small-{}", std::process::id()));
18450 let _ = std::fs::create_dir_all(&dir);
18451 let path = dir.join("audit.log");
18452 let _ = std::fs::remove_file(&path);
18453 std::fs::write(&path, b"tiny").unwrap();
18454 rotate_if_oversize(&path, 1_000);
18455 assert!(path.exists());
18456 assert!(!dir.join("audit.log.1").exists());
18457 let _ = std::fs::remove_file(&path);
18458 let _ = std::fs::remove_dir(&dir);
18459 }
18460
18461 #[test]
18462 fn event_time_format_cycles_utc_local_age() {
18463 let f = EventTimeFormat::default();
18464 assert_eq!(f, EventTimeFormat::Utc);
18465 assert_eq!(f.next(), EventTimeFormat::Local);
18466 assert_eq!(f.next().next(), EventTimeFormat::Age);
18467 assert_eq!(f.next().next().next(), EventTimeFormat::Utc);
18468 }
18469
18470 #[test]
18471 fn event_time_format_parse_round_trips() {
18472 for f in [
18473 EventTimeFormat::Utc,
18474 EventTimeFormat::Local,
18475 EventTimeFormat::Age,
18476 ] {
18477 assert_eq!(EventTimeFormat::parse(f.label()), Some(f));
18478 }
18479 assert_eq!(EventTimeFormat::parse("UTC"), Some(EventTimeFormat::Utc));
18481 assert_eq!(
18482 EventTimeFormat::parse("relative"),
18483 Some(EventTimeFormat::Age)
18484 );
18485 assert_eq!(EventTimeFormat::parse("nonsense"), None);
18486 }
18487
18488 #[test]
18489 fn shell_quote_passes_safe_chars_unchanged() {
18490 assert_eq!(shell_quote("safe-Name_1.0"), "safe-Name_1.0");
18491 assert_eq!(shell_quote("with space"), "'with space'");
18492 assert_eq!(shell_quote("o'clock"), "'o'\\''clock'");
18494 }
18495
18496 #[test]
18497 fn instance_hourly_usd_known_types() {
18498 assert!(instance_hourly_usd("t3.micro").unwrap() > 0.0);
18499 assert!(instance_hourly_usd("m5.large").unwrap() > 0.0);
18500 assert_eq!(instance_hourly_usd("not-a-real-type"), None);
18501 }
18502
18503 #[test]
18504 fn estimate_cost_handles_mixed() {
18505 let mk = |t: &str, az: &str| Instance {
18506 id: "i-1".into(),
18507 health: "Ok".into(),
18508 color: "Green".into(),
18509 causes: vec![],
18510 instance_type: t.into(),
18511 availability_zone: az.into(),
18512 launched_at: None,
18513 };
18514 let instances = vec![
18515 mk("t3.micro", "us-east-1a"),
18516 mk("t3.micro", "us-east-1b"),
18517 mk("unknown-type-xyz", "us-east-1c"),
18518 ];
18519 let (hourly, missing) = estimate_cost(&instances);
18520 assert_eq!(missing, 1);
18521 assert!((hourly - 0.0208).abs() < 1e-9);
18523 }
18524
18525 fn fake_env(name: &str, status: &str, health: &str, version: &str) -> Environment {
18526 Environment {
18527 name: name.into(),
18528 application: "my-app".into(),
18529 status: status.into(),
18530 health: health.into(),
18531 platform: "Java 17".into(),
18532 solution_stack: String::new(),
18533 tier: "Web".into(),
18534 cname: format!("{name}.elb.amazonaws.com"),
18535 version_label: version.into(),
18536 arn: None,
18537 updated: None,
18538 id: None,
18539 region: None,
18540 }
18541 }
18542
18543 #[test]
18544 fn palette_score_prefers_label_prefix_then_substring_then_detail() {
18545 assert_eq!(palette_score("", "anything", "anything"), Some(0));
18547 assert_eq!(palette_score("reg", "region", "switch AWS region"), Some(0));
18549 let s_label = palette_score("ion", "region", "switch AWS region").unwrap();
18551 assert!(s_label > 0 && s_label < 1_000);
18552 let s_detail = palette_score("aws", ":region", "switch AWS profile").unwrap();
18554 let s_label_match = palette_score("aws", "aws-thing", "irrelevant").unwrap();
18555 assert!(s_detail >= 1_000);
18556 assert!(s_label_match < s_detail);
18557 assert_eq!(palette_score("xyzzy", "region", "switch AWS region"), None);
18559 }
18560
18561 #[test]
18562 fn bucket_delta_only_envs_in_both() {
18563 let mut prev = HashMap::new();
18564 prev.insert("a".into(), "Green".into());
18565 prev.insert("b".into(), "Red".into());
18566 prev.insert("c".into(), "Green".into()); let next = vec![
18568 fake_env("a", "Ready", "Yellow", "v1"), fake_env("b", "Ready", "Red", "v1"), fake_env("d", "Ready", "Green", "v1"), ];
18572 let delta = bucket_delta(&prev, &next, |e| e.health.clone());
18573 let map: BTreeMap<String, i32> = delta.into_iter().collect();
18574 assert_eq!(map.get("Green").copied(), Some(-1));
18576 assert_eq!(map.get("Yellow").copied(), Some(1));
18577 assert_eq!(map.get("Red").copied(), None);
18578 }
18579
18580 #[test]
18581 fn bucket_delta_empty_prev_yields_no_deltas() {
18582 let prev = HashMap::new();
18586 let next = vec![
18587 fake_env("a", "Ready", "Green", "v1"),
18588 fake_env("b", "Ready", "Red", "v1"),
18589 ];
18590 let delta = bucket_delta(&prev, &next, |e| e.health.clone());
18591 assert!(
18592 delta.is_empty(),
18593 "expected no deltas with empty prev, got {delta:?}"
18594 );
18595 }
18596
18597 #[test]
18598 fn diff_envs_marks_differing_fields() {
18599 let a = fake_env("prod", "Ready", "Green", "v1");
18600 let b = fake_env("staging", "Updating", "Yellow", "v2");
18601 let out = diff_envs(&a, &b, false);
18602 assert!(out.contains("≠ Status"));
18604 assert!(out.contains("≠ Health"));
18605 assert!(out.contains("≠ Version"));
18606 assert!(out.contains("≠ Name"));
18607 assert!(out.contains("≠ CNAME"));
18608 assert!(out.contains(" Application"));
18610 assert!(out.contains(" Tier"));
18611 assert!(out.contains(" Platform"));
18612 }
18613
18614 #[test]
18615 fn diff_envs_redacts_cname() {
18616 let a = fake_env("prod", "Ready", "Green", "v1");
18617 let b = fake_env("staging", "Updating", "Yellow", "v2");
18618 let out = diff_envs(&a, &b, true);
18619 assert!(!out.contains("prod.elb.amazonaws.com"));
18621 assert!(out.contains("▓"));
18622 }
18623
18624 #[test]
18625 fn encode_filter_only_view_emits_just_the_filter_part() {
18626 let encoded = super::encode_filter_only_view("tag:env=prod");
18630 assert_eq!(encoded, "filter=tag:env=prod");
18631 assert_eq!(super::encode_filter_only_view(""), "filter=");
18634 }
18635
18636 #[test]
18637 fn view_filter_value_extracts_filter_or_empty() {
18638 assert_eq!(
18639 super::view_filter_value("filter=tag:env=prod"),
18640 "tag:env=prod"
18641 );
18642 assert_eq!(
18644 super::view_filter_value("sort=name:asc;filter=tag:env=prod;grouped=false"),
18645 "tag:env=prod",
18646 );
18647 assert_eq!(super::view_filter_value("sort=name:asc;grouped=true"), "");
18650 assert_eq!(super::view_filter_value(""), "");
18652 assert_eq!(super::view_filter_value("sort=name:asc; filter=foo"), "foo",);
18655 }
18656
18657 #[tokio::test]
18658 async fn cycle_saved_view_wraps_forward_through_saved_views() {
18659 let mut app = test_app();
18663 app.saved_views
18664 .insert("dev".into(), super::encode_filter_only_view("tag:env=dev"));
18665 app.saved_views.insert(
18666 "prod".into(),
18667 super::encode_filter_only_view("tag:env=prod"),
18668 );
18669 app.saved_views.insert(
18670 "staging".into(),
18671 super::encode_filter_only_view("tag:env=staging"),
18672 );
18673 app.filter = "tag:env=dev".into();
18675 app.cycle_saved_view(1);
18676 assert_eq!(app.filter, "tag:env=prod");
18677 app.cycle_saved_view(1);
18678 assert_eq!(app.filter, "tag:env=staging");
18679 app.cycle_saved_view(1);
18681 assert_eq!(app.filter, "tag:env=dev");
18682 }
18683
18684 #[tokio::test]
18685 async fn cycle_saved_view_wraps_backward_and_handles_no_active() {
18686 let mut app = test_app();
18688 app.saved_views
18689 .insert("dev".into(), super::encode_filter_only_view("tag:env=dev"));
18690 app.saved_views.insert(
18691 "staging".into(),
18692 super::encode_filter_only_view("tag:env=staging"),
18693 );
18694 app.filter = "tag:env=dev".into();
18695 app.cycle_saved_view(-1);
18696 assert_eq!(app.filter, "tag:env=staging");
18697 app.filter = "some-random-text".into();
18700 app.cycle_saved_view(1);
18701 assert_eq!(app.filter, "tag:env=dev", "forward-with-no-active → first");
18702 app.filter = "some-random-text".into();
18703 app.cycle_saved_view(-1);
18704 assert_eq!(
18705 app.filter, "tag:env=staging",
18706 "backward-with-no-active → last"
18707 );
18708 }
18709
18710 #[tokio::test]
18711 async fn cycle_saved_view_noop_with_empty_views() {
18712 let mut app = test_app();
18716 app.filter = "keep-me".into();
18717 app.cycle_saved_view(1);
18718 assert_eq!(app.filter, "keep-me");
18719 }
18720
18721 #[tokio::test]
18722 async fn cycle_saved_view_with_full_view_applies_sort_and_group_too() {
18723 let mut app = test_app();
18728 app.saved_views
18730 .insert("dev".into(), super::encode_filter_only_view("tag:env=dev"));
18731 app.saved_views.insert(
18733 "by-app".into(),
18734 "filter=tag:env=prod;sort=app:asc;grouped=true;scope=envs".into(),
18735 );
18736 app.filter = "tag:env=dev".into();
18737 app.grouped = false;
18738 app.cycle_saved_view(1); assert_eq!(app.filter, "tag:env=prod");
18740 assert!(
18741 app.grouped,
18742 "full view must apply its grouped=true alongside the filter"
18743 );
18744 }
18745
18746 #[tokio::test]
18747 async fn ssh_with_instance_id_arg_queues_pending_shell_target() {
18748 let mut app = test_app();
18751 app.execute_command("ssh i-0abc1234567890def");
18752 assert_eq!(
18753 app.pending_shell_target.as_deref(),
18754 Some("i-0abc1234567890def")
18755 );
18756 assert!(
18757 app.error_message.is_none(),
18758 "unexpected: {:?}",
18759 app.error_message
18760 );
18761 assert!(
18762 app.mode == Mode::Normal,
18763 "ssh-with-arg should not change mode"
18764 );
18765 }
18766
18767 #[tokio::test]
18768 async fn ssh_rejects_non_instance_id_arg() {
18769 let mut app = test_app();
18773 app.execute_command("ssh staging-web");
18774 assert!(app.pending_shell_target.is_none());
18775 let err = app.error_message.as_deref().unwrap_or("");
18776 assert!(
18777 err.contains("instance ID") && err.contains("staging-web"),
18778 "expected guidance + offending value, got: {err}"
18779 );
18780 }
18781
18782 #[tokio::test]
18783 async fn ssh_no_arg_without_detail_errors_clearly() {
18784 let mut app = test_app();
18789 app.execute_command("ssh");
18790 assert!(app.picker.is_none());
18791 let err = app.error_message.as_deref().unwrap_or("");
18792 assert!(
18793 err.contains("Detail") || err.contains("instance ID"),
18794 "expected guidance about Detail/Instances or instance ID, got: {err}"
18795 );
18796 }
18797
18798 #[test]
18799 fn deploy_snapshot_round_trips_through_persisted_form() {
18800 use chrono::TimeZone;
18804 let original = DeploySnapshot {
18805 env_name: "prod-api".into(),
18806 previous_version_label: "build-825".into(),
18807 taken_at: chrono::Utc
18808 .with_ymd_and_hms(2026, 5, 25, 14, 30, 0)
18809 .unwrap(),
18810 };
18811 let raw = original.to_persisted();
18812 assert_eq!(raw, "build-825|2026-05-25T14:30:00+00:00");
18813 let parsed = DeploySnapshot::parse_persisted("prod-api", &raw).expect("parses");
18814 assert_eq!(parsed.env_name, original.env_name);
18815 assert_eq!(
18816 parsed.previous_version_label,
18817 original.previous_version_label
18818 );
18819 assert_eq!(parsed.taken_at, original.taken_at);
18820 }
18821
18822 #[test]
18823 fn deploy_snapshot_parse_persisted_rejects_garbage() {
18824 assert!(DeploySnapshot::parse_persisted("e", "nopipe").is_none());
18828 assert!(DeploySnapshot::parse_persisted("e", "|2026-05-25T14:30:00Z").is_none());
18829 assert!(DeploySnapshot::parse_persisted("e", "label|not-a-timestamp").is_none());
18830 assert!(DeploySnapshot::parse_persisted("e", "label|").is_none());
18831 }
18832
18833 #[tokio::test]
18834 async fn rebuild_clears_armed_watchdogs_and_snapshots() {
18835 let mut app = test_app();
18844 let now = chrono::Utc::now();
18845 app.armed_watchdogs.insert(
18846 "prod".into(),
18847 ArmedWatchdog {
18848 env_name: "prod".into(),
18849 target_label: "build-old".into(),
18850 armed_at: now,
18851 deadline_at: now + chrono::Duration::seconds(300),
18852 },
18853 );
18854 app.deploy_snapshots.insert(
18855 "prod".into(),
18856 DeploySnapshot {
18857 env_name: "prod".into(),
18858 previous_version_label: "build-old".into(),
18859 taken_at: now,
18860 },
18861 );
18862 app.apply_rebuild(Ok(Box::new(crate::aws::AwsClient::stub())));
18867 assert!(
18868 app.armed_watchdogs.is_empty(),
18869 "context switch should drop armed watchdogs"
18870 );
18871 assert!(
18872 app.deploy_snapshots.is_empty(),
18873 "context switch should drop deploy snapshots"
18874 );
18875 }
18876
18877 #[tokio::test]
18878 async fn rollback_to_label_opens_confirm_for_named_label() {
18879 let mut app = test_app();
18884 app.environments = vec![mk_env("prod", "shop", "Web", "Red")];
18885 app.rebuild_view();
18886 app.table_state.select(Some(0));
18887 app.deploy_snapshots.insert(
18889 "prod".into(),
18890 DeploySnapshot {
18891 env_name: "prod".into(),
18892 previous_version_label: "build-snap".into(),
18893 taken_at: chrono::Utc::now(),
18894 },
18895 );
18896 app.execute_command("rollback --to build-820");
18897 match &app.action_flow {
18899 Some(ActionFlow::Confirm(modal)) => {
18900 assert_eq!(modal.deploy_version.as_deref(), Some("build-820"));
18901 assert!(modal.auto_rollback_secs.is_none());
18903 }
18904 _ => panic!("expected confirm modal open"),
18905 }
18906 }
18907
18908 #[tokio::test]
18909 async fn rollback_to_label_with_auto_rollback_threads_secs_through() {
18910 let mut app = test_app();
18915 app.environments = vec![mk_env("prod", "shop", "Web", "Red")];
18916 app.rebuild_view();
18917 app.table_state.select(Some(0));
18918 app.execute_command("rollback --to build-820 --auto-rollback 5m");
18919 match &app.action_flow {
18920 Some(ActionFlow::Confirm(modal)) => {
18921 assert_eq!(modal.deploy_version.as_deref(), Some("build-820"));
18922 assert_eq!(modal.auto_rollback_secs, Some(300));
18923 }
18924 _ => panic!("expected confirm modal open"),
18925 }
18926 }
18927
18928 #[tokio::test]
18929 async fn rollback_to_same_label_as_deployed_refuses() {
18930 let mut app = test_app();
18934 let mut env = mk_env("prod", "shop", "Web", "Red");
18935 env.version_label = "build-822".into();
18936 app.environments = vec![env];
18937 app.rebuild_view();
18938 app.table_state.select(Some(0));
18939 app.execute_command("rollback --to build-822");
18940 let err = app.error_message.as_deref().unwrap_or("");
18941 assert!(
18942 err.contains("already the deployed version"),
18943 "expected idempotent guard, got: {err}"
18944 );
18945 assert!(app.action_flow.is_none(), "no confirm modal on no-op");
18946 }
18947
18948 #[tokio::test]
18949 async fn rollback_auto_rollback_without_snapshot_errors_clearly() {
18950 let mut app = test_app();
18956 app.environments = vec![mk_env("prod", "shop", "Web", "Red")];
18957 app.rebuild_view();
18958 app.table_state.select(Some(0));
18959 app.execute_command("rollback --auto-rollback 5m");
18961 let err = app.error_message.as_deref().unwrap_or("");
18962 assert!(
18963 err.contains("needs an in-memory snapshot") && err.contains("--to LABEL"),
18964 "expected refusal + hint, got: {err}"
18965 );
18966 }
18967
18968 #[tokio::test]
18969 async fn deploy_wait_for_green_threads_secs_through_to_modal() {
18970 let mut app = test_app();
18975 app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
18976 app.rebuild_view();
18977 app.table_state.select(Some(0));
18978 app.execute_command("deploy build-900 --wait-for-green 5m");
18979 match &app.action_flow {
18980 Some(ActionFlow::Confirm(modal)) => {
18981 assert_eq!(modal.deploy_version.as_deref(), Some("build-900"));
18982 assert_eq!(modal.wait_for_green_secs, Some(300));
18983 assert!(modal.auto_rollback_secs.is_none());
18984 }
18985 _ => panic!("expected confirm modal open"),
18986 }
18987 }
18988
18989 #[tokio::test]
18990 async fn deploy_wait_for_green_rejects_malformed_duration() {
18991 let mut app = test_app();
18995 app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
18996 app.rebuild_view();
18997 app.table_state.select(Some(0));
18998 app.execute_command("deploy build-900 --wait-for-green forever");
18999 let err = app.error_message.as_deref().unwrap_or("");
19000 assert!(
19001 err.contains("--wait-for-green") && err.contains("duration"),
19002 "expected parse refusal, got: {err}"
19003 );
19004 assert!(
19005 app.action_flow.is_none(),
19006 "no modal should open on malformed duration"
19007 );
19008 }
19009
19010 #[tokio::test]
19011 async fn deploy_with_both_flags_threads_both_through() {
19012 let mut app = test_app();
19016 app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
19017 app.rebuild_view();
19018 app.table_state.select(Some(0));
19019 app.execute_command("deploy build-900 --auto-rollback 10m --wait-for-green 5m");
19020 match &app.action_flow {
19021 Some(ActionFlow::Confirm(modal)) => {
19022 assert_eq!(modal.deploy_version.as_deref(), Some("build-900"));
19023 assert_eq!(modal.auto_rollback_secs, Some(600));
19024 assert_eq!(modal.wait_for_green_secs, Some(300));
19025 }
19026 _ => panic!("expected confirm modal open"),
19027 }
19028 }
19029
19030 #[tokio::test]
19031 async fn apply_refresh_keeps_watching_when_status_is_updating_even_if_health_is_green() {
19032 let mut app = test_app();
19038 let now = chrono::Utc::now();
19039 app.watching_deploys.insert(
19040 "prod".into(),
19041 WatchingDeploy {
19042 env_name: "prod".into(),
19043 target_label: "build-900".into(),
19044 armed_at: now,
19045 deadline_at: now + chrono::Duration::seconds(300),
19046 },
19047 );
19048 let mut env = mk_env("prod", "shop", "Web", "Green");
19049 env.status = "Updating".into();
19050 app.apply_refresh(Ok(vec![env]));
19051 assert!(
19052 app.watching_deploys.contains_key("prod"),
19053 "Updating+Green is mid-deploy — watcher must remain armed"
19054 );
19055 let pinned = app.status_message.as_deref().unwrap_or("");
19056 assert!(
19057 !pinned.contains("reached Green"),
19058 "must not pin success during Updating, got: {pinned:?}"
19059 );
19060 }
19061
19062 #[tokio::test]
19063 async fn apply_refresh_keeps_armed_watchdog_when_status_is_updating_even_if_health_is_green() {
19064 let mut app = test_app();
19069 let now = chrono::Utc::now();
19070 app.deploy_snapshots.insert(
19071 "prod".into(),
19072 DeploySnapshot {
19073 env_name: "prod".into(),
19074 previous_version_label: "build-820".into(),
19075 taken_at: now,
19076 },
19077 );
19078 app.armed_watchdogs.insert(
19079 "prod".into(),
19080 ArmedWatchdog {
19081 env_name: "prod".into(),
19082 target_label: "build-820".into(),
19083 armed_at: now,
19084 deadline_at: now + chrono::Duration::seconds(300),
19085 },
19086 );
19087 let mut env = mk_env("prod", "shop", "Web", "Green");
19088 env.status = "Updating".into();
19089 app.apply_refresh(Ok(vec![env]));
19090 assert!(
19091 app.armed_watchdogs.contains_key("prod"),
19092 "Updating+Green is mid-deploy — watchdog must remain armed"
19093 );
19094 }
19095
19096 #[test]
19097 fn deploy_settled_green_requires_both_status_ready_and_health_green_or_ok() {
19098 assert!(super::deploy_settled_green("Ready", "Green"));
19099 assert!(super::deploy_settled_green("Ready", "Ok"));
19100 assert!(super::deploy_settled_green("ready", "green")); assert!(super::deploy_settled_green("READY", "OK"));
19102 assert!(!super::deploy_settled_green("Updating", "Green"));
19104 assert!(!super::deploy_settled_green("Launching", "Ok"));
19105 assert!(!super::deploy_settled_green("Terminating", "Green"));
19106 assert!(!super::deploy_settled_green("Ready", "Red"));
19108 assert!(!super::deploy_settled_green("Ready", "Yellow"));
19109 assert!(!super::deploy_settled_green("Ready", "Severe"));
19110 assert!(!super::deploy_settled_green("", ""));
19112 }
19113
19114 #[test]
19115 fn build_health_check_probe_url_normalises_path() {
19116 assert_eq!(
19118 super::build_health_check_probe_url("api.example.com", "/healthz"),
19119 "http://api.example.com/healthz"
19120 );
19121 assert_eq!(
19124 super::build_health_check_probe_url("api.example.com", "healthz"),
19125 "http://api.example.com/healthz"
19126 );
19127 assert_eq!(
19129 super::build_health_check_probe_url("api.example.com", ""),
19130 "http://api.example.com/"
19131 );
19132 assert_eq!(
19134 super::build_health_check_probe_url("api.example.com", "/"),
19135 "http://api.example.com/"
19136 );
19137 }
19138
19139 #[test]
19140 fn classify_health_check_status_treats_2xx_as_ok_and_others_as_warning() {
19141 assert!(super::classify_health_check_status(200).is_ok());
19143 assert!(super::classify_health_check_status(201).is_ok());
19144 assert!(super::classify_health_check_status(299).is_ok());
19145 let err = super::classify_health_check_status(0).unwrap_err();
19147 assert!(err.contains("no response"));
19148 let err = super::classify_health_check_status(301).unwrap_err();
19151 assert!(err.contains("301"));
19152 let err = super::classify_health_check_status(404).unwrap_err();
19154 assert!(err.contains("404"));
19155 let err = super::classify_health_check_status(503).unwrap_err();
19157 assert!(err.contains("503"));
19158 }
19159
19160 #[test]
19161 fn compute_unavailability_count_per_policy() {
19162 assert_eq!(
19164 super::compute_unavailability_count("AllAtOnce", 1, "Fixed", 4),
19165 4
19166 );
19167 assert_eq!(
19169 super::compute_unavailability_count("Rolling", 1, "Fixed", 4),
19170 1
19171 );
19172 assert_eq!(
19174 super::compute_unavailability_count("Rolling", 2, "Fixed", 4),
19175 2
19176 );
19177 assert_eq!(
19179 super::compute_unavailability_count("Rolling", 50, "Percentage", 4),
19180 2
19181 );
19182 assert_eq!(
19184 super::compute_unavailability_count("Rolling", 33, "Percentage", 4),
19185 2
19186 );
19187 assert_eq!(
19189 super::compute_unavailability_count("RollingWithAdditionalBatch", 1, "Fixed", 4),
19190 0
19191 );
19192 assert_eq!(
19194 super::compute_unavailability_count("Immutable", 1, "Fixed", 4),
19195 0
19196 );
19197 assert_eq!(
19198 super::compute_unavailability_count("TrafficSplitting", 1, "Fixed", 4),
19199 0
19200 );
19201 assert_eq!(
19204 super::compute_unavailability_count("WeirdCustomPolicy", 1, "Fixed", 4),
19205 4
19206 );
19207 assert_eq!(
19209 super::compute_unavailability_count("allatonce", 1, "Fixed", 4),
19210 4
19211 );
19212 }
19213
19214 #[test]
19215 fn compute_batch_count_clamps_and_rounds_up() {
19216 assert_eq!(super::compute_batch_count(0, "Fixed", 4), 1);
19218 assert_eq!(super::compute_batch_count(10, "Fixed", 4), 4);
19219 assert_eq!(super::compute_batch_count(2, "Fixed", 4), 2);
19220 assert_eq!(super::compute_batch_count(33, "Percentage", 4), 2); assert_eq!(super::compute_batch_count(25, "Percentage", 4), 1);
19223 assert_eq!(super::compute_batch_count(26, "Percentage", 4), 2); assert_eq!(super::compute_batch_count(100, "Percentage", 4), 4);
19225 assert_eq!(super::compute_batch_count(0, "Percentage", 4), 1);
19227 assert_eq!(super::compute_batch_count(200, "Percentage", 4), 4);
19228 }
19229
19230 #[test]
19231 fn format_unavailability_line_distinguishes_zero_from_partial_from_full() {
19232 let (text, caution) = super::format_unavailability_line("Immutable", 0, 4);
19233 assert!(text.contains("no in-service unavailability"));
19234 assert!(!caution);
19235 let (text, caution) = super::format_unavailability_line("Rolling", 1, 4);
19236 assert!(text.contains("max 1/4 instance unavailable"));
19237 assert!(caution);
19238 let (text, caution) = super::format_unavailability_line("AllAtOnce", 4, 4);
19239 assert!(text.contains("max 4/4 instances unavailable"));
19240 assert!(caution);
19241 }
19242
19243 #[test]
19244 fn extract_unavailability_inputs_uses_eb_defaults_on_missing_settings() {
19245 let (policy, batch, btype, asg) = super::extract_unavailability_inputs(&[]);
19248 assert_eq!(policy, "AllAtOnce");
19249 assert_eq!(batch, 1);
19250 assert_eq!(btype, "Fixed");
19251 assert_eq!(asg, 1);
19252
19253 let opts = vec![("aws:autoscaling:asg".into(), "MaxSize".into(), "6".into())];
19255 let (_, _, _, asg) = super::extract_unavailability_inputs(&opts);
19256 assert_eq!(asg, 6);
19257
19258 let opts = vec![(
19261 "aws:elasticbeanstalk:command".into(),
19262 "DeploymentPolicy".into(),
19263 String::new(),
19264 )];
19265 let (policy, _, _, _) = super::extract_unavailability_inputs(&opts);
19266 assert_eq!(policy, "AllAtOnce");
19267 }
19268
19269 #[tokio::test]
19270 async fn handle_unavailability_estimate_stuffs_line_into_modal() {
19271 let mut app = test_app();
19272 app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
19273 app.rebuild_view();
19274 app.table_state.select(Some(0));
19275 app.execute_command("deploy build-900");
19276 app.handle_msg(AppMsg::UnavailabilityEstimate {
19277 gen: app.generation,
19278 env_name: "prod".into(),
19279 line: Some((
19280 "deploy plan: Rolling → max 1/4 instance unavailable".into(),
19281 true,
19282 )),
19283 });
19284 match &app.action_flow {
19285 Some(ActionFlow::Confirm(modal)) => {
19286 assert!(!modal.loading_unavailability);
19287 let (text, caution) = modal.unavailability_line.as_ref().unwrap();
19288 assert!(text.contains("max 1/4"));
19289 assert!(*caution);
19290 }
19291 _ => panic!("expected confirm modal"),
19292 }
19293 }
19294
19295 #[test]
19296 fn build_undo_entry_set_with_prior_value_reverses_to_set() {
19297 let pre = vec![(
19300 "aws:autoscaling:launchconfiguration".into(),
19301 "EC2KeyName".into(),
19302 "bar".into(),
19303 )];
19304 let to_set = vec![(
19305 "aws:autoscaling:launchconfiguration".into(),
19306 "EC2KeyName".into(),
19307 "foo".into(),
19308 )];
19309 let entry = super::build_undo_entry("prod", "keypair foo", &to_set, &[], &pre);
19310 assert_eq!(entry.to_set.len(), 1);
19311 assert_eq!(entry.to_set[0].2, "bar");
19312 assert!(entry.to_remove.is_empty());
19313 assert_eq!(entry.env_name, "prod");
19314 assert_eq!(entry.original_summary, "keypair foo");
19315 }
19316
19317 #[test]
19318 fn build_undo_entry_set_with_no_prior_value_reverses_to_remove() {
19319 let pre: Vec<(String, String, String)> = vec![];
19323 let to_set = vec![(
19324 "aws:elasticbeanstalk:application".into(),
19325 "Application Healthcheck URL".into(),
19326 "/healthz".into(),
19327 )];
19328 let entry =
19329 super::build_undo_entry("prod", "health-check-url /healthz", &to_set, &[], &pre);
19330 assert!(entry.to_set.is_empty());
19331 assert_eq!(entry.to_remove.len(), 1);
19332 assert_eq!(entry.to_remove[0].1, "Application Healthcheck URL");
19333 }
19334
19335 #[test]
19336 fn build_undo_entry_empty_string_prior_treated_as_unset() {
19337 let pre = vec![(
19340 "aws:autoscaling:launchconfiguration".into(),
19341 "EC2KeyName".into(),
19342 String::new(),
19343 )];
19344 let to_set = vec![(
19345 "aws:autoscaling:launchconfiguration".into(),
19346 "EC2KeyName".into(),
19347 "foo".into(),
19348 )];
19349 let entry = super::build_undo_entry("prod", "keypair foo", &to_set, &[], &pre);
19350 assert!(entry.to_set.is_empty());
19351 assert_eq!(entry.to_remove.len(), 1);
19352 }
19353
19354 #[test]
19355 fn build_undo_entry_remove_with_prior_value_reverses_to_set() {
19356 let pre = vec![(
19359 "aws:autoscaling:launchconfiguration".into(),
19360 "EC2KeyName".into(),
19361 "bar".into(),
19362 )];
19363 let to_remove = vec![(
19364 "aws:autoscaling:launchconfiguration".into(),
19365 "EC2KeyName".into(),
19366 )];
19367 let entry = super::build_undo_entry("prod", "clear keypair", &[], &to_remove, &pre);
19368 assert_eq!(entry.to_set.len(), 1);
19369 assert_eq!(entry.to_set[0].2, "bar");
19370 assert!(entry.to_remove.is_empty());
19371 }
19372
19373 #[test]
19374 fn build_undo_entry_remove_with_no_prior_value_is_a_noop_reverse() {
19375 let entry = super::build_undo_entry(
19378 "prod",
19379 "clear keypair",
19380 &[],
19381 &[(
19382 "aws:autoscaling:launchconfiguration".into(),
19383 "EC2KeyName".into(),
19384 )],
19385 &[],
19386 );
19387 assert!(entry.to_set.is_empty());
19388 assert!(entry.to_remove.is_empty());
19389 }
19390
19391 #[tokio::test]
19392 async fn batch_set_option_skips_envs_no_longer_in_view() {
19393 let mut app = test_app();
19401 app.environments = vec![mk_env("prod-api", "shop", "Web", "Green")];
19402 app.rebuild_view();
19403 app.spawn_batch_set_option(
19405 "vanished".into(),
19406 "aws:elasticbeanstalk:application".into(),
19407 "Application Healthcheck URL".into(),
19408 "/healthz".into(),
19409 );
19410 assert!(
19412 app.pending_actions.iter().all(|p| p.target != "vanished"),
19413 "expected no pending action for vanished env"
19414 );
19415 }
19416
19417 #[tokio::test]
19418 async fn handle_undo_captured_pushes_into_history_with_cap() {
19419 let mut app = test_app();
19423 for i in 0..(super::UNDO_HISTORY_CAP + 2) {
19424 let entry = super::UndoEntry {
19425 env_name: "prod".into(),
19426 to_set: vec![("ns".into(), format!("k{i}"), "v".into())],
19427 to_remove: vec![],
19428 original_summary: format!("write #{i}"),
19429 captured_at: chrono::Utc::now(),
19430 };
19431 app.handle_msg(AppMsg::UndoCaptured {
19432 gen: app.generation,
19433 entry,
19434 });
19435 }
19436 assert_eq!(app.undo_history.len(), super::UNDO_HISTORY_CAP);
19437 assert_eq!(
19440 app.undo_history.front().unwrap().original_summary,
19441 "write #2"
19442 );
19443 assert_eq!(
19445 app.undo_history.back().unwrap().original_summary,
19446 format!("write #{}", super::UNDO_HISTORY_CAP + 1)
19447 );
19448 }
19449
19450 #[tokio::test]
19451 async fn cmd_undo_with_empty_history_hints_at_the_buffer() {
19452 let mut app = test_app();
19453 app.execute_command("undo");
19454 let status = app.status_message.as_deref().unwrap_or("");
19455 assert!(
19456 status.contains("no undo history"),
19457 "expected empty-history hint, got: {status}"
19458 );
19459 }
19460
19461 #[tokio::test]
19462 async fn cmd_undo_with_no_op_reverse_surfaces_clearly() {
19463 let mut app = test_app();
19467 app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
19468 app.rebuild_view();
19469 app.undo_history.push_back(super::UndoEntry {
19470 env_name: "prod".into(),
19471 to_set: vec![],
19472 to_remove: vec![],
19473 original_summary: "keypair foo".into(),
19474 captured_at: chrono::Utc::now(),
19475 });
19476 app.execute_command("undo");
19477 let status = app.status_message.as_deref().unwrap_or("");
19478 assert!(
19479 status.contains("prior state was identical"),
19480 "expected no-op hint, got: {status}"
19481 );
19482 }
19483
19484 #[tokio::test]
19485 async fn cmd_undo_uses_display_row_index_not_envs_vec_index() {
19486 let mut app = test_app();
19493 let mut prod_api = mk_env("prod-api", "shop", "Web", "Green");
19494 prod_api.application = "shop".into();
19495 let mut staging_api = mk_env("staging-api", "shop", "Web", "Green");
19496 staging_api.application = "shop".into();
19497 let mut prod_web = mk_env("prod-web", "shop", "Web", "Green");
19498 prod_web.application = "shop".into();
19499 app.environments = vec![prod_api, staging_api, prod_web];
19500 app.filter = "prod-".into();
19504 app.rebuild_view();
19505 app.undo_history.push_back(super::UndoEntry {
19507 env_name: "prod-web".into(),
19508 to_set: vec![(
19509 "aws:autoscaling:launchconfiguration".into(),
19510 "EC2KeyName".into(),
19511 "bar".into(),
19512 )],
19513 to_remove: vec![],
19514 original_summary: "keypair foo".into(),
19515 captured_at: chrono::Utc::now(),
19516 });
19517 app.table_state.select(Some(0));
19520 app.execute_command("undo");
19521 assert!(
19525 app.error_message.is_none(),
19526 "expected dispatch to succeed, got error: {:?}",
19527 app.error_message
19528 );
19529 assert_eq!(
19530 app.table_state.selected(),
19531 Some(0),
19532 "cursor must be restored to the prior selection"
19533 );
19534 }
19535
19536 #[tokio::test]
19537 async fn cmd_undo_refuses_with_hint_when_env_filtered_out() {
19538 let mut app = test_app();
19543 app.environments = vec![
19544 mk_env("prod-api", "shop", "Web", "Green"),
19545 mk_env("staging-api", "shop", "Web", "Green"),
19546 ];
19547 app.filter = "staging-".into();
19548 app.rebuild_view();
19549 app.undo_history.push_back(super::UndoEntry {
19550 env_name: "prod-api".into(),
19551 to_set: vec![("ns".into(), "k".into(), "v".into())],
19552 to_remove: vec![],
19553 original_summary: "keypair foo".into(),
19554 captured_at: chrono::Utc::now(),
19555 });
19556 app.execute_command("undo");
19557 let err = app.error_message.as_deref().unwrap_or("");
19558 assert!(
19559 err.contains("filtered out") && err.contains("clear the filter"),
19560 "expected filter hint, got: {err}"
19561 );
19562 assert_eq!(
19563 app.undo_history.len(),
19564 1,
19565 "entry must be put back on the deque so the operator can retry"
19566 );
19567 }
19568
19569 #[tokio::test]
19570 async fn cmd_undo_refuses_when_target_env_no_longer_visible() {
19571 let mut app = test_app();
19575 app.undo_history.push_back(super::UndoEntry {
19576 env_name: "vanished".into(),
19577 to_set: vec![("ns".into(), "k".into(), "v".into())],
19578 to_remove: vec![],
19579 original_summary: "keypair foo".into(),
19580 captured_at: chrono::Utc::now(),
19581 });
19582 app.execute_command("undo");
19583 let err = app.error_message.as_deref().unwrap_or("");
19584 assert!(
19585 err.contains("no longer in the current view"),
19586 "expected missing-env refusal, got: {err}"
19587 );
19588 }
19589
19590 #[test]
19591 fn expand_command_alias_pass_through_when_no_match() {
19592 use std::collections::HashMap;
19593 let mut aliases = HashMap::new();
19594 aliases.insert("dp".to_string(), "deploy --auto-rollback 5m".to_string());
19595 assert_eq!(super::expand_command_alias("rebuild", &aliases), "rebuild");
19597 assert_eq!(
19599 super::expand_command_alias("deploy build-x", &HashMap::new()),
19600 "deploy build-x"
19601 );
19602 }
19603
19604 #[test]
19605 fn expand_command_alias_swaps_first_token_and_keeps_args() {
19606 use std::collections::HashMap;
19607 let mut aliases = HashMap::new();
19608 aliases.insert("dp".to_string(), "deploy --auto-rollback 5m".to_string());
19609 assert_eq!(
19610 super::expand_command_alias("dp build-900", &aliases),
19611 "deploy --auto-rollback 5m build-900"
19612 );
19613 assert_eq!(
19615 super::expand_command_alias("dp", &aliases),
19616 "deploy --auto-rollback 5m"
19617 );
19618 }
19619
19620 #[test]
19621 fn expand_command_alias_does_not_chain_transitively() {
19622 use std::collections::HashMap;
19625 let mut aliases = HashMap::new();
19626 aliases.insert("a".to_string(), "b stuff".to_string());
19627 aliases.insert("b".to_string(), "c things".to_string());
19628 assert_eq!(super::expand_command_alias("a", &aliases), "b stuff");
19629 let mut aliases = HashMap::new();
19631 aliases.insert("loop".to_string(), "loop forever".to_string());
19632 assert_eq!(
19633 super::expand_command_alias("loop", &aliases),
19634 "loop forever"
19635 );
19636 }
19637
19638 #[tokio::test]
19639 async fn execute_command_uses_command_aliases() {
19640 let mut app = test_app();
19645 app.command_aliases
19646 .insert("emergency".into(), "freeze-deploys incident #1234".into());
19647 app.execute_command("emergency");
19648 assert!(app.deploy_freeze.is_some());
19649 let reason = app
19650 .deploy_freeze
19651 .as_ref()
19652 .map(|f| f.reason.clone())
19653 .unwrap();
19654 assert_eq!(reason, "incident #1234");
19655 }
19656
19657 #[tokio::test]
19658 async fn freeze_deploys_blocks_writes_with_reason_surfaced() {
19659 let mut app = test_app();
19664 app.execute_command("freeze-deploys incident #1234");
19665 assert!(app.deploy_freeze.is_some(), "freeze should be set");
19666 assert!(
19667 app.is_read_only_for("any-env"),
19668 "freeze must block every env"
19669 );
19670 let reason = app.read_only_reason("any-env").unwrap_or_default();
19671 assert!(
19672 reason.contains("deploys frozen") && reason.contains("incident #1234"),
19673 "expected reason to surface, got: {reason}"
19674 );
19675 }
19676
19677 #[tokio::test]
19678 async fn freeze_deploys_with_no_reason_still_blocks() {
19679 let mut app = test_app();
19682 app.execute_command("freeze-deploys");
19683 assert!(app.deploy_freeze.is_some());
19684 let reason = app.read_only_reason("env").unwrap_or_default();
19685 assert!(
19686 reason.contains("deploys frozen") && !reason.contains(": "),
19687 "no-reason wording shouldn't include `: <reason>`, got: {reason}"
19688 );
19689 }
19690
19691 #[tokio::test]
19692 async fn thaw_deploys_clears_the_freeze() {
19693 let mut app = test_app();
19694 app.execute_command("freeze-deploys testing");
19695 assert!(app.deploy_freeze.is_some());
19696 app.execute_command("thaw-deploys");
19697 assert!(app.deploy_freeze.is_none(), "thaw should clear freeze");
19698 assert!(
19699 !app.is_read_only_for("env"),
19700 "thaw must restore writes (no other locks set in this test)"
19701 );
19702 }
19703
19704 #[tokio::test]
19705 async fn re_freezing_updates_the_reason_in_place() {
19706 let mut app = test_app();
19708 app.execute_command("freeze-deploys rolling back");
19709 app.execute_command("freeze-deploys rolling back — PROD only");
19710 let reason = app
19711 .deploy_freeze
19712 .as_ref()
19713 .map(|f| f.reason.clone())
19714 .unwrap();
19715 assert_eq!(reason, "rolling back — PROD only");
19716 }
19717
19718 #[tokio::test]
19719 async fn freeze_overrides_per_env_pin_in_read_only_reason() {
19720 let mut app = test_app();
19724 app.safety_envs.insert("prod".into(), true);
19725 app.execute_command("freeze-deploys incident");
19726 let reason = app.read_only_reason("prod").unwrap_or_default();
19727 assert!(
19728 reason.contains("deploys frozen"),
19729 "freeze reason must win over per-env pin, got: {reason}"
19730 );
19731 }
19732
19733 #[tokio::test]
19734 async fn handle_unavailability_estimate_silent_on_fetch_failure() {
19735 let mut app = test_app();
19738 app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
19739 app.rebuild_view();
19740 app.table_state.select(Some(0));
19741 app.execute_command("deploy build-900");
19742 app.handle_msg(AppMsg::UnavailabilityEstimate {
19743 gen: app.generation,
19744 env_name: "prod".into(),
19745 line: None,
19746 });
19747 match &app.action_flow {
19748 Some(ActionFlow::Confirm(modal)) => {
19749 assert!(!modal.loading_unavailability);
19750 assert!(modal.unavailability_line.is_none());
19751 }
19752 _ => panic!("expected confirm modal"),
19753 }
19754 }
19755
19756 #[tokio::test]
19757 async fn handle_health_check_probe_renders_warning_on_failure() {
19758 let mut app = test_app();
19761 app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
19762 app.rebuild_view();
19763 app.table_state.select(Some(0));
19764 app.execute_command("deploy build-900");
19765 app.handle_msg(AppMsg::HealthCheckProbe {
19766 gen: app.generation,
19767 env_name: "prod".into(),
19768 result: Err("HTTP 404".into()),
19769 });
19770 match &app.action_flow {
19771 Some(ActionFlow::Confirm(modal)) => {
19772 assert!(!modal.loading_health_check);
19773 assert_eq!(
19774 modal.health_check_probe.as_ref().map(|r| r.is_err()),
19775 Some(true)
19776 );
19777 }
19778 _ => panic!("expected confirm modal open"),
19779 }
19780 }
19781
19782 #[tokio::test]
19783 async fn handle_health_check_probe_silent_on_ok() {
19784 let mut app = test_app();
19788 app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
19789 app.rebuild_view();
19790 app.table_state.select(Some(0));
19791 app.execute_command("deploy build-900");
19792 app.handle_msg(AppMsg::HealthCheckProbe {
19793 gen: app.generation,
19794 env_name: "prod".into(),
19795 result: Ok(()),
19796 });
19797 match &app.action_flow {
19798 Some(ActionFlow::Confirm(modal)) => {
19799 assert!(!modal.loading_health_check);
19800 assert_eq!(
19801 modal.health_check_probe.as_ref().map(|r| r.is_ok()),
19802 Some(true)
19803 );
19804 }
19805 _ => panic!("expected confirm modal open"),
19806 }
19807 }
19808
19809 #[tokio::test]
19810 async fn apply_refresh_drains_watching_deploy_on_green() {
19811 let mut app = test_app();
19815 let now = chrono::Utc::now();
19816 app.watching_deploys.insert(
19817 "prod".into(),
19818 WatchingDeploy {
19819 env_name: "prod".into(),
19820 target_label: "build-900".into(),
19821 armed_at: now,
19822 deadline_at: now + chrono::Duration::seconds(300),
19823 },
19824 );
19825 app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Green")]));
19826 assert!(
19827 app.watching_deploys.is_empty(),
19828 "Green should drain the watcher"
19829 );
19830 let pinned = app.status_message.as_deref().unwrap_or("");
19834 assert!(
19835 pinned.contains("reached Green") && pinned.contains("prod"),
19836 "expected pinned success status, got: {pinned:?}"
19837 );
19838 }
19839
19840 #[tokio::test]
19841 async fn apply_refresh_drains_watching_deploy_on_timeout() {
19842 let mut app = test_app();
19847 let now = chrono::Utc::now();
19848 app.watching_deploys.insert(
19849 "prod".into(),
19850 WatchingDeploy {
19851 env_name: "prod".into(),
19852 target_label: "build-900".into(),
19853 armed_at: now - chrono::Duration::seconds(600),
19854 deadline_at: now - chrono::Duration::seconds(60),
19855 },
19856 );
19857 app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Red")]));
19858 assert!(
19859 app.watching_deploys.is_empty(),
19860 "expired watcher should drain on timeout"
19861 );
19862 let pinned = app.error_message.as_deref().unwrap_or("");
19863 assert!(
19864 pinned.contains("did not reach Green") && pinned.contains("prod"),
19865 "expected pinned timeout error, got: {pinned:?}"
19866 );
19867 }
19868
19869 #[tokio::test]
19870 async fn rebuild_clears_watching_deploys() {
19871 let mut app = test_app();
19875 app.watching_deploys.insert(
19876 "prod".into(),
19877 WatchingDeploy {
19878 env_name: "prod".into(),
19879 target_label: "build-900".into(),
19880 armed_at: chrono::Utc::now(),
19881 deadline_at: chrono::Utc::now() + chrono::Duration::seconds(300),
19882 },
19883 );
19884 app.apply_rebuild(Ok(Box::new(crate::aws::AwsClient::stub())));
19885 assert!(
19886 app.watching_deploys.is_empty(),
19887 "watching_deploys must clear on context rebuild"
19888 );
19889 }
19890
19891 #[test]
19892 fn soonest_watching_deploy_picks_earliest_deadline() {
19893 let mut map: std::collections::HashMap<String, WatchingDeploy> =
19896 std::collections::HashMap::new();
19897 let now = chrono::Utc::now();
19898 map.insert(
19899 "later".into(),
19900 WatchingDeploy {
19901 env_name: "later".into(),
19902 target_label: "v2".into(),
19903 armed_at: now,
19904 deadline_at: now + chrono::Duration::seconds(600),
19905 },
19906 );
19907 map.insert(
19908 "sooner".into(),
19909 WatchingDeploy {
19910 env_name: "sooner".into(),
19911 target_label: "v1".into(),
19912 armed_at: now,
19913 deadline_at: now + chrono::Duration::seconds(120),
19914 },
19915 );
19916 let (env, _remaining) = soonest_watching_deploy(&map, now).expect("not empty");
19917 assert_eq!(env, "sooner");
19918 }
19919
19920 #[tokio::test]
19921 async fn promote_env_opens_deploy_confirm_on_target_with_sources_version() {
19922 let mut app = test_app();
19926 let mut staging = mk_env("staging", "shop", "Web", "Green");
19927 staging.version_label = "build-900".into();
19928 let mut prod = mk_env("prod", "shop", "Web", "Green");
19929 prod.version_label = "build-820".into();
19930 app.environments = vec![staging, prod];
19931 app.rebuild_view();
19932 app.table_state.select(Some(0));
19936 app.execute_command("promote-env staging prod");
19937 match &app.action_flow {
19938 Some(ActionFlow::Confirm(modal)) => {
19939 assert_eq!(modal.target_env, "prod");
19940 assert_eq!(modal.deploy_version.as_deref(), Some("build-900"));
19941 assert!(matches!(modal.action, Action::Deploy));
19942 }
19943 _ => panic!("expected confirm modal open on target"),
19944 }
19945 }
19946
19947 #[tokio::test]
19948 async fn promote_env_composes_with_watchdog_flags() {
19949 let mut app = test_app();
19952 let mut staging = mk_env("staging", "shop", "Web", "Green");
19953 staging.version_label = "build-900".into();
19954 let prod = mk_env("prod", "shop", "Web", "Green");
19955 app.environments = vec![staging, prod];
19956 app.rebuild_view();
19957 app.table_state.select(Some(0));
19958 app.execute_command("promote-env staging prod --auto-rollback 10m --wait-for-green 5m");
19959 match &app.action_flow {
19960 Some(ActionFlow::Confirm(modal)) => {
19961 assert_eq!(modal.target_env, "prod");
19962 assert_eq!(modal.deploy_version.as_deref(), Some("build-900"));
19963 assert_eq!(modal.auto_rollback_secs, Some(600));
19964 assert_eq!(modal.wait_for_green_secs, Some(300));
19965 }
19966 _ => panic!("expected confirm modal open"),
19967 }
19968 }
19969
19970 #[tokio::test]
19971 async fn promote_env_refuses_when_versions_match() {
19972 let mut app = test_app();
19975 let mut staging = mk_env("staging", "shop", "Web", "Green");
19976 staging.version_label = "build-900".into();
19977 let mut prod = mk_env("prod", "shop", "Web", "Green");
19978 prod.version_label = "build-900".into();
19979 app.environments = vec![staging, prod];
19980 app.rebuild_view();
19981 app.execute_command("promote-env staging prod");
19982 let err = app.error_message.as_deref().unwrap_or("");
19983 assert!(
19984 err.contains("already deployed to prod"),
19985 "expected idempotent guard, got: {err}"
19986 );
19987 assert!(app.action_flow.is_none(), "no modal on no-op");
19988 }
19989
19990 #[tokio::test]
19991 async fn promote_env_refuses_when_source_has_no_version() {
19992 let mut app = test_app();
19994 let mut staging = mk_env("staging", "shop", "Web", "Pending");
19995 staging.version_label = String::new();
19996 let prod = mk_env("prod", "shop", "Web", "Green");
19997 app.environments = vec![staging, prod];
19998 app.rebuild_view();
19999 app.execute_command("promote-env staging prod");
20000 let err = app.error_message.as_deref().unwrap_or("");
20001 assert!(
20002 err.contains("no version deployed"),
20003 "expected no-version refusal, got: {err}"
20004 );
20005 }
20006
20007 #[tokio::test]
20008 async fn promote_env_refuses_same_source_and_target() {
20009 let mut app = test_app();
20011 let mut staging = mk_env("staging", "shop", "Web", "Green");
20012 staging.version_label = "build-900".into();
20013 app.environments = vec![staging];
20014 app.rebuild_view();
20015 app.execute_command("promote-env staging staging");
20016 let err = app.error_message.as_deref().unwrap_or("");
20017 assert!(
20018 err.contains("must be different"),
20019 "expected same-env refusal, got: {err}"
20020 );
20021 }
20022
20023 #[tokio::test]
20024 async fn promote_env_refuses_unknown_env() {
20025 let mut app = test_app();
20026 let mut staging = mk_env("staging", "shop", "Web", "Green");
20027 staging.version_label = "build-900".into();
20028 app.environments = vec![staging];
20029 app.rebuild_view();
20030 app.execute_command("promote-env staging nope");
20031 let err = app.error_message.as_deref().unwrap_or("");
20032 assert!(
20033 err.contains("no env named 'nope'"),
20034 "expected unknown-env refusal, got: {err}"
20035 );
20036 }
20037
20038 #[tokio::test]
20039 async fn deploy_modal_opens_with_version_preview_loading_flag_set() {
20040 let mut app = test_app();
20045 app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
20046 app.rebuild_view();
20047 app.table_state.select(Some(0));
20048 app.execute_command("deploy build-900");
20049 match &app.action_flow {
20050 Some(ActionFlow::Confirm(modal)) => {
20051 assert!(
20052 modal.loading_version_preview,
20053 "Deploy modal must reserve space for the inline preview"
20054 );
20055 assert!(modal.version_preview.is_none());
20056 }
20057 _ => panic!("expected confirm modal open"),
20058 }
20059 }
20060
20061 #[tokio::test]
20062 async fn deploy_modal_handle_version_preview_stuffs_body_in_slot() {
20063 let mut app = test_app();
20066 app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
20067 app.rebuild_view();
20068 app.table_state.select(Some(0));
20069 app.execute_command("deploy build-900");
20070 let body = "candidate: build-900\ncurrent: build-820\n".to_string();
20071 app.handle_msg(AppMsg::VersionPreview {
20072 gen: app.generation,
20073 env_name: "prod".into(),
20074 result: Ok(body.clone()),
20075 });
20076 match &app.action_flow {
20077 Some(ActionFlow::Confirm(modal)) => {
20078 assert!(!modal.loading_version_preview);
20079 assert_eq!(modal.version_preview.as_deref(), Some(body.as_str()));
20080 }
20081 _ => panic!("expected confirm modal still open"),
20082 }
20083 }
20084
20085 #[tokio::test]
20086 async fn deploy_modal_handle_version_preview_error_renders_inline() {
20087 let mut app = test_app();
20090 app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
20091 app.rebuild_view();
20092 app.table_state.select(Some(0));
20093 app.execute_command("deploy build-900");
20094 app.handle_msg(AppMsg::VersionPreview {
20095 gen: app.generation,
20096 env_name: "prod".into(),
20097 result: Err("ListApplicationVersions throttled".into()),
20098 });
20099 match &app.action_flow {
20100 Some(ActionFlow::Confirm(modal)) => {
20101 assert!(!modal.loading_version_preview);
20102 let preview = modal.version_preview.as_deref().unwrap_or("");
20103 assert!(
20104 preview.contains("version preview unavailable")
20105 && preview.contains("throttled"),
20106 "expected inline error, got: {preview}"
20107 );
20108 }
20109 _ => panic!("expected confirm modal still open"),
20110 }
20111 }
20112
20113 #[tokio::test]
20114 async fn handle_confirm_modal_lint_stuffs_issues_into_modal() {
20115 let mut app = test_app();
20119 app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
20120 app.rebuild_view();
20121 app.table_state.select(Some(0));
20122 app.execute_command("deploy build-900");
20123 app.handle_msg(AppMsg::ConfirmModalLint {
20125 gen: app.generation,
20126 env_name: "prod".into(),
20127 issues: vec![],
20128 });
20129 match &app.action_flow {
20130 Some(ActionFlow::Confirm(modal)) => {
20131 assert!(!modal.loading_lint, "loading flag should clear");
20132 assert_eq!(modal.lint_issues.as_ref().map(|v| v.len()), Some(0));
20133 }
20134 _ => panic!("expected confirm modal open"),
20135 }
20136 }
20137
20138 #[tokio::test]
20139 async fn handle_confirm_modal_lint_drops_stale_target_results() {
20140 let mut app = test_app();
20145 app.environments = vec![
20146 mk_env("prod", "shop", "Web", "Green"),
20147 mk_env("staging", "shop", "Web", "Green"),
20148 ];
20149 app.rebuild_view();
20150 app.table_state.select(Some(1));
20152 app.execute_command("deploy build-900");
20153 app.handle_msg(AppMsg::ConfirmModalLint {
20155 gen: app.generation,
20156 env_name: "prod".into(),
20157 issues: vec![crate::lint::Issue {
20158 rule_id: "EBL001".into(),
20159 severity: crate::lint::Severity::Warn,
20160 env_name: Some("prod".into()),
20161 title: "stale".into(),
20162 detail: "stale".into(),
20163 suggestion: None,
20164 fields: Default::default(),
20165 }],
20166 });
20167 match &app.action_flow {
20168 Some(ActionFlow::Confirm(modal)) => {
20169 assert!(
20172 modal.loading_lint,
20173 "loading flag must stay true on stale result"
20174 );
20175 assert!(
20176 modal.lint_issues.is_none(),
20177 "stale result must not populate"
20178 );
20179 }
20180 _ => panic!("expected confirm modal open"),
20181 }
20182 }
20183
20184 #[tokio::test]
20185 async fn refresh_tf_managed_envs_derives_set_from_tf_state() {
20186 let mut app = test_app();
20190 assert!(app.tf_managed_envs.is_empty(), "starts empty");
20191 app.tf_state = Some(crate::terraform::TfState {
20192 envs: vec![
20193 crate::terraform::TfEnv {
20194 name: "prod-api".into(),
20195 application: "shop".into(),
20196 version_label: "build-820".into(),
20197 options: vec![],
20198 tags: Default::default(),
20199 },
20200 crate::terraform::TfEnv {
20201 name: "prod-web".into(),
20202 application: "shop".into(),
20203 version_label: "build-820".into(),
20204 options: vec![],
20205 tags: Default::default(),
20206 },
20207 ],
20208 });
20209 app.refresh_tf_managed_envs();
20210 assert_eq!(app.tf_managed_envs.len(), 2);
20211 assert!(app.tf_managed_envs.contains("prod-api"));
20212 assert!(app.tf_managed_envs.contains("prod-web"));
20213 assert!(!app.tf_managed_envs.contains("staging-api"));
20214 app.tf_state = None;
20216 app.refresh_tf_managed_envs();
20217 assert!(app.tf_managed_envs.is_empty());
20218 }
20219
20220 #[tokio::test]
20221 async fn cmd_drift_refresh_reloads_tf_state_and_pins_status() {
20222 let mut app = test_app();
20227 app.environments = vec![mk_env("prod-api", "shop", "Web", "Green")];
20228 app.rebuild_view();
20229 app.execute_command("drift refresh");
20230 let msg = app.status_message.as_deref().unwrap_or("");
20232 assert!(
20233 msg.contains("tfstate"),
20234 "expected tfstate status, got: {msg}"
20235 );
20236 }
20237
20238 #[tokio::test]
20239 async fn cmd_drift_with_no_tfstate_loaded_hints_at_discovery() {
20240 let mut app = test_app();
20244 app.environments = vec![mk_env("prod-api", "shop", "Web", "Green")];
20245 app.rebuild_view();
20246 app.table_state.select(Some(0));
20247 app.tf_state = None;
20248 app.execute_command("drift");
20249 let msg = app.status_message.as_deref().unwrap_or("");
20250 assert!(
20251 msg.contains("no terraform.tfstate found"),
20252 "expected discovery hint, got: {msg}"
20253 );
20254 }
20255
20256 #[test]
20257 fn render_lint_overlay_empty_shows_clean_stub() {
20258 let body = crate::app::cmd_misc::render_lint_overlay("prod-api", &[]);
20259 assert!(body.contains("prod-api"));
20260 assert!(body.contains("✓ No issues found"));
20261 assert!(body.contains("esc / q to close"));
20262 }
20263
20264 #[test]
20265 fn render_lint_overlay_with_issues_renders_per_severity_glyph() {
20266 use crate::lint::{Issue, Severity};
20267 use std::collections::BTreeMap;
20268 let issues = vec![
20269 Issue {
20270 rule_id: "EBL001".into(),
20271 severity: Severity::Warn,
20272 env_name: Some("prod".into()),
20273 title: "AllAtOnce on 4-instance env".into(),
20274 detail: "Deployment policy AllAtOnce with MaxSize=4 means full unavailability."
20275 .into(),
20276 suggestion: Some(":deployment-policy Rolling".into()),
20277 fields: BTreeMap::new(),
20278 },
20279 Issue {
20280 rule_id: "EBL005".into(),
20281 severity: Severity::Info,
20282 env_name: Some("prod".into()),
20283 title: "Single-instance env".into(),
20284 detail: "MinSize=MaxSize=1.".into(),
20285 suggestion: None,
20286 fields: BTreeMap::new(),
20287 },
20288 ];
20289 let body = crate::app::cmd_misc::render_lint_overlay("prod-api", &issues);
20290 assert!(body.contains("⚠ [EBL001]"));
20292 assert!(body.contains("· [EBL005]"));
20293 assert!(body.contains("→ :deployment-policy Rolling"));
20295 assert!(body.contains(" Deployment policy AllAtOnce"));
20297 assert!(body.contains("2 issues found"));
20299 }
20300
20301 #[test]
20302 fn build_audit_webhook_body_has_slack_compatible_text_plus_structured_fields() {
20303 let body = super::build_audit_webhook_body(
20307 Some("123456789012"),
20308 Some("prod"),
20309 "us-east-1",
20310 "stage=request action=Deploy target=prod-api",
20311 "2026-05-25T12:00:00Z",
20312 );
20313 assert!(body.starts_with('{') && body.ends_with('}'));
20314 assert!(
20315 body.contains("\"text\":\"[ebman]"),
20316 "missing slack-shaped text field"
20317 );
20318 assert!(body.contains("\"at\":\"2026-05-25T12:00:00Z\""));
20319 assert!(body.contains("\"account\":\"123456789012\""));
20320 assert!(body.contains("\"profile\":\"prod\""));
20321 assert!(body.contains("\"region\":\"us-east-1\""));
20322 assert!(body.contains("\"detail\":\"stage=request action=Deploy target=prod-api\""));
20323 }
20324
20325 #[test]
20326 fn build_audit_webhook_body_dashes_missing_account_and_profile_in_text() {
20327 let body = super::build_audit_webhook_body(
20331 None,
20332 None,
20333 "eu-west-1",
20334 "stage=event kind=red_transition env=prod-api",
20335 "2026-05-25T12:00:00Z",
20336 );
20337 assert!(
20338 body.contains("account=- profile=- region=eu-west-1"),
20339 "missing dash placeholders in text, got: {body}"
20340 );
20341 assert!(body.contains("\"account\":\"\""));
20344 assert!(body.contains("\"profile\":\"\""));
20345 }
20346
20347 #[test]
20348 fn build_audit_webhook_body_escapes_quotes_in_detail() {
20349 let body = super::build_audit_webhook_body(
20352 None,
20353 None,
20354 "us-east-1",
20355 "stage=event message=\"deploy started\"",
20356 "2026-05-25T12:00:00Z",
20357 );
20358 assert!(body.contains("\\\"deploy started\\\""));
20361 let _: serde_yml::Value = serde_yml::from_str(&body)
20363 .expect("webhook body must be parseable JSON / YAML-superset");
20364 }
20365
20366 #[tokio::test]
20367 async fn dispatch_auto_rollback_also_drains_watching_deploys() {
20368 let mut app = test_app();
20374 app.environments = vec![mk_env("prod", "shop", "Web", "Red")];
20375 app.rebuild_view();
20376 app.deploy_snapshots.insert(
20378 "prod".into(),
20379 DeploySnapshot {
20380 env_name: "prod".into(),
20381 previous_version_label: "build-820".into(),
20382 taken_at: chrono::Utc::now(),
20383 },
20384 );
20385 app.armed_watchdogs.insert(
20386 "prod".into(),
20387 ArmedWatchdog {
20388 env_name: "prod".into(),
20389 target_label: "build-820".into(),
20390 armed_at: chrono::Utc::now(),
20391 deadline_at: chrono::Utc::now() - chrono::Duration::seconds(1),
20392 },
20393 );
20394 app.watching_deploys.insert(
20395 "prod".into(),
20396 WatchingDeploy {
20397 env_name: "prod".into(),
20398 target_label: "build-900".into(),
20399 armed_at: chrono::Utc::now(),
20400 deadline_at: chrono::Utc::now() + chrono::Duration::seconds(300),
20401 },
20402 );
20403 app.dispatch_auto_rollback("prod".into(), "Red".into());
20404 assert!(
20405 !app.watching_deploys.contains_key("prod"),
20406 "rollback dispatch must drain the parallel wait-for-green watcher"
20407 );
20408 assert!(
20409 !app.armed_watchdogs.contains_key("prod"),
20410 "rollback dispatch must drain its own armed watchdog"
20411 );
20412 }
20413
20414 #[test]
20415 fn soonest_watching_deploy_empty_returns_none() {
20416 let map: std::collections::HashMap<String, WatchingDeploy> =
20417 std::collections::HashMap::new();
20418 assert!(soonest_watching_deploy(&map, chrono::Utc::now()).is_none());
20419 }
20420
20421 #[tokio::test]
20422 async fn abort_rollback_named_env_disarms_just_that_one() {
20423 let mut app = test_app();
20427 let now = chrono::Utc::now();
20428 for env in ["prod", "staging"] {
20429 app.armed_watchdogs.insert(
20430 env.into(),
20431 ArmedWatchdog {
20432 env_name: env.into(),
20433 target_label: "build-820".into(),
20434 armed_at: now,
20435 deadline_at: now + chrono::Duration::seconds(300),
20436 },
20437 );
20438 }
20439 app.execute_command("abort-rollback staging");
20440 assert!(
20441 !app.armed_watchdogs.contains_key("staging"),
20442 "named env should be drained"
20443 );
20444 assert!(
20445 app.armed_watchdogs.contains_key("prod"),
20446 "other env's watchdog must stay armed"
20447 );
20448 let status = app.status_message.as_deref().unwrap_or("");
20449 assert!(
20450 status.contains("aborted auto-rollback for staging"),
20451 "expected confirm in status, got: {status}"
20452 );
20453 }
20454
20455 #[tokio::test]
20456 async fn abort_rollback_named_env_not_armed_errors_clearly() {
20457 let mut app = test_app();
20461 app.execute_command("abort-rollback ghost");
20462 let err = app.error_message.as_deref().unwrap_or("");
20463 assert!(
20464 err.contains("no auto-rollback armed for 'ghost'") && err.contains("rollbacks-armed"),
20465 "expected not-armed + discovery hint, got: {err}"
20466 );
20467 }
20468
20469 #[tokio::test]
20470 async fn abort_rollback_no_args_drains_every_watchdog() {
20471 let mut app = test_app();
20474 let now = chrono::Utc::now();
20475 for env in ["a", "b", "c"] {
20476 app.armed_watchdogs.insert(
20477 env.into(),
20478 ArmedWatchdog {
20479 env_name: env.into(),
20480 target_label: "x".into(),
20481 armed_at: now,
20482 deadline_at: now + chrono::Duration::seconds(300),
20483 },
20484 );
20485 }
20486 app.execute_command("abort-rollback");
20487 assert!(
20488 app.armed_watchdogs.is_empty(),
20489 "drain-all clears everything"
20490 );
20491 let status = app.status_message.as_deref().unwrap_or("");
20492 assert!(status.contains("aborted 3 auto-rollbacks"), "got: {status}");
20493 for env in ["a", "b", "c"] {
20495 assert!(
20496 status.contains(env),
20497 "expected {env} in status, got: {status}"
20498 );
20499 }
20500 }
20501
20502 #[tokio::test]
20503 async fn abort_rollback_no_args_empty_is_a_noop_status() {
20504 let mut app = test_app();
20508 app.execute_command("abort-rollback");
20509 let status = app.status_message.as_deref().unwrap_or("");
20510 assert!(status.contains("no auto-rollbacks armed to abort"));
20511 assert!(app.error_message.is_none());
20512 }
20513
20514 #[test]
20515 fn format_armed_rollbacks_empty_returns_stub() {
20516 let armed = std::collections::HashMap::new();
20517 let body = super::format_armed_rollbacks(&armed, chrono::Utc::now());
20518 assert!(body.contains("no auto-rollbacks armed"));
20519 }
20520
20521 #[test]
20522 fn format_armed_rollbacks_sorts_by_deadline_ascending() {
20523 use chrono::TimeZone;
20524 let now = chrono::Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap();
20525 let mut armed = std::collections::HashMap::new();
20526 armed.insert(
20528 "prod-api".into(),
20529 ArmedWatchdog {
20530 env_name: "prod-api".into(),
20531 target_label: "build-820".into(),
20532 armed_at: now - chrono::Duration::seconds(60),
20533 deadline_at: now + chrono::Duration::seconds(300),
20534 },
20535 );
20536 armed.insert(
20537 "staging-api".into(),
20538 ArmedWatchdog {
20539 env_name: "staging-api".into(),
20540 target_label: "build-822".into(),
20541 armed_at: now - chrono::Duration::seconds(30),
20542 deadline_at: now + chrono::Duration::seconds(60),
20543 },
20544 );
20545 let body = super::format_armed_rollbacks(&armed, now);
20546 let p_staging = body.find("staging-api").expect("staging-api row");
20548 let p_prod = body.find("prod-api").expect("prod-api row");
20549 assert!(
20550 p_staging < p_prod,
20551 "soonest-firing row should sort first; got body:\n{body}"
20552 );
20553 assert!(body.contains("build-822"));
20556 assert!(body.contains("build-820"));
20557 }
20558
20559 #[test]
20560 fn format_armed_rollbacks_expired_deadline_reads_as_expired() {
20561 use chrono::TimeZone;
20562 let now = chrono::Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap();
20563 let mut armed = std::collections::HashMap::new();
20564 armed.insert(
20565 "prod-api".into(),
20566 ArmedWatchdog {
20567 env_name: "prod-api".into(),
20568 target_label: "build-820".into(),
20569 armed_at: now - chrono::Duration::seconds(600),
20570 deadline_at: now - chrono::Duration::seconds(5),
20571 },
20572 );
20573 let body = super::format_armed_rollbacks(&armed, now);
20574 assert!(
20575 body.contains("fired / expired"),
20576 "expected expired marker, got: {body}"
20577 );
20578 }
20579
20580 #[test]
20581 fn soonest_armed_rollback_picks_the_earliest_deadline() {
20582 use chrono::TimeZone;
20583 let now = chrono::Utc.with_ymd_and_hms(2026, 5, 25, 12, 0, 0).unwrap();
20584 let mut armed = std::collections::HashMap::new();
20585 armed.insert(
20586 "later".into(),
20587 ArmedWatchdog {
20588 env_name: "later".into(),
20589 target_label: "x".into(),
20590 armed_at: now,
20591 deadline_at: now + chrono::Duration::seconds(600),
20592 },
20593 );
20594 armed.insert(
20595 "sooner".into(),
20596 ArmedWatchdog {
20597 env_name: "sooner".into(),
20598 target_label: "x".into(),
20599 armed_at: now,
20600 deadline_at: now + chrono::Duration::seconds(60),
20601 },
20602 );
20603 let (env, remaining) = super::soonest_armed_rollback(&armed, now).expect("one armed");
20604 assert_eq!(env, "sooner");
20605 assert!(remaining.contains('m') || remaining.contains('s'));
20607 }
20608
20609 #[test]
20610 fn soonest_armed_rollback_returns_none_when_empty() {
20611 let armed = std::collections::HashMap::new();
20612 assert!(super::soonest_armed_rollback(&armed, chrono::Utc::now()).is_none());
20613 }
20614
20615 #[tokio::test]
20616 async fn refresh_early_disarms_armed_watchdog_when_env_goes_green() {
20617 let mut app = test_app();
20622 let now = chrono::Utc::now();
20623 app.armed_watchdogs.insert(
20624 "prod".into(),
20625 ArmedWatchdog {
20626 env_name: "prod".into(),
20627 target_label: "build-old".into(),
20628 armed_at: now,
20629 deadline_at: now + chrono::Duration::seconds(300),
20630 },
20631 );
20632 app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Green")]));
20634 assert!(
20635 app.armed_watchdogs.is_empty(),
20636 "Green refresh should clear the armed watchdog"
20637 );
20638 let status = app.status_message.as_deref().unwrap_or("");
20639 assert!(
20640 status.contains("watchdog disarmed"),
20641 "expected disarm status, got: {status}"
20642 );
20643 }
20644
20645 #[tokio::test]
20646 async fn refresh_leaves_watchdog_armed_when_env_still_non_green() {
20647 let mut app = test_app();
20650 let now = chrono::Utc::now();
20651 app.armed_watchdogs.insert(
20652 "prod".into(),
20653 ArmedWatchdog {
20654 env_name: "prod".into(),
20655 target_label: "build-old".into(),
20656 armed_at: now,
20657 deadline_at: now + chrono::Duration::seconds(300),
20658 },
20659 );
20660 app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Red")]));
20661 assert!(
20662 app.armed_watchdogs.contains_key("prod"),
20663 "Red refresh must leave watchdog armed"
20664 );
20665 }
20666
20667 #[tokio::test]
20668 async fn auto_rollback_check_is_noop_when_no_watchdog_armed() {
20669 let mut app = test_app();
20675 app.environments = vec![mk_env("prod", "shop", "Web", "Green")];
20676 app.deploy_snapshots.insert(
20680 "prod".into(),
20681 DeploySnapshot {
20682 env_name: "prod".into(),
20683 previous_version_label: "build-old".into(),
20684 taken_at: chrono::Utc::now(),
20685 },
20686 );
20687 let pending_before = app.pending_actions.len();
20688 let load_before = app.load_state;
20689 app.handle_msg(AppMsg::AutoRollbackCheck {
20690 gen: app.generation,
20691 env_name: "prod".into(),
20692 });
20693 assert_eq!(
20694 app.pending_actions.len(),
20695 pending_before,
20696 "noop check shouldn't push pending"
20697 );
20698 assert_eq!(
20702 app.load_state, load_before,
20703 "noop check shouldn't kick a refresh"
20704 );
20705 }
20706
20707 #[tokio::test]
20708 async fn apply_refresh_disarms_armed_watchdog_when_env_reaches_green() {
20709 let mut app = test_app();
20713 let now = chrono::Utc::now();
20714 app.armed_watchdogs.insert(
20715 "prod".into(),
20716 ArmedWatchdog {
20717 env_name: "prod".into(),
20718 target_label: "build-old".into(),
20719 armed_at: now,
20720 deadline_at: now + chrono::Duration::seconds(300),
20721 },
20722 );
20723 app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Green")]));
20724 assert!(
20725 app.armed_watchdogs.is_empty(),
20726 "Green refresh should disarm"
20727 );
20728 let status = app.status_message.as_deref().unwrap_or("");
20729 assert!(status.contains("watchdog disarmed"));
20730 }
20731
20732 #[tokio::test]
20733 async fn apply_refresh_dispatches_rollback_when_deadline_passed_and_env_non_green() {
20734 let mut app = test_app();
20738 let now = chrono::Utc::now();
20739 app.armed_watchdogs.insert(
20740 "prod".into(),
20741 ArmedWatchdog {
20742 env_name: "prod".into(),
20743 target_label: "build-old".into(),
20744 armed_at: now - chrono::Duration::seconds(600),
20745 deadline_at: now - chrono::Duration::seconds(1),
20746 },
20747 );
20748 app.deploy_snapshots.insert(
20749 "prod".into(),
20750 DeploySnapshot {
20751 env_name: "prod".into(),
20752 previous_version_label: "build-old".into(),
20753 taken_at: now - chrono::Duration::seconds(600),
20754 },
20755 );
20756 let pending_before = app.pending_actions.len();
20757 app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Red")]));
20758 assert!(
20759 app.armed_watchdogs.is_empty(),
20760 "dispatch should drain the watchdog"
20761 );
20762 assert_eq!(
20763 app.pending_actions.len(),
20764 pending_before + 1,
20765 "rollback dispatch should push a pending row"
20766 );
20767 assert!(app
20768 .pending_actions
20769 .iter()
20770 .any(|p| p.label.contains("Auto-rollback") && p.target == "prod"));
20771 let status = app.status_message.as_deref().unwrap_or("");
20772 assert!(status.contains("redeploying build-old"));
20773 assert!(status.contains("Red"));
20774 }
20775
20776 #[tokio::test]
20777 async fn apply_refresh_keeps_watchdog_armed_before_deadline_even_when_non_green() {
20778 let mut app = test_app();
20784 let now = chrono::Utc::now();
20785 app.armed_watchdogs.insert(
20786 "prod".into(),
20787 ArmedWatchdog {
20788 env_name: "prod".into(),
20789 target_label: "build-old".into(),
20790 armed_at: now,
20791 deadline_at: now + chrono::Duration::seconds(300),
20792 },
20793 );
20794 app.deploy_snapshots.insert(
20795 "prod".into(),
20796 DeploySnapshot {
20797 env_name: "prod".into(),
20798 previous_version_label: "build-old".into(),
20799 taken_at: now,
20800 },
20801 );
20802 let pending_before = app.pending_actions.len();
20803 app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Yellow")]));
20804 assert!(
20805 app.armed_watchdogs.contains_key("prod"),
20806 "Yellow + pre-deadline must keep watchdog armed"
20807 );
20808 assert_eq!(
20809 app.pending_actions.len(),
20810 pending_before,
20811 "no dispatch before the deadline"
20812 );
20813 }
20814
20815 #[tokio::test]
20816 async fn apply_refresh_errors_when_deadline_passed_but_no_snapshot() {
20817 let mut app = test_app();
20821 let now = chrono::Utc::now();
20822 app.armed_watchdogs.insert(
20823 "prod".into(),
20824 ArmedWatchdog {
20825 env_name: "prod".into(),
20826 target_label: "build-old".into(),
20827 armed_at: now - chrono::Duration::seconds(600),
20828 deadline_at: now - chrono::Duration::seconds(1),
20829 },
20830 );
20831 app.apply_refresh(Ok(vec![mk_env("prod", "shop", "Web", "Red")]));
20833 let err = app.error_message.as_deref().unwrap_or("");
20834 assert!(
20835 err.contains("no pre-deploy snapshot"),
20836 "expected missing-snapshot guidance, got: {err}"
20837 );
20838 assert!(
20839 app.armed_watchdogs.is_empty(),
20840 "missing-snapshot path still drains the watchdog"
20841 );
20842 }
20843
20844 #[tokio::test]
20845 async fn diff_two_arg_form_opens_overlay_for_named_envs() {
20846 let mut app = test_app();
20851 app.environments = vec![
20852 mk_env("staging", "uflexi", "Web", "Green"),
20853 mk_env("prod", "uflexi", "Web", "Green"),
20854 ];
20855 app.rebuild_view();
20856 app.execute_command("diff staging prod");
20859 assert!(
20860 matches!(app.current_overlay, Some(Overlay::Diff(_))),
20861 "expected Overlay::Diff, got {:?}",
20862 app.current_overlay.is_some()
20863 );
20864 assert!(
20865 app.error_message.is_none(),
20866 "unexpected error: {:?}",
20867 app.error_message
20868 );
20869 }
20870
20871 #[tokio::test]
20872 async fn diff_two_arg_form_rejects_same_env_twice() {
20873 let mut app = test_app();
20876 app.environments = vec![mk_env("prod", "uflexi", "Web", "Green")];
20877 app.rebuild_view();
20878 app.execute_command("diff prod prod");
20879 assert!(
20880 app.current_overlay.is_none(),
20881 "shouldn't open overlay for same-env diff"
20882 );
20883 let err = app.error_message.as_deref().unwrap_or("");
20884 assert!(
20885 err.contains("different envs"),
20886 "expected 'different envs' guidance, got: {err}"
20887 );
20888 }
20889
20890 #[tokio::test]
20891 async fn diff_two_arg_form_errors_on_unknown_env() {
20892 let mut app = test_app();
20895 app.environments = vec![mk_env("staging", "uflexi", "Web", "Green")];
20896 app.rebuild_view();
20897 app.execute_command("diff staging missing-env");
20898 assert!(app.current_overlay.is_none());
20899 let err = app.error_message.as_deref().unwrap_or("");
20900 assert!(
20901 err.contains("missing-env"),
20902 "expected error to name the missing env, got: {err}"
20903 );
20904 }
20905
20906 #[test]
20907 fn format_ssm_results_renders_per_instance_sections() {
20908 let rows = vec![
20914 crate::aws::SsmRunResult {
20915 instance_id: "i-aaa".into(),
20916 status: "Success".into(),
20917 exit_code: 0,
20918 stdout: "hello world\nline two".into(),
20919 stderr: String::new(),
20920 },
20921 crate::aws::SsmRunResult {
20922 instance_id: "i-bbb".into(),
20923 status: "Failed".into(),
20924 exit_code: 2,
20925 stdout: String::new(),
20926 stderr: "permission denied".into(),
20927 },
20928 ];
20929 let body = super::format_ssm_results("uptime", &rows);
20930 assert!(body.contains("`uptime`"));
20932 assert!(body.contains("i-aaa [Success, exit=0]"));
20934 assert!(body.contains("i-bbb [Failed, exit=2]"));
20935 assert!(body.contains("hello world"));
20937 assert!(body.contains("line two"));
20938 assert!(body.contains("permission denied"));
20940 }
20941
20942 #[test]
20943 fn format_ssm_results_truncates_long_output() {
20944 let stdout: String = (0..100).map(|i| format!("line {i}\n")).collect();
20948 let rows = vec![crate::aws::SsmRunResult {
20949 instance_id: "i-aaa".into(),
20950 status: "Success".into(),
20951 exit_code: 0,
20952 stdout,
20953 stderr: String::new(),
20954 }];
20955 let body = super::format_ssm_results("seq 0 99", &rows);
20956 assert!(
20958 body.contains("50 more lines truncated"),
20959 "expected truncation footer, got body:\n{body}"
20960 );
20961 assert!(body.contains("line 49"));
20963 assert!(!body.contains("line 99"));
20964 }
20965
20966 #[test]
20967 fn format_ssm_results_empty_rows_produces_stub() {
20968 let body = super::format_ssm_results("uptime", &[]);
20969 assert!(body.contains("No instances targeted"));
20970 }
20971
20972 #[tokio::test]
20973 async fn ssm_run_without_args_errors_clearly() {
20974 let mut app = test_app();
20975 app.execute_command("ssm-run");
20976 let err = app.error_message.as_deref().unwrap_or("");
20977 assert!(
20978 err.contains("usage:") && err.contains("shell-command"),
20979 "expected usage hint, got: {err}"
20980 );
20981 }
20982
20983 #[tokio::test]
20984 async fn ssm_run_without_detail_errors_with_instances_guidance() {
20985 let mut app = test_app();
20988 app.execute_command("ssm-run \"uptime\"");
20989 let err = app.error_message.as_deref().unwrap_or("");
20990 assert!(
20991 err.contains("Detail") && err.contains("Instances"),
20992 "expected Detail/Instances guidance, got: {err}"
20993 );
20994 }
20995
20996 #[test]
20997 fn format_alarm_history_renders_entries_and_empty_stub() {
20998 use chrono::TimeZone;
20999 let ts = |h, mi| chrono::Utc.with_ymd_and_hms(2026, 5, 24, h, mi, 0).unwrap();
21000 let mk = |t, kind: &str, summary: &str| crate::aws::AlarmHistoryEntry {
21001 at: Some(t),
21002 kind: kind.into(),
21003 summary: summary.into(),
21004 };
21005 let stub = super::format_alarm_history("high-cpu", &[]);
21009 assert!(stub.contains("No history items"));
21010 assert!(stub.contains("90 days"));
21011 let entries = vec![
21015 mk(ts(12, 5), "StateUpdate", "Alarm updated from OK to ALARM"),
21016 mk(ts(11, 0), "ConfigurationUpdate", "Threshold changed to 80"),
21017 ];
21018 let body = super::format_alarm_history("high-cpu", &entries);
21019 assert!(body.contains("[StateUpdate]"));
21020 assert!(body.contains("[ConfigurationUpdate]"));
21021 assert!(body.contains("Alarm updated from OK to ALARM"));
21022 assert!(body.contains("Threshold changed to 80"));
21023 let p_state = body.find("StateUpdate").unwrap();
21025 let p_cfg = body.find("ConfigurationUpdate").unwrap();
21026 assert!(p_state < p_cfg);
21027 }
21028
21029 #[test]
21030 fn format_alarm_history_handles_missing_timestamp() {
21031 let entries = vec![crate::aws::AlarmHistoryEntry {
21034 at: None,
21035 kind: "StateUpdate".into(),
21036 summary: "Alarm went ALARM".into(),
21037 }];
21038 let body = super::format_alarm_history("high-cpu", &entries);
21039 assert!(body.contains("—"));
21040 assert!(body.contains("Alarm went ALARM"));
21041 }
21042
21043 #[test]
21044 fn format_alarms_handles_empty_and_error() {
21045 let none = format_alarms(Ok(vec![]));
21046 assert!(none.contains("no CloudWatch alarms"));
21047 let err = format_alarms(Err("boom".into()));
21048 assert!(err.contains("error"));
21049 let alarms = format_alarms(Ok(vec![CwAlarm {
21050 name: "high-cpu".into(),
21051 state: "ALARM".into(),
21052 state_reason: "CPU > 80%".into(),
21053 metric_name: "CPUUtilization".into(),
21054 namespace: "AWS/EC2".into(),
21055 }]));
21056 assert!(alarms.contains("ALARM"));
21057 assert!(alarms.contains("high-cpu"));
21058 assert!(alarms.contains("CPU > 80%"));
21059 }
21060
21061 #[test]
21062 fn view_round_trips() {
21063 let snap = "filter=prod;sort=health:desc;grouped=true;scope=apps";
21067 let mut got_filter = String::new();
21068 let mut got_sort = (SortKey::App, false);
21069 let mut got_grouped = false;
21070 let mut got_scope = Scope::Envs;
21071 for part in snap.split(';') {
21072 let (k, v) = part.split_once('=').unwrap();
21073 match k {
21074 "filter" => got_filter = v.into(),
21075 "sort" => got_sort = parse_sort(Some(v)),
21076 "grouped" => got_grouped = v == "true",
21077 "scope" => {
21078 got_scope = if v == "apps" {
21079 Scope::Apps
21080 } else {
21081 Scope::Envs
21082 }
21083 }
21084 _ => {}
21085 }
21086 }
21087 assert_eq!(got_filter, "prod");
21088 assert_eq!(got_sort, (SortKey::Health, true));
21089 assert!(got_grouped);
21090 assert_eq!(got_scope, Scope::Apps);
21091 }
21092
21093 #[test]
21094 fn view_mode_cycle_includes_spacious() {
21095 assert_eq!(ViewMode::Default.next(), ViewMode::Compact);
21096 assert_eq!(ViewMode::Compact.next(), ViewMode::Spacious);
21097 assert_eq!(ViewMode::Spacious.next(), ViewMode::Default);
21098 assert_eq!(ViewMode::Spacious.label(), "spacious");
21099 }
21100
21101 #[test]
21102 fn md_escape_protects_pipes_and_backslashes() {
21103 assert_eq!(md_escape("simple"), "simple");
21104 assert_eq!(md_escape("a|b|c"), "a\\|b\\|c");
21105 assert_eq!(md_escape("back\\slash"), "back\\\\slash");
21106 assert_eq!(md_escape("a\\|b"), "a\\\\\\|b");
21107 }
21108
21109 #[test]
21110 fn describe_env_dumps_known_fields() {
21111 let env = Environment {
21112 name: "my-env".into(),
21113 application: "my-app".into(),
21114 status: "Ready".into(),
21115 health: "Green".into(),
21116 platform: "Java 17".into(),
21117 solution_stack: String::new(),
21118 tier: "Web".into(),
21119 cname: "my-env.elb.amazonaws.com".into(),
21120 version_label: "v42".into(),
21121 arn: None,
21122 updated: None,
21123 id: None,
21124 region: None,
21125 };
21126 let text = describe_env(&env);
21127 assert!(text.contains("\"name\""));
21128 assert!(text.contains("my-env"));
21129 assert!(text.contains("\"updated\": null"));
21130 }
21131
21132 #[test]
21133 fn detail_tab_titles_are_distinct() {
21134 use std::collections::HashSet;
21135 let titles: HashSet<&str> = [
21136 DetailTab::Health,
21137 DetailTab::Events,
21138 DetailTab::Instances,
21139 DetailTab::Metrics,
21140 DetailTab::Queue,
21141 DetailTab::Config,
21142 ]
21143 .iter()
21144 .map(|t| t.title())
21145 .collect();
21146 assert_eq!(titles.len(), 6);
21147 }
21148
21149 use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
21176
21177 fn test_app() -> App {
21182 let cfg = crate::config::Config {
21185 theme: "dark".into(),
21186 icons: "unicode".into(),
21187 ..crate::config::Config::default()
21188 };
21189 App::for_tests(crate::aws::AwsClient::stub(), cfg)
21190 }
21191
21192 fn press(app: &mut App, code: KeyCode, mods: KeyModifiers) {
21195 app.handle_event(Event::Key(KeyEvent {
21196 code,
21197 modifiers: mods,
21198 kind: KeyEventKind::Press,
21199 state: crossterm::event::KeyEventState::NONE,
21200 }));
21201 }
21202
21203 fn render(app: &mut App, w: u16, h: u16) -> String {
21207 use ratatui::backend::TestBackend;
21208 use ratatui::Terminal;
21209 let backend = TestBackend::new(w, h);
21210 let mut terminal = Terminal::new(backend).expect("terminal");
21211 terminal.draw(|f| crate::ui::draw(f, app)).expect("draw");
21212 let buf = terminal.backend().buffer();
21213 let mut out = String::new();
21214 for y in 0..buf.area.height {
21215 for x in 0..buf.area.width {
21216 out.push_str(buf[(x, y)].symbol());
21217 }
21218 out.push('\n');
21219 }
21220 out
21221 }
21222
21223 fn mk_env(name: &str, app: &str, tier: &str, health: &str) -> crate::aws::Environment {
21224 crate::aws::Environment {
21225 name: name.into(),
21226 application: app.into(),
21227 status: "Ready".into(),
21228 health: health.into(),
21229 platform: "Java 17".into(),
21230 solution_stack: String::new(),
21231 tier: tier.into(),
21232 cname: format!("{name}.example.com"),
21233 version_label: "build-1".into(),
21234 arn: Some(format!("arn:aws:eb:us-east-1:0:env/{name}")),
21235 updated: None,
21236 id: None,
21237 region: None,
21238 }
21239 }
21240
21241 #[tokio::test]
21242 async fn persist_state_is_a_noop_in_demo_mode() {
21243 let mut app = test_app();
21258 app.demo_mode = true;
21259 app.persist_state();
21263 }
21264
21265 #[tokio::test]
21266 async fn tab_cycles_scope_envs_to_apps_and_back() {
21267 let mut app = test_app();
21268 assert_eq!(app.scope, Scope::Envs);
21269 press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
21270 assert_eq!(app.scope, Scope::Apps);
21271 press(&mut app, KeyCode::Tab, KeyModifiers::NONE);
21272 assert_eq!(app.scope, Scope::Envs);
21273 }
21274
21275 #[tokio::test]
21276 async fn question_mark_opens_help_and_escape_dismisses_it() {
21277 let mut app = test_app();
21278 assert_eq!(app.mode, Mode::Normal);
21279 press(&mut app, KeyCode::Char('?'), KeyModifiers::NONE);
21280 assert_eq!(app.mode, Mode::Help);
21281 press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
21282 assert_eq!(app.mode, Mode::Normal);
21283 }
21284
21285 #[tokio::test]
21286 async fn colon_enters_command_mode_and_esc_cancels() {
21287 let mut app = test_app();
21288 press(&mut app, KeyCode::Char(':'), KeyModifiers::NONE);
21289 assert_eq!(app.mode, Mode::Command);
21290 press(&mut app, KeyCode::Char('q'), KeyModifiers::NONE);
21292 assert_eq!(app.command_input, "q");
21293 press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
21294 assert_eq!(app.mode, Mode::Normal);
21295 assert!(app.command_input.is_empty());
21297 }
21298
21299 #[tokio::test]
21300 async fn slash_enters_filter_mode_and_text_lands() {
21301 let mut app = test_app();
21302 app.environments = vec![
21304 mk_env("prod-web", "uflexi", "Web", "Green"),
21305 mk_env("staging-web", "uflexi", "Web", "Green"),
21306 ];
21307 app.rebuild_view();
21308 press(&mut app, KeyCode::Char('/'), KeyModifiers::NONE);
21309 assert_eq!(app.mode, Mode::Filter);
21310 for c in "prod".chars() {
21311 press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
21312 }
21313 assert_eq!(app.filter, "prod");
21314 press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
21316 assert_eq!(app.mode, Mode::Normal);
21317 assert!(app.filter.is_empty());
21318 }
21319
21320 #[tokio::test]
21321 async fn enter_on_red_env_opens_why_via_bang_keybind() {
21322 let mut app = test_app();
21323 app.environments = vec![mk_env("prod-web", "uflexi", "Web", "Red")];
21325 app.rebuild_view();
21326 app.table_state.select(Some(0));
21327 press(&mut app, KeyCode::Char('!'), KeyModifiers::NONE);
21329 assert!(
21330 matches!(app.current_overlay, Some(Overlay::WhyRed { .. })),
21331 "expected WhyRed overlay, got {:?}",
21332 app.current_overlay
21333 );
21334 }
21335
21336 #[tokio::test]
21337 async fn render_main_table_includes_seeded_env_name() {
21338 let mut app = test_app();
21339 app.environments = vec![mk_env("api-prod-canary", "uflexi", "Web", "Green")];
21340 app.rebuild_view();
21341 let frame = render(&mut app, 160, 24);
21342 assert!(
21343 frame.contains("api-prod-canary"),
21344 "rendered frame should show seeded env name; got:\n{frame}"
21345 );
21346 }
21347
21348 #[tokio::test]
21349 async fn render_main_table_includes_inst_column_header_and_data() {
21350 let mut app = test_app();
21354 app.environments = vec![
21355 mk_env("api-prod", "uflexi", "Web", "Green"),
21356 mk_env("api-staging", "uflexi", "Web", "Green"),
21357 ];
21358 app.env_instance_counts.insert(
21360 "api-prod".into(),
21361 crate::aws::EnvInstanceCounts {
21362 healthy: 3,
21363 total: 3,
21364 },
21365 );
21366 app.rebuild_view();
21367 let frame = render(&mut app, 160, 24);
21368 assert!(
21369 frame.contains("INST"),
21370 "expected INST column header in rendered frame; got:\n{frame}"
21371 );
21372 assert!(
21373 frame.contains("3/3"),
21374 "expected '3/3' for env with seeded counts; got:\n{frame}"
21375 );
21376 assert!(
21378 frame.contains("—"),
21379 "expected em-dash placeholder for env with no counts; got:\n{frame}"
21380 );
21381 }
21382
21383 #[tokio::test]
21384 async fn is_read_only_for_layers_global_env_and_account() {
21385 let mut app = test_app();
21388 app.read_only = true;
21389 assert!(app.is_read_only_for("any-env"));
21390 assert!(app.read_only_reason("any-env").unwrap().contains("global"));
21391
21392 let mut app = test_app();
21395 app.safety_envs.insert("uflexi-prod".into(), true);
21396 app.safety_envs.insert("uflexi-staging".into(), false);
21397 assert!(app.is_read_only_for("uflexi-prod"));
21398 assert!(!app.is_read_only_for("uflexi-staging"));
21399 assert!(!app.is_read_only_for("uflexi-dev"));
21400 assert!(app
21401 .read_only_reason("uflexi-prod")
21402 .unwrap()
21403 .contains("safety.envs.uflexi-prod"));
21404
21405 let mut app = test_app();
21408 app.context.profile = Some("prod-acct".into());
21409 app.safety_accounts.insert("prod-acct".into(), true);
21410 assert!(app.is_read_only_for("any-env"));
21411 assert!(app
21412 .read_only_reason("any-env")
21413 .unwrap()
21414 .contains("safety.accounts.prod-acct"));
21415 app.context.profile = Some("dev-acct".into());
21417 assert!(!app.is_read_only_for("any-env"));
21418
21419 let app = test_app();
21421 assert!(!app.is_read_only_for("any-env"));
21422 assert!(app.read_only_reason("any-env").is_none());
21423 }
21424
21425 #[tokio::test]
21426 async fn ctrl_x_toggles_redact() {
21427 let mut app = test_app();
21428 assert!(!app.redact);
21429 press(&mut app, KeyCode::Char('x'), KeyModifiers::CONTROL);
21430 assert!(app.redact);
21431 press(&mut app, KeyCode::Char('x'), KeyModifiers::CONTROL);
21432 assert!(!app.redact);
21433 }
21434
21435 #[tokio::test]
21436 async fn space_toggles_multi_select_and_esc_clears_it() {
21437 let mut app = test_app();
21438 app.environments = vec![
21439 mk_env("api-prod", "uflexi", "Web", "Green"),
21440 mk_env("api-staging", "uflexi", "Web", "Green"),
21441 ];
21442 app.rebuild_view();
21443 press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
21445 assert_eq!(app.multi_selected.len(), 1);
21446 assert!(app.multi_selected.contains("api-prod"));
21447 press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
21449 assert!(app.multi_selected.is_empty());
21450 press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
21452 press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE);
21453 press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
21454 assert_eq!(app.multi_selected.len(), 2);
21455 press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
21456 assert!(app.multi_selected.is_empty());
21457 }
21458
21459 #[tokio::test]
21460 async fn filter_mode_text_input_and_backspace_round_trips() {
21461 let mut app = test_app();
21462 app.environments = vec![
21463 mk_env("api-prod", "uflexi", "Web", "Green"),
21464 mk_env("api-staging", "uflexi", "Web", "Green"),
21465 ];
21466 app.rebuild_view();
21467 press(&mut app, KeyCode::Char('/'), KeyModifiers::NONE);
21469 assert_eq!(app.mode, Mode::Filter);
21470 for c in "prod".chars() {
21472 press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
21473 }
21474 assert_eq!(app.filter, "prod");
21475 press(&mut app, KeyCode::Backspace, KeyModifiers::NONE);
21477 assert_eq!(app.filter, "pro");
21478 press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
21482 assert_eq!(app.mode, Mode::Normal);
21483 assert_eq!(app.filter, "pro");
21484 }
21485
21486 #[tokio::test]
21487 async fn esc_in_filter_mode_clears_the_filter() {
21488 let mut app = test_app();
21489 app.environments = vec![mk_env("api-prod", "uflexi", "Web", "Green")];
21490 app.rebuild_view();
21491 press(&mut app, KeyCode::Char('/'), KeyModifiers::NONE);
21492 for c in "x".chars() {
21493 press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
21494 }
21495 assert_eq!(app.filter, "x");
21496 press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
21498 assert_eq!(app.mode, Mode::Normal);
21499 assert!(app.filter.is_empty());
21500 }
21501
21502 #[tokio::test]
21503 async fn star_toggles_pinned_set_for_selected_env() {
21504 let mut app = test_app();
21505 app.environments = vec![
21506 mk_env("api-prod", "uflexi", "Web", "Green"),
21507 mk_env("api-staging", "uflexi", "Web", "Green"),
21508 ];
21509 app.rebuild_view();
21510 press(&mut app, KeyCode::Char('*'), KeyModifiers::NONE);
21512 assert!(app.pinned.contains("api-prod"));
21513 press(&mut app, KeyCode::Char('*'), KeyModifiers::NONE);
21515 assert!(!app.pinned.contains("api-prod"));
21516 }
21517
21518 #[tokio::test]
21519 async fn picker_workflow_open_filter_enter_dispatches_choice() {
21520 let mut app = test_app();
21526 press(&mut app, KeyCode::Char('r'), KeyModifiers::NONE);
21527 assert_eq!(app.mode, Mode::Picker);
21528 assert!(app.picker.is_some());
21529 press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
21531 assert_eq!(app.mode, Mode::Normal);
21532 assert!(app.picker.is_none());
21533 }
21534
21535 fn mk_modal(action: Action, env: &str) -> ConfirmModal {
21540 ConfirmModal {
21541 action,
21542 target_env: env.into(),
21543 swap_with: None,
21544 typed: String::new(),
21545 kind: ConfirmKind::YesNo,
21546 dryrun: None,
21547 loading_dryrun: false,
21548 recent_events: None,
21549 loading_events: false,
21550 traffic_warning: None,
21551 deploy_version: None,
21552 upgrade_platform_arn: None,
21553 upgrade_platform_label: None,
21554 clone_target: None,
21555 scale_min: None,
21556 scale_max: None,
21557 auto_rollback_secs: None,
21558 wait_for_green_secs: None,
21559 version_preview: None,
21560 loading_version_preview: false,
21561 health_check_probe: None,
21562 loading_health_check: false,
21563 unavailability_line: None,
21564 loading_unavailability: false,
21565 lint_issues: None,
21566 loading_lint: false,
21567 }
21568 }
21569
21570 #[tokio::test]
21571 async fn queue_action_dispatch_holds_action_for_cancel_window() {
21572 let mut app = test_app();
21573 let modal = mk_modal(Action::Rebuild, "uflexi-prod");
21574 app.queue_action_dispatch(modal);
21575 let pd = app
21576 .pending_dispatch
21577 .as_ref()
21578 .expect("queue should set pending_dispatch");
21579 assert_eq!(pd.target, "uflexi-prod");
21580 assert!(
21581 matches!(pd.kind, PendingDispatchKind::Single { .. }),
21582 "queue_action_dispatch should produce a Single variant"
21583 );
21584 assert!(
21585 pd.deadline > std::time::Instant::now(),
21586 "deadline must be in the future"
21587 );
21588 let remaining = pd
21589 .deadline
21590 .saturating_duration_since(std::time::Instant::now());
21591 assert!(
21592 remaining <= UNDO_WINDOW && remaining >= UNDO_WINDOW - Duration::from_millis(500),
21593 "deadline should be roughly UNDO_WINDOW from now; got {remaining:?}"
21594 );
21595 }
21596
21597 #[tokio::test]
21598 async fn cancel_pending_dispatch_clears_field_and_emits_status() {
21599 let mut app = test_app();
21600 app.queue_action_dispatch(mk_modal(Action::Terminate, "uflexi-prod"));
21601 assert!(app.pending_dispatch.is_some());
21602 app.cancel_pending_dispatch();
21603 assert!(app.pending_dispatch.is_none());
21604 let msg = app.status_message.as_deref().unwrap_or("");
21605 assert!(
21606 msg.contains("undone") && msg.contains("uflexi-prod"),
21607 "status should mention the undo + env; got: {msg:?}"
21608 );
21609 }
21610
21611 #[tokio::test]
21612 async fn second_queue_attempt_errors_while_first_pending() {
21613 let mut app = test_app();
21614 app.queue_action_dispatch(mk_modal(Action::Rebuild, "first"));
21615 assert!(app.pending_dispatch.is_some());
21616 let first_deadline = app.pending_dispatch.as_ref().unwrap().deadline;
21617 app.queue_action_dispatch(mk_modal(Action::Rebuild, "second"));
21619 assert_eq!(
21620 app.pending_dispatch.as_ref().unwrap().target,
21621 "first",
21622 "second queue must not replace the first"
21623 );
21624 assert_eq!(
21625 app.pending_dispatch.as_ref().unwrap().deadline,
21626 first_deadline,
21627 "second queue must not bump the deadline"
21628 );
21629 assert!(
21630 app.error_message
21631 .as_deref()
21632 .unwrap_or("")
21633 .contains("press U to undo"),
21634 "second queue should surface a useful error"
21635 );
21636 }
21637
21638 #[tokio::test]
21639 async fn tick_pending_dispatch_fires_after_deadline() {
21640 let mut app = test_app();
21641 let modal = mk_modal(Action::Rebuild, "expired");
21645 app.pending_dispatch = Some(PendingDispatch {
21646 deadline: std::time::Instant::now() - Duration::from_millis(1),
21647 label: "Rebuild env".into(),
21648 target: "expired".into(),
21649 kind: PendingDispatchKind::Single { modal },
21650 });
21651 app.tick_pending_dispatch();
21652 assert!(
21653 app.pending_dispatch.is_none(),
21654 "expired tick should clear the field (dispatch handed to spawn_action)"
21655 );
21656 }
21657
21658 #[tokio::test]
21659 async fn batch_action_routes_through_cancel_window() {
21660 let mut app = test_app();
21661 app.environments = vec![
21662 mk_env("prod-web", "uflexi", "Web", "Green"),
21663 mk_env("staging-web", "uflexi", "Web", "Green"),
21664 ];
21665 app.multi_selected.insert("prod-web".into());
21666 app.multi_selected.insert("staging-web".into());
21667 app.cmd_batch_action(Action::Rebuild);
21668 assert!(
21670 app.multi_selected.is_empty(),
21671 "multi-select should clear once the batch is queued"
21672 );
21673 let pd = app
21674 .pending_dispatch
21675 .as_ref()
21676 .expect("batch action should queue a pending dispatch");
21677 match &pd.kind {
21678 PendingDispatchKind::BatchAction { action, env_names } => {
21679 assert_eq!(*action, Action::Rebuild);
21680 assert_eq!(env_names.len(), 2);
21681 }
21682 other => panic!(
21683 "expected BatchAction variant; got {other:?}",
21684 other = match other {
21685 PendingDispatchKind::Single { .. } => "Single",
21686 PendingDispatchKind::BatchAction { .. } => "BatchAction",
21687 PendingDispatchKind::BatchDeploy { .. } => "BatchDeploy",
21688 PendingDispatchKind::BatchTag { .. } => "BatchTag",
21689 PendingDispatchKind::BatchSetOption { .. } => "BatchSetOption",
21690 }
21691 ),
21692 }
21693 }
21694
21695 #[tokio::test]
21696 async fn batch_action_undo_cancels_whole_fanout() {
21697 let mut app = test_app();
21698 app.environments = vec![
21699 mk_env("e1", "uflexi", "Web", "Green"),
21700 mk_env("e2", "uflexi", "Web", "Green"),
21701 mk_env("e3", "uflexi", "Web", "Green"),
21702 ];
21703 for name in ["e1", "e2", "e3"] {
21704 app.multi_selected.insert(name.into());
21705 }
21706 app.cmd_batch_action(Action::RestartAppServer);
21707 assert!(app.pending_dispatch.is_some());
21708 app.cancel_pending_dispatch();
21709 assert!(
21710 app.pending_dispatch.is_none(),
21711 "cancel should drop the whole batch, not just one env"
21712 );
21713 let msg = app.status_message.as_deref().unwrap_or("");
21714 assert!(
21715 msg.contains("undone") && msg.contains("3 env(s)"),
21716 "status should call out the 3-env batch; got: {msg:?}"
21717 );
21718 }
21719
21720 #[tokio::test]
21721 async fn apps_scope_space_toggles_apps_selected() {
21722 let mut app = test_app();
21723 app.applications = vec![
21725 crate::aws::Application {
21726 name: "billing".into(),
21727 description: String::new(),
21728 date_created: None,
21729 date_updated: None,
21730 version_count: 0,
21731 templates: vec![],
21732 latest_version_label: None,
21733 latest_version_created: None,
21734 },
21735 crate::aws::Application {
21736 name: "checkout".into(),
21737 description: String::new(),
21738 date_created: None,
21739 date_updated: None,
21740 version_count: 0,
21741 templates: vec![],
21742 latest_version_label: None,
21743 latest_version_created: None,
21744 },
21745 ];
21746 app.set_scope(Scope::Apps);
21747 app.app_table_state.select(Some(0));
21748 press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
21750 assert!(app.apps_selected.contains("billing"));
21751 press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
21752 assert!(!app.apps_selected.contains("billing"));
21753 }
21754
21755 #[tokio::test]
21756 async fn apps_scope_star_pins_and_unpins_app() {
21757 let mut app = test_app();
21758 app.applications = vec![crate::aws::Application {
21759 name: "billing".into(),
21760 description: String::new(),
21761 date_created: None,
21762 date_updated: None,
21763 version_count: 0,
21764 templates: vec![],
21765 latest_version_label: None,
21766 latest_version_created: None,
21767 }];
21768 app.set_scope(Scope::Apps);
21769 app.app_table_state.select(Some(0));
21770 assert!(!app.pinned_apps.contains("billing"));
21771 press(&mut app, KeyCode::Char('*'), KeyModifiers::SHIFT);
21772 assert!(app.pinned_apps.contains("billing"));
21773 press(&mut app, KeyCode::Char('*'), KeyModifiers::SHIFT);
21774 assert!(!app.pinned_apps.contains("billing"));
21775 }
21776
21777 #[tokio::test]
21778 async fn esc_clears_apps_selected_when_no_envs_selected() {
21779 let mut app = test_app();
21780 app.apps_selected.insert("billing".into());
21781 app.apps_selected.insert("checkout".into());
21782 press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
21783 assert!(app.apps_selected.is_empty());
21784 }
21785
21786 #[tokio::test]
21787 async fn capital_u_cancels_pending_dispatch_in_normal_mode() {
21788 let mut app = test_app();
21789 app.queue_action_dispatch(mk_modal(Action::Rebuild, "uflexi-prod"));
21790 assert!(app.pending_dispatch.is_some());
21791 press(&mut app, KeyCode::Char('U'), KeyModifiers::SHIFT);
21792 assert!(
21793 app.pending_dispatch.is_none(),
21794 "capital U in Normal mode should cancel the pending dispatch"
21795 );
21796 }
21797}