use taba_client::json::Fields;
use taba_client::Error;
use crate::profile::ConfigFile;
use crate::tui::api::{
ApiCall, Backup, ClusterStatus, Drain, FlushReport, Me, Missing, Nodes, Severity, StopReport,
Token, Topic, TopicNumbers,
};
use crate::tui::editor::Editor;
use crate::tui::history::History;
use crate::tui::i18n::{self, Lang};
use crate::tui::prom::Metrics;
use crate::tui::theme::Theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tab {
Cluster,
Topics,
Tokens,
Profiles,
}
pub const TABS: [Tab; 4] = [Tab::Cluster, Tab::Topics, Tab::Tokens, Tab::Profiles];
impl Tab {
pub fn index(self) -> usize {
TABS.iter().position(|t| *t == self).unwrap_or(0)
}
pub fn name_key(self) -> &'static str {
match self {
Tab::Cluster => "tab.cluster",
Tab::Topics => "tab.topics",
Tab::Tokens => "tab.tokens",
Tab::Profiles => "tab.profiles",
}
}
}
#[derive(Debug, Clone)]
pub struct Options {
pub lang: Lang,
pub theme: Theme,
pub profile: String,
pub url: String,
pub retention_secs: u64,
pub interval_secs: u64,
pub profiles: ConfigFile,
pub choose_profile: bool,
}
pub fn short_id(id: &str) -> String {
let chars: Vec<char> = id.chars().collect();
if chars.len() <= 12 {
return id.to_owned();
}
let head: String = chars[..4].iter().collect();
let tail: String = chars[chars.len() - 6..].iter().collect();
format!("{head}…{tail}")
}
pub fn file_tag(id: &str) -> String {
let kept: Vec<char> = id.chars().filter(char::is_ascii_alphanumeric).collect();
kept[kept.len().saturating_sub(8)..].iter().collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Fail {
pub status: u16,
pub code: String,
pub message: String,
pub request_id: Option<String>,
pub retry_after: Option<u64>,
pub required: Option<String>,
}
impl Fail {
pub fn is_offline(&self) -> bool {
self.status == 0
}
pub fn is_denied(&self) -> bool {
self.status == 401 || self.status == 403
}
}
impl From<&Error> for Fail {
fn from(e: &Error) -> Fail {
let body = String::from_utf8_lossy(&e.body);
let required = Fields::parse(&body).ok().and_then(|f| f.string("required"));
Fail {
status: e.status,
code: e.code.clone(),
message: e.message.clone(),
request_id: e.request_id.clone(),
retry_after: e.retry_after,
required,
}
}
}
const MAX_BACKOFF_MS: u64 = 30_000;
#[derive(Debug, Clone, PartialEq)]
pub struct Feed<T> {
pub data: Option<T>,
pub fetched_at: Option<u64>,
pub error: Option<Fail>,
pub in_flight: Option<u64>,
pub next_at: u64,
pub failures: u32,
}
impl<T> Default for Feed<T> {
fn default() -> Feed<T> {
Feed {
data: None,
fetched_at: None,
error: None,
in_flight: None,
next_at: 0,
failures: 0,
}
}
}
impl<T> Feed<T> {
pub fn due(&self, now: u64) -> bool {
self.in_flight.is_none() && now >= self.next_at
}
pub fn ok(&mut self, now: u64, data: T, every_ms: u64) {
self.data = Some(data);
self.fetched_at = Some(now);
self.error = None;
self.in_flight = None;
self.failures = 0;
self.next_at = now + every_ms;
}
pub fn fail(&mut self, now: u64, fail: Fail, every_ms: u64) {
self.failures = self.failures.saturating_add(1);
let backoff = every_ms
.saturating_mul(1 << self.failures.min(5))
.min(MAX_BACKOFF_MS);
self.next_at = now
+ match fail.retry_after {
Some(secs) => secs.saturating_mul(1000),
None => backoff,
};
self.error = Some(fail);
self.in_flight = None;
}
pub fn is_stale(&self) -> bool {
self.data.is_some() && self.error.is_some()
}
pub fn retry_in(&self, now: u64) -> Option<u64> {
self.error.as_ref()?;
Some(self.next_at.saturating_sub(now).div_ceil(1000))
}
pub fn refresh_now(&mut self) {
self.next_at = 0;
}
pub fn reset(&mut self) {
*self = Feed::default();
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pending {
Drain(String),
Return(String),
FlushNode(String),
FlushAll,
Stop,
RevokeToken(String, String),
RemoveProfile(String),
DeleteTopic(String),
Prune(String),
}
impl Pending {
pub fn call(&self) -> Option<ApiCall> {
Some(match self {
Pending::Drain(id) => ApiCall::DrainStart(id.clone()),
Pending::Return(id) => ApiCall::DrainCancel(id.clone()),
Pending::FlushNode(id) => ApiCall::FlushNode(id.clone()),
Pending::FlushAll => ApiCall::FlushCluster,
Pending::Stop => ApiCall::StopCluster,
Pending::RevokeToken(id, _) => ApiCall::TokenRevoke(id.clone()),
Pending::DeleteTopic(name) => ApiCall::TopicDelete(name.clone()),
Pending::Prune(name) => ApiCall::Prune(name.clone()),
Pending::RemoveProfile(_) => return None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Typed {
pub expected: String,
pub input: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Confirm {
pub pending: Pending,
pub typed: Option<Typed>,
pub ok_focused: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Report {
Flush(FlushReport),
Stop(StopReport),
Failure { call: ApiCall, fail: Fail },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InputPurpose {
ExportCsv {
node: String,
series: usize,
},
TokenFilter,
TopicFilter,
ExportTopicCsv,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Input {
pub purpose: InputPurpose,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Menu {
pub title: String,
pub items: Vec<crate::tui::keymap::Action>,
pub selected: usize,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Modal {
Menu(Menu),
TopicSummary,
Message,
Help {
scroll: u16,
},
Palette {
query: String,
selected: usize,
},
Language {
selected: usize,
},
Confirm(Confirm),
Report(Report),
Input(Input),
Condition(String),
Profiles {
selected: usize,
},
Editor(Editor),
Secret(Secret),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Secret {
pub id: String,
pub name: String,
pub token: String,
pub shown_at: u64,
pub copied: bool,
pub saved: bool,
pub warned: bool,
pub replaces: Option<(String, String)>,
pub revoke_old: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Notice {
pub severity: Severity,
pub text: String,
pub until: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Goal {
Drained,
Active,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DrainWatch {
pub node: String,
pub goal: Goal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClusterView {
List,
Detail,
Monitor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pane {
Conditions,
Nodes,
}
pub const WINDOWS_MIN: [u64; 5] = [1, 5, 15, 30, 60];
#[derive(Debug, Clone, PartialEq)]
pub struct Monitor {
pub chart: usize,
pub window: usize,
pub frozen: Option<u64>,
pub fixed_scale: bool,
pub full: bool,
}
impl Default for Monitor {
fn default() -> Monitor {
Monitor {
chart: 0,
window: 1,
frozen: None,
fixed_scale: false,
full: false,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ClusterScreen {
pub view: ClusterView,
pub pane: Pane,
pub condition: usize,
pub node: Option<String>,
pub scroll: u16,
pub monitor: Monitor,
pub nodes: Feed<Nodes>,
pub metrics: Feed<Metrics>,
pub backup: Feed<Option<Backup>>,
pub drain: Feed<Drain>,
pub watch: Option<DrainWatch>,
}
impl Default for ClusterScreen {
fn default() -> ClusterScreen {
ClusterScreen {
view: ClusterView::List,
pane: Pane::Nodes,
condition: 0,
node: None,
scroll: 0,
monitor: Monitor::default(),
nodes: Feed::default(),
metrics: Feed::default(),
backup: Feed::default(),
drain: Feed::default(),
watch: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TokenSort {
NewestFirst,
OldestFirst,
Name,
Id,
}
impl TokenSort {
pub fn next(self) -> TokenSort {
match self {
TokenSort::NewestFirst => TokenSort::OldestFirst,
TokenSort::OldestFirst => TokenSort::Name,
TokenSort::Name => TokenSort::Id,
TokenSort::Id => TokenSort::NewestFirst,
}
}
pub fn key(self) -> &'static str {
match self {
TokenSort::NewestFirst => "sort.newest",
TokenSort::OldestFirst => "sort.oldest",
TokenSort::Name => "sort.name",
TokenSort::Id => "sort.id",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TokensScreen {
pub list: Feed<Vec<Token>>,
pub reading: Vec<Token>,
pub pages: u32,
pub selected: Option<String>,
pub filter: String,
pub sort: TokenSort,
}
impl Default for TokensScreen {
fn default() -> TokensScreen {
TokensScreen {
list: Feed::default(),
reading: Vec::new(),
pages: 0,
selected: None,
filter: String::new(),
sort: TokenSort::NewestFirst,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TopicSort {
Name,
Newest,
Oldest,
Depth,
}
impl TopicSort {
pub fn next(self) -> TopicSort {
match self {
TopicSort::Name => TopicSort::Newest,
TopicSort::Newest => TopicSort::Oldest,
TopicSort::Oldest => TopicSort::Depth,
TopicSort::Depth => TopicSort::Name,
}
}
pub fn key(self) -> &'static str {
match self {
TopicSort::Name => "sort.name",
TopicSort::Newest => "sort.newest",
TopicSort::Oldest => "sort.oldest",
TopicSort::Depth => "sort.depth",
}
}
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct TopicStats {
pub by_name: std::collections::HashMap<String, TopicNumbers>,
pub partial: bool,
pub missing: Vec<Missing>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TopicTab {
Charts,
Messages,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Received {
pub id: String,
pub group: Option<String>,
pub deliveries: u64,
pub size: usize,
pub content_type: String,
pub body: String,
pub raw: Vec<u8>,
pub receipt: String,
pub at: u64,
pub held_until: Option<u64>,
pub acked: bool,
}
pub const MAX_RECEIVED: usize = 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum BodyView {
#[default]
Sent,
Formatted,
Base64,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Messages {
pub rows: Vec<Received>,
pub selected: usize,
pub view: BodyView,
pub last: Option<(Severity, String)>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TopicMonitor {
pub name: String,
pub tab: TopicTab,
pub distributor: bool,
pub dead_letter: bool,
pub messages: Messages,
pub monitor: Monitor,
pub feed: Feed<()>,
pub series: std::collections::HashMap<String, crate::tui::history::Ring>,
pub groups: Vec<String>,
}
impl TopicMonitor {
pub fn new(name: String, tab: TopicTab, distributor: bool, dead_letter: bool) -> TopicMonitor {
TopicMonitor {
name,
tab,
distributor,
dead_letter,
messages: Messages::default(),
monitor: Monitor::default(),
feed: Feed::default(),
series: std::collections::HashMap::new(),
groups: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct TopicsScreen {
pub previews: Vec<TopicMonitor>,
pub show_dead: bool,
pub monitor: Option<TopicMonitor>,
pub list: Feed<Vec<Topic>>,
pub reading: Vec<Topic>,
pub pages: u32,
pub stats: Feed<TopicStats>,
pub stats_reading: TopicStats,
pub stats_pages: u32,
pub selected: Option<String>,
pub filter: String,
pub sort: TopicSort,
}
impl Default for TopicsScreen {
fn default() -> TopicsScreen {
TopicsScreen {
show_dead: false,
previews: Vec::new(),
monitor: None,
list: Feed::default(),
reading: Vec::new(),
pages: 0,
stats: Feed::default(),
stats_reading: TopicStats::default(),
stats_pages: 0,
selected: None,
filter: String::new(),
sort: TopicSort::Name,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProfilesScreen {
pub file: ConfigFile,
pub selected: Option<String>,
}
pub struct Model {
pub lang: Lang,
pub theme: Theme,
pub profile: String,
pub url: String,
pub tab: Tab,
pub size: (u16, u16),
pub now: u64,
pub wall_ms: i64,
pub paused: bool,
pub focused: bool,
pub quit: bool,
pub modal: Option<Modal>,
pub notice: Option<Notice>,
pub busy: Option<ApiCall>,
pub me: Feed<Me>,
pub status: Feed<ClusterStatus>,
pub status_denied: Option<Fail>,
pub cluster: ClusterScreen,
pub tokens: TokensScreen,
pub topics: TopicsScreen,
pub profiles: ProfilesScreen,
pub awaiting_profile: bool,
pub history: History,
pub next_id: u64,
pub generation: u64,
}
impl Model {
pub fn new(options: Options) -> Model {
let choose = options.choose_profile && options.profiles.profiles.len() > 1;
let create = options.choose_profile && options.profiles.profiles.is_empty();
let first = choose.then(|| {
let file = &options.profiles;
let selected = file
.default
.as_ref()
.and_then(|name| file.profiles.iter().position(|(n, _)| n == name))
.unwrap_or(0);
Modal::Profiles { selected }
});
let mut model = Model {
lang: options.lang,
theme: options.theme,
profile: options.profile,
url: options.url,
tab: Tab::Cluster,
size: (80, 24),
now: 0,
wall_ms: 0,
paused: false,
focused: true,
quit: false,
modal: first,
notice: None,
busy: None,
me: Feed::default(),
status: Feed::default(),
status_denied: None,
cluster: ClusterScreen::default(),
tokens: TokensScreen::default(),
topics: TopicsScreen::default(),
profiles: ProfilesScreen {
selected: options.profiles.default.clone(),
file: options.profiles,
},
awaiting_profile: choose || create,
history: History::new(options.retention_secs, options.interval_secs),
next_id: 1,
generation: 0,
};
if create {
crate::tui::screens::profiles::open_form(&mut model, None);
}
model
}
pub fn reconnect(&mut self, profile: String, url: String) {
self.profile = profile;
self.url = url;
self.generation += 1;
self.awaiting_profile = false;
self.busy = None;
self.me = Feed::default();
self.status = Feed::default();
self.status_denied = None;
self.cluster = ClusterScreen::default();
self.tokens = TokensScreen::default();
self.topics = TopicsScreen::default();
let retention = self.history.retention_ms() / 1000;
let interval = self.history.interval_ms() / 1000;
self.history = History::new(retention, interval);
}
pub fn id(&mut self) -> u64 {
let id = self.next_id;
self.next_id += 1;
id
}
pub fn tx(&self) -> Tx {
Tx(self.lang)
}
pub fn is_admin(&self) -> bool {
self.me.data.as_ref().is_some_and(Me::is_admin)
}
pub fn is_root(&self) -> bool {
self.me.data.as_ref().is_some_and(Me::is_root)
}
pub fn uses_status(&self) -> bool {
self.is_admin() && self.status_denied.is_none()
}
pub fn is_offline(&self) -> bool {
let feeds = [
self.me.error.as_ref(),
self.status.error.as_ref(),
self.cluster.nodes.error.as_ref(),
];
feeds.into_iter().flatten().any(Fail::is_offline)
}
pub fn health(&self) -> (Severity, String) {
if self.is_offline() {
return (Severity::Critical, "offline".to_owned());
}
if let Some(status) = &self.status.data {
let severity = status
.conditions
.iter()
.map(|c| c.severity)
.max()
.unwrap_or(Severity::Ok);
let word = if status.worst.is_empty() {
"ok"
} else {
&status.worst
};
return (severity, word.to_owned());
}
if let Some(nodes) = &self.cluster.nodes.data {
let bad = nodes
.items
.iter()
.any(|n| !n.reachable || !matches!(n.state.as_str(), "active" | ""));
if bad || nodes.partial {
return (Severity::Warning, "degraded".to_owned());
}
return (Severity::Ok, "ok".to_owned());
}
(Severity::Ok, "unknown".to_owned())
}
pub fn notify(&mut self, severity: Severity, text: String) {
self.notice = Some(Notice {
severity,
text,
until: self.now + 5000,
});
}
}
#[derive(Debug, Clone, Copy)]
pub struct Tx(pub Lang);
impl Tx {
pub fn t(self, key: &str) -> String {
i18n::tr(self.0, key)
}
pub fn tw(self, key: &str, args: &[(&str, &dyn std::fmt::Display)]) -> String {
i18n::tr_with(self.0, key, args)
}
pub fn tn(self, key: &str, n: u64) -> String {
i18n::tr_n(self.0, key, n)
}
}
#[cfg(test)]
mod tests;