use std::time::{Duration, Instant};
use ratatui::widgets::ListState;
use tui_common::TextInput;
use crate::aws::Event as EbEvent;
use super::tail;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Table,
Events,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewMode {
Default,
Compact,
Spacious,
}
impl ViewMode {
pub fn next(self) -> Self {
match self {
Self::Default => Self::Compact,
Self::Compact => Self::Spacious,
Self::Spacious => Self::Default,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Default => "default",
Self::Compact => "compact",
Self::Spacious => "spacious",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Scope {
Envs,
Apps,
}
impl Scope {
pub fn next(self) -> Self {
match self {
Self::Envs => Self::Apps,
Self::Apps => Self::Envs,
}
}
pub fn prev(self) -> Self {
self.next()
}
}
pub const HISTORY_CAP: usize = 20;
pub(crate) const MESSAGE_LOG_CAP: usize = 50;
pub(crate) const TOAST_CAP: usize = 4;
pub const LOADING_INDICATOR_THRESHOLD: Duration = Duration::from_millis(300);
pub const LOADING_INDICATOR_LINGER: Duration = Duration::from_millis(500);
#[derive(Debug, Clone)]
pub enum Overlay {
Describe(String),
Whatsnew(String),
History(String),
Alarms { env_name: String, body: String },
Diff(String),
SavedConfigs(String),
TextDump { title: String, body: String },
SavedConfigsInteractive {
items: Vec<(String, String)>,
cursor: usize,
confirm_delete: bool,
},
WhyRed {
env_name: String,
tier: String,
events: Option<Result<Vec<crate::aws::Event>, String>>,
alarms: Option<Result<Vec<crate::aws::CwAlarm>, String>>,
instances: Option<Result<Vec<crate::aws::Instance>, String>>,
deploys: Option<Result<Vec<crate::aws::AppVersion>, String>>,
queues: Option<Result<crate::aws::WorkerQueues, String>>,
dlq_messages: Option<Result<Vec<crate::aws::QueueMessage>, String>>,
session_id: u64,
cursor: usize,
},
ReportBug { body: String },
AppsActionMenu {
app_name: String,
env_names: Vec<String>,
cursor: usize,
},
LogTail {
log_group: String,
env_name: String,
events: std::collections::VecDeque<crate::aws::LogEvent>,
since_ms: i64,
view: tail::TailView,
last_err: Option<String>,
session_id: u64,
},
EventTail {
events: std::collections::VecDeque<crate::aws::Event>,
view: tail::TailView,
last_err: Option<String>,
session_id: u64,
truncated_polls: usize,
},
About(std::time::Instant),
}
pub const LOG_TAIL_MAX_LINES: usize = 2000;
pub const EVENT_TAIL_MAX_EVENTS: usize = 1000;
pub(crate) const EVENT_TAIL_FIRST_BATCH: i32 = 100;
pub(crate) const EVENT_TAIL_POLL_BATCH: i32 = 300;
pub(crate) fn next_event_watermark_ms(events: &[crate::aws::Event], prev_ms: i64) -> i64 {
events
.iter()
.filter_map(|e| e.at.map(|at| at.timestamp_millis() + 1))
.max()
.unwrap_or(prev_ms)
.max(prev_ms)
}
pub(crate) const EVENT_TAIL_GAP_SEVERITY: &str = "GAP";
pub(crate) fn is_event_tail_gap(ev: &crate::aws::Event) -> bool {
ev.severity == EVENT_TAIL_GAP_SEVERITY && ev.at.is_none()
}
pub(crate) fn event_tail_matches(pattern: ®ex::Regex, ev: &crate::aws::Event) -> bool {
is_event_tail_gap(ev)
|| pattern.is_match(&ev.env)
|| pattern.is_match(&ev.application)
|| pattern.is_match(&ev.severity)
|| pattern.is_match(&ev.message)
}
#[derive(Debug, Clone)]
pub enum WhyItem {
Describe(String),
OpenDlq,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToastKind {
Info,
Success,
Error,
}
#[derive(Debug, Clone)]
pub struct Toast {
pub text: String,
pub kind: ToastKind,
pub shown_at: Instant,
}
impl Toast {
pub fn ttl(&self) -> Duration {
match self.kind {
ToastKind::Error => Duration::from_secs(8),
_ => Duration::from_secs(4),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MsgKind {
Info,
Error,
}
pub(crate) const WHATSNEW: &str = "\
ebman — what's new
==================
Recent additions:
• --version / --help / --read-only CLI flags
• README and GitHub Actions CI
• Themes: dark, light, high-contrast (set in config.toml)
• Detail auto-refresh (R in Detail mode)
• Open env in console (b)
• Describe overlay (D — raw env JSON)
• Breadcrumb top-line, FROZEN pill, quick-jump 1-9
• Pin / star envs (*), persisted across runs
• Local env aliases (:alias NAME LABEL)
• Exports: TSV (^Y), JSON (:json), Markdown (:report)
• Read-only mode (--read-only or :readonly on)
• Local audit log (~/.cache/ebman/audit.log)
• Notification bell (notify_bell = true in config.toml)
• Crash report writer
Press esc / q / w to close.";
pub(crate) const WELCOME_OVERLAY: &str = "\
Welcome to ebman
================
Looks like this is your first run — no AWS credentials or persisted ebman
state were found on this machine. Here's what you'll need:
1. AWS credentials. Either:
aws sso login --profile my-sso-profile (recommended)
or set up ~/.aws/credentials with an access key, then
export AWS_PROFILE=my-profile
2. The IAM identity needs at least these EB read permissions:
elasticbeanstalk:DescribeEnvironments
elasticbeanstalk:DescribeApplications
elasticbeanstalk:DescribeEvents
Destructive actions (rebuild / restart / swap / terminate) require their
matching write permission; you can stay safe with `--read-only` until then.
3. Optional: drop a config at ~/.config/ebman/config.toml. See README.md for
the full schema (theme, refresh_interval_secs, extra_regions, …).
Key bindings:
? this help screen
p / r switch profile / region
: command bar
Ctrl-K fuzzy command palette
Ctrl-X redact mode (good for screenshots / streaming)
Press esc / q / w to close.";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortKey {
App,
Name,
Status,
Health,
Age,
Version,
}
impl SortKey {
pub fn next(self) -> Self {
match self {
Self::Name => Self::App,
Self::App => Self::Status,
Self::Status => Self::Health,
Self::Health => Self::Version,
Self::Version => Self::Age,
Self::Age => Self::Name,
}
}
pub fn label(self) -> &'static str {
match self {
Self::App => "app",
Self::Name => "name",
Self::Status => "status",
Self::Health => "health",
Self::Age => "age",
Self::Version => "version",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"app" => Some(Self::App),
"name" => Some(Self::Name),
"status" => Some(Self::Status),
"health" => Some(Self::Health),
"age" => Some(Self::Age),
"version" => Some(Self::Version),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum EventTimeFormat {
#[default]
Utc,
Local,
Age,
}
impl EventTimeFormat {
pub fn next(self) -> Self {
match self {
Self::Utc => Self::Local,
Self::Local => Self::Age,
Self::Age => Self::Utc,
}
}
pub fn label(self) -> &'static str {
match self {
Self::Utc => "utc",
Self::Local => "local",
Self::Age => "age",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s.to_ascii_lowercase().as_str() {
"utc" => Some(Self::Utc),
"local" => Some(Self::Local),
"age" | "relative" => Some(Self::Age),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Normal,
Filter,
Help,
Picker,
Command,
Detail,
Action,
Dlq,
QuickJump,
Palette,
Shell,
Form,
}
#[derive(Debug, Clone)]
pub enum PaletteAction {
RunCommand(String),
PrefillCommand(String),
JumpEnv(String),
LoadView(String),
}
#[derive(Debug, Clone)]
pub struct PaletteItem {
pub label: String,
pub detail: String,
pub kind_tag: &'static str, pub action: PaletteAction,
}
#[derive(Debug, Clone)]
pub struct PendingAction {
pub label: String,
pub target: String,
pub started: Instant,
pub completed: Option<(Instant, Result<(), String>)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum HelpTopic {
Global,
Detail,
Dlq,
Action,
Shell,
SavedConfigs,
}
pub const PENDING_CAP: usize = 20;
pub const PENDING_COMPLETED_TTL: Duration = Duration::from_secs(60);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromotionRecord {
pub source: String,
pub target: String,
pub version_label: String,
pub at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)] pub(crate) struct DeploySnapshot {
pub env_name: String,
pub previous_version_label: String,
pub taken_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)] pub(crate) struct ArmedWatchdog {
pub env_name: String,
pub target_label: String,
pub armed_at: chrono::DateTime<chrono::Utc>,
pub deadline_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
pub(crate) struct WatchingDeploy {
pub env_name: String,
pub target_label: String,
pub armed_at: chrono::DateTime<chrono::Utc>,
pub deadline_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
pub(crate) struct UndoEntry {
pub env_name: String,
pub to_set: Vec<(String, String, String)>,
pub to_remove: Vec<(String, String)>,
pub original_summary: String,
pub captured_at: chrono::DateTime<chrono::Utc>,
}
pub(crate) const UNDO_HISTORY_CAP: usize = 10;
#[derive(Debug, Clone)]
pub(crate) struct DeployFreeze {
pub reason: String,
pub frozen_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Clone)]
pub(crate) struct Incident {
pub headline: String,
pub started_at: chrono::DateTime<chrono::Utc>,
}
impl DeploySnapshot {
pub fn to_persisted(&self) -> String {
format!(
"{}|{}",
self.previous_version_label,
self.taken_at.to_rfc3339()
)
}
pub fn parse_persisted(env_name: &str, raw: &str) -> Option<Self> {
let (label, ts_str) = raw.split_once('|')?;
let label = label.trim();
if label.is_empty() {
return None;
}
let taken_at = chrono::DateTime::parse_from_rfc3339(ts_str.trim())
.ok()?
.with_timezone(&chrono::Utc);
Some(Self {
env_name: env_name.to_string(),
previous_version_label: label.to_string(),
taken_at,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PickerKind {
Profile,
Region,
LogGroup,
SshInstance,
}
pub struct Picker {
pub kind: PickerKind,
pub items: Vec<String>,
pub filter: TextInput,
pub list_state: ListState,
}
#[derive(Clone, Debug)]
pub struct MultiSelectOptions {
pub options: Vec<String>,
pub annotations: Vec<String>,
pub initial: Vec<String>,
}
impl Picker {
pub fn new(kind: PickerKind, items: Vec<String>, current: Option<&str>) -> Self {
let mut list_state = ListState::default();
let initial = current
.and_then(|c| items.iter().position(|i| i == c))
.unwrap_or(0);
if !items.is_empty() {
list_state.select(Some(initial));
}
Self {
kind,
items,
filter: TextInput::new(),
list_state,
}
}
pub fn title(&self) -> &'static str {
match self.kind {
PickerKind::Profile => " select profile ",
PickerKind::Region => " select region ",
PickerKind::LogGroup => " select log group ",
PickerKind::SshInstance => " select instance for SSM session ",
}
}
pub fn filtered(&self) -> Vec<usize> {
if self.filter.is_empty() {
return (0..self.items.len()).collect();
}
let needle = self.filter.text().to_lowercase();
self.items
.iter()
.enumerate()
.filter(|(_, v)| v.to_lowercase().contains(&needle))
.map(|(i, _)| i)
.collect()
}
pub fn move_selection(&mut self, delta: i32) {
let filt = self.filtered();
if filt.is_empty() {
self.list_state.select(None);
return;
}
let cur_visible = self
.list_state
.selected()
.and_then(|s| filt.iter().position(|i| *i == s))
.unwrap_or(0) as i32;
let next = (cur_visible + delta).rem_euclid(filt.len() as i32) as usize;
self.list_state.select(Some(filt[next]));
}
pub fn selected_value(&self) -> Option<String> {
self.list_state
.selected()
.and_then(|i| self.items.get(i).cloned())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoadState {
Idle,
Loading,
Error,
}
#[derive(Default)]
pub struct CompletionState {
pub origin: Option<String>,
pub index: usize,
}
pub struct HelpState {
pub scroll: u16,
pub max_scroll: u16,
pub topic: HelpTopic,
pub pre_mode: Option<Mode>,
pub pre_overlay: Option<Overlay>,
}
pub struct EventPanel {
pub events: Vec<EbEvent>,
pub visible: bool,
pub time_format: EventTimeFormat,
pub for_env: Option<String>,
pub scroll: u16,
pub area: Option<ratatui::layout::Rect>,
pub drag_origin: Option<u16>,
pub cursor: Option<usize>,
pub height: u16,
}
#[derive(Debug, Clone, Default)]
pub struct ResolvedConfig {
pub notify_webhook: Option<String>,
pub command_aliases: std::collections::HashMap<String, String>,
pub lint_disable: Vec<String>,
pub explain_settings: crate::llm::Settings,
pub required_tags: Vec<String>,
pub alarm_dimensions: Vec<String>,
pub passthrough: Vec<String>,
pub cfg_icons_raw: String,
pub profile_themes: std::collections::HashMap<String, String>,
pub runbooks: std::collections::HashMap<String, String>,
pub safety_envs: std::collections::HashMap<String, bool>,
pub safety_accounts: std::collections::HashMap<String, bool>,
pub accounts: std::collections::HashMap<String, crate::config::AccountSpec>,
pub base_theme_name: String,
}