use crate::aws::{Environment, Event as EbEvent, Instance, MetricSeries, WorkerQueues};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HealthItem {
Event { event_idx: usize },
Instance { instance_idx: usize },
MainQueue,
Dlq,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DetailTab {
Health,
Events,
Instances,
Metrics,
Queue,
Logs,
Config,
}
impl DetailTab {
pub fn title(self) -> &'static str {
match self {
Self::Health => "Health",
Self::Events => "Events",
Self::Instances => "Instances",
Self::Metrics => "Metrics",
Self::Queue => "Queue",
Self::Logs => "Logs",
Self::Config => "Config",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct LogTail {
pub by_instance: Vec<(String, String)>,
pub stage: LogTailStage,
pub poll_attempt: u32,
pub error: Option<String>,
pub search_input: String,
pub search_active: bool,
pub search_pattern: Option<regex::Regex>,
pub search_error: Option<String>,
pub scroll: u16,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LogTailStage {
#[default]
Idle,
Requesting,
Polling,
Fetching,
Ready,
}
pub struct DetailState {
pub env_name: String,
pub env_snapshot: Environment, pub tabs: Vec<DetailTab>,
pub tab_idx: usize,
pub events: Vec<EbEvent>,
pub instances: Vec<Instance>,
pub queues: WorkerQueues,
pub metrics: Vec<MetricSeries>,
pub metrics_range_secs: i64,
pub auto_refresh: bool,
pub search_input: String,
pub search_active: bool, pub search_pattern: Option<regex::Regex>,
pub search_error: Option<String>,
pub events_scroll: u16,
pub instances_scroll: u16,
pub tags: Vec<(String, String)>,
pub env_vars: Vec<(String, String)>,
pub cw_log_groups: Option<Vec<String>>,
pub loading_events: bool,
pub loading_instances: bool,
pub loading_queues: bool,
pub loading_metrics: bool,
pub loading_tags: bool,
pub loading_env_vars: bool,
pub error: Option<String>,
pub log_tail: LogTail,
pub queue_cursor: usize,
pub instances_cursor: usize,
pub instance_terminate_confirm: Option<usize>,
pub health_cursor: usize,
pub metrics_hover_col: Option<u16>,
pub metrics_body_rect: Option<ratatui::layout::Rect>,
}
impl DetailState {
pub fn tab(&self) -> DetailTab {
self.tabs[self.tab_idx]
}
}
pub fn health_items(detail: &DetailState, now: chrono::DateTime<chrono::Utc>) -> Vec<HealthItem> {
let mut out: Vec<HealthItem> = Vec::new();
let cutoff = now - chrono::Duration::minutes(30);
for (idx, e) in detail.events.iter().enumerate() {
if out
.iter()
.filter(|h| matches!(h, HealthItem::Event { .. }))
.count()
>= 10
{
break;
}
let sev = e.severity.to_uppercase();
if sev != "ERROR" && sev != "WARN" {
continue;
}
if !e.at.map(|t| t >= cutoff).unwrap_or(true) {
continue;
}
out.push(HealthItem::Event { event_idx: idx });
}
let mut shown = 0;
for (idx, i) in detail.instances.iter().enumerate() {
if shown >= 3 {
break;
}
let red = i.color.eq_ignore_ascii_case("Red") || i.health.eq_ignore_ascii_case("Severe");
if !red {
continue;
}
out.push(HealthItem::Instance { instance_idx: idx });
shown += 1;
}
if detail.env_snapshot.tier.eq_ignore_ascii_case("Worker") {
if detail.queues.main_stats.is_some() {
out.push(HealthItem::MainQueue);
}
if detail.queues.dlq_stats.is_some() {
out.push(HealthItem::Dlq);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::aws::{Environment, Event as EbEvent, Instance, QueueStats, WorkerQueues};
fn mk_env(tier: &str) -> Environment {
Environment {
name: "uflexi-prod".into(),
application: "uflexi".into(),
status: "Updating".into(),
health: "Red".into(),
platform: "Java 17".into(),
tier: tier.into(),
cname: String::new(),
version_label: String::new(),
arn: None,
updated: None,
id: None,
region: None,
}
}
fn mk_event(sev: &str, mins_ago: i64) -> EbEvent {
EbEvent {
at: Some(chrono::Utc::now() - chrono::Duration::minutes(mins_ago)),
env: "uflexi-prod".into(),
application: "uflexi".into(),
message: "test".into(),
severity: sev.into(),
}
}
fn mk_instance(id: &str, color: &str) -> Instance {
let health = match color {
"Red" => "Severe",
"Yellow" => "Warning",
_ => "Ok",
};
Instance {
id: id.into(),
health: health.into(),
color: color.into(),
causes: vec![],
instance_type: "t3.medium".into(),
availability_zone: "us-east-1a".into(),
launched_at: None,
}
}
fn empty_detail(tier: &str) -> DetailState {
DetailState {
env_name: "uflexi-prod".into(),
env_snapshot: mk_env(tier),
tabs: vec![DetailTab::Health],
tab_idx: 0,
events: vec![],
instances: vec![],
queues: WorkerQueues::default(),
metrics: vec![],
metrics_range_secs: 3600,
auto_refresh: false,
search_input: String::new(),
search_active: false,
search_pattern: None,
search_error: None,
events_scroll: 0,
instances_scroll: 0,
tags: vec![],
env_vars: vec![],
cw_log_groups: None,
loading_events: false,
loading_instances: false,
loading_queues: false,
loading_metrics: false,
loading_tags: false,
loading_env_vars: false,
error: None,
log_tail: LogTail::default(),
queue_cursor: 0,
instances_cursor: 0,
instance_terminate_confirm: None,
health_cursor: 0,
metrics_hover_col: None,
metrics_body_rect: None,
}
}
#[test]
fn health_items_includes_error_warn_events_only() {
let mut d = empty_detail("Web");
d.events = vec![
mk_event("ERROR", 1),
mk_event("INFO", 1), mk_event("WARN", 5),
mk_event("DEBUG", 1), ];
let items = health_items(&d, chrono::Utc::now());
assert_eq!(items.len(), 2);
assert!(matches!(items[0], HealthItem::Event { event_idx: 0 }));
assert!(matches!(items[1], HealthItem::Event { event_idx: 2 }));
}
#[test]
fn health_items_excludes_events_older_than_30_min() {
let mut d = empty_detail("Web");
d.events = vec![
mk_event("ERROR", 5), mk_event("ERROR", 45), ];
let items = health_items(&d, chrono::Utc::now());
assert_eq!(items.len(), 1);
}
#[test]
fn health_items_only_severe_instances_up_to_3() {
let mut d = empty_detail("Web");
d.instances = vec![
mk_instance("i-1", "Red"),
mk_instance("i-2", "Green"), mk_instance("i-3", "Red"),
mk_instance("i-4", "Red"),
mk_instance("i-5", "Red"), ];
let items = health_items(&d, chrono::Utc::now());
assert_eq!(items.len(), 3);
assert!(matches!(items[0], HealthItem::Instance { instance_idx: 0 }));
assert!(matches!(items[1], HealthItem::Instance { instance_idx: 2 }));
assert!(matches!(items[2], HealthItem::Instance { instance_idx: 3 }));
}
#[test]
fn health_items_includes_worker_queues_when_populated() {
let mut d = empty_detail("Worker");
d.queues = WorkerQueues {
main_url: Some("https://sqs/main".into()),
dlq_url: Some("https://sqs/dlq".into()),
main_stats: Some(QueueStats::default()),
dlq_stats: Some(QueueStats::default()),
};
let items = health_items(&d, chrono::Utc::now());
assert_eq!(items.len(), 2);
assert_eq!(items[0], HealthItem::MainQueue);
assert_eq!(items[1], HealthItem::Dlq);
}
#[test]
fn health_items_skips_queues_for_web_tier() {
let mut d = empty_detail("Web");
d.queues = WorkerQueues {
main_url: None,
dlq_url: None,
main_stats: Some(QueueStats::default()),
dlq_stats: Some(QueueStats::default()),
};
let items = health_items(&d, chrono::Utc::now());
assert!(items.is_empty());
}
#[test]
fn health_items_render_order_matches_section_order() {
let mut d = empty_detail("Worker");
d.events = vec![mk_event("ERROR", 1)];
d.instances = vec![mk_instance("i-1", "Red")];
d.queues = WorkerQueues {
main_url: Some("https://sqs/main".into()),
dlq_url: Some("https://sqs/dlq".into()),
main_stats: Some(QueueStats::default()),
dlq_stats: Some(QueueStats::default()),
};
let items = health_items(&d, chrono::Utc::now());
assert!(matches!(items[0], HealthItem::Event { .. }));
assert!(matches!(items[1], HealthItem::Instance { .. }));
assert_eq!(items[2], HealthItem::MainQueue);
assert_eq!(items[3], HealthItem::Dlq);
}
}