1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
//! Opening the read-only informational overlays — why-red, what's
//! new, about, report-a-bug, instance info, the DLQ queue viewer —
//! and the small toggles that sit alongside them.
use super::*;
impl App {
/// `:why` / `:diagnose` — open the unified diagnostic overlay for the
/// given env. Installs an empty `Overlay::WhyRed` immediately so the
/// user sees "fetching…" placeholders, then fans out four parallel
/// fetchers (events, alarms, instances, deploys). Each lands as its
/// own `AppMsg::WhyRed*` variant gated on `session_id`.
pub(crate) fn open_why_red(&mut self, env_name: String, app_name: String) {
self.why_red_session = self.why_red_session.wrapping_add(1);
let session_id = self.why_red_session;
// Tier captured up front so the renderer can hide the queue
// section for Web envs without consulting `self.environments`
// (which may have refreshed under us by the time the overlay
// renders).
let tier = self
.environments
.iter()
.find(|e| e.name == env_name)
.map(|e| e.tier.clone())
.unwrap_or_default();
let is_worker = tier.eq_ignore_ascii_case("Worker");
self.current_overlay = Some(Overlay::WhyRed {
env_name: env_name.clone(),
tier,
events: None,
alarms: None,
instances: None,
deploys: None,
// Web envs never get a queues entry — keep it None so the
// renderer omits the section entirely. Worker envs start at
// None and fill in via WhyRedQueues.
queues: None,
dlq_messages: None,
session_id,
cursor: 0,
});
self.spawn_why_red_events(env_name.clone(), session_id);
self.spawn_why_red_alarms(env_name.clone(), session_id);
self.spawn_why_red_instances(env_name.clone(), session_id);
self.spawn_why_red_deploys(app_name.clone(), session_id);
if is_worker {
self.spawn_why_red_queues(app_name, env_name, session_id);
}
}
pub(crate) fn set_log_level(&mut self, level: &str) {
// Treat a bare level as a directive applied to the root, but keep the
// AWS/hyper crates capped at warn unless the user explicitly opts in.
let directive = match level.to_lowercase().as_str() {
"trace" | "debug" | "info" | "warn" | "error" => {
format!("{level},aws=warn,hyper=warn")
}
other => other.to_string(),
};
let new_filter = match tracing_subscriber::EnvFilter::try_new(&directive) {
Ok(f) => f,
Err(e) => {
self.error_message = Some(format!("invalid log directive '{level}': {e}"));
return;
}
};
let Some(handle) = self.log_reload.as_ref() else {
self.error_message = Some("log reload handle missing".into());
return;
};
match handle.modify(|f| *f = new_filter) {
Ok(()) => {
self.log_directive = directive.clone();
self.status_message = Some(format!("log level → {directive}"));
}
Err(e) => self.error_message = Some(format!("log reload failed: {e}")),
}
}
pub(crate) fn open_whatsnew(&mut self) {
// Embedded changelog text. Keep this short — full release notes live in
// git history / GitHub releases. Update on every release.
self.current_overlay = Some(Overlay::Whatsnew(WHATSNEW.into()));
}
/// `:about` / `:credits` — author + license + repo info. Discoverable
/// via the command palette but never pushed at the operator;
/// existence justifies removing the splash byline if anyone ever
/// objects to the 3-second introduction.
/// `:report-bug` — build a scrubbed bug-report payload from
/// current app state + ~/.cache/ebman/ebman.log tail + latest
/// crash log (if any), and show it in the `Overlay::ReportBug`.
/// Operator chooses `y` (copy to clipboard) or `b` (open
/// GitHub issue in browser). See `report_bug` module for the
/// scrubbing rules.
pub(crate) fn open_report_bug_overlay(&mut self) {
let cnames: std::collections::BTreeSet<String> = self
.environments
.iter()
.filter(|e| !e.cname.is_empty())
.map(|e| e.cname.clone())
.collect();
let env_names: std::collections::BTreeSet<String> =
self.environments.iter().map(|e| e.name.clone()).collect();
let app_names: std::collections::BTreeSet<String> =
self.applications.iter().map(|a| a.name.clone()).collect();
// message_log entries are (timestamp, kind, text) tuples;
// pull the text + a single-char severity prefix so the
// operator can see whether each line was a status or an
// error without the structured tracing noise.
let recent_messages: Vec<String> = self
.message_log
.iter()
.rev()
.take(10)
.map(|(ts, kind, text)| {
let sev = match kind {
MsgKind::Info => "[i]",
MsgKind::Error => "[!]",
};
let when = ts.format("%H:%M:%S");
format!("{when} {sev} {text}")
})
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
let icons = format!("{:?}", self.theme.icons).to_lowercase();
let input = crate::report_bug::ReportInput {
ebman_version: env!("CARGO_PKG_VERSION"),
os: std::env::consts::OS,
os_release: std::env::consts::ARCH,
icons: &icons,
theme: self.theme.name,
refresh_interval_secs: self.refresh_interval.as_secs(),
recent_log_lines: crate::report_bug::tail_ebman_log(30),
recent_messages,
recent_crash: crate::report_bug::latest_crash_log(),
env_count: self.environments.len(),
app_count: self.applications.len(),
multi_regions_count: self.multi_regions.len(),
multi_account_enabled: !self.cfg.accounts.is_empty(),
};
let ctx = crate::report_bug::ScrubContext {
account_id: self.context.account_id.clone(),
profile: self.context.profile.clone(),
region: Some(self.context.region.clone()),
env_names,
app_names,
cnames,
};
let body = crate::report_bug::build_report(&input, &ctx);
self.current_overlay = Some(Overlay::ReportBug { body });
}
/// Key handler for the `:report-bug` overlay. `y` copies the
/// scrubbed payload to clipboard; `b` opens a pre-filled
/// GitHub issue in the browser; `esc` / `q` closes. Same shape
/// as the other interactive overlays.
pub(crate) fn handle_report_bug_key(&mut self, key: KeyEvent) {
let body = match self.current_overlay.as_ref() {
Some(Overlay::ReportBug { body }) => body.clone(),
_ => return,
};
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
self.current_overlay = None;
}
KeyCode::Char('y') | KeyCode::Char('Y') => {
match yank(&body) {
Ok(()) => {
self.status_message = Some(format!(
"bug report copied to clipboard ({} chars) — paste at https://github.com/tombaldwin/ebman/issues/new",
body.chars().count()
));
}
Err(e) => {
self.error_message = Some(format!("clipboard error: {e}"));
}
}
self.current_overlay = None;
}
KeyCode::Char('b') | KeyCode::Char('B') => {
let url = crate::report_bug::github_issue_url(
"tombaldwin/ebman",
"Bug report from ebman",
&body,
);
match open_url(&url) {
Ok(()) => {
self.status_message = Some("opened GitHub issue draft in browser".into());
}
Err(e) => {
self.error_message = Some(format!("couldn't open browser: {e}"));
}
}
self.current_overlay = None;
}
_ => {}
}
}
pub(crate) fn open_about_overlay(&mut self) {
// The card content is built by `draw_about`; the overlay just
// carries the open time so the giant scene can animate.
self.current_overlay = Some(Overlay::About(Instant::now()));
}
pub(crate) fn cycle_metrics_range(&mut self, delta: i32) {
const RANGES: &[i64] = &[900, 3600, 21_600, 86_400]; // 15m / 1h / 6h / 24h
let Some(d) = self.detail.as_mut() else {
return;
};
let cur = RANGES
.iter()
.position(|r| *r == d.metrics_range_secs)
.unwrap_or(1) as i32;
let next = (cur + delta).rem_euclid(RANGES.len() as i32) as usize;
d.metrics_range_secs = RANGES[next];
let env_name = d.env_name.clone();
self.spawn_detail_metrics(env_name);
}
/// Open the worker-queue viewer for the env in Detail mode, defaulting
/// to whichever queue the caller asked for. `open_dlq` is the legacy
/// shortcut that always opens the DLQ.
pub(crate) fn open_queue_viewer(&mut self, viewing: QueueView) {
let Some(detail) = self.detail.as_ref() else {
return;
};
if detail.tab() != DetailTab::Queue {
return;
}
let main_url = detail.queues.main_url.clone().unwrap_or_default();
let dlq_url = detail.queues.dlq_url.clone().unwrap_or_default();
let target_url = match viewing {
QueueView::Main => main_url.clone(),
QueueView::Dlq => dlq_url.clone(),
};
if target_url.is_empty() {
self.status_message = Some(match viewing {
QueueView::Main => "no main queue URL known".into(),
QueueView::Dlq => "no DLQ for this env".into(),
});
return;
}
let dlq = DlqState {
env_name: detail.env_name.clone(),
main_queue_url: main_url,
dlq_url,
messages: Vec::new(),
list_state: ListState::default(),
loading: false,
error: None,
confirm_purge: false,
purge_typed: TextInput::new(),
viewing,
confirm_delete_id: None,
replay_input: None,
};
self.dlq = Some(dlq);
self.mode = Mode::Dlq;
self.spawn_dlq_fetch();
}
/// Open a confirm modal for an action that carries parameters (deploy
/// version, clone target, scale min/max, …). Uses the same Y/N path as
/// the existing Rebuild / Restart / Swap confirms so the operator sees
/// the impact summary before authorising.
/// Surface the selected instance's details as an `Overlay::TextDump`.
/// Non-intrusive alternative to opening the EC2 console — operators
/// can scan id / type / AZ / health / causes / launch age without
/// leaving the TUI. `b` still opens the browser when needed.
pub(crate) fn open_instance_info_overlay(&mut self) {
let Some(d) = self.detail.as_ref() else {
return;
};
let Some(inst) = d.instances.get(d.instances_cursor) else {
self.status_message = Some("no instance selected".into());
return;
};
let mut body = String::new();
body.push_str(&format!("Instance ID {}\n", inst.id));
body.push_str(&format!("Type {}\n", inst.instance_type));
body.push_str(&format!("Availability zone {}\n", inst.availability_zone));
body.push_str(&format!(
"Health {} ({})\n",
inst.health, inst.color
));
if let Some(t) = inst.launched_at {
let age = chrono::Utc::now().signed_duration_since(t);
body.push_str(&format!(
"Launched {} (up {})\n",
t.format("%Y-%m-%d %H:%M UTC"),
humanize_short_age(age.to_std().unwrap_or_default())
));
}
if !inst.causes.is_empty() {
body.push_str("\nCauses:\n");
for c in &inst.causes {
body.push_str(&format!(" • {c}\n"));
}
}
body.push_str(
"\nKeys: b → open in EC2 console · s → SSM shell · y → yank id · x → terminate",
);
self.current_overlay = Some(Overlay::TextDump {
title: format!("instance — {}", inst.id),
body,
});
}
}