mod metric;
mod render;
pub use metric::{AlertMetric, AlertState, AlertThreshold};
use std::marker::PhantomData;
use super::{Component, EventContext, RenderContext};
use crate::input::{Event, Key};
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum AlertPanelMessage {
UpdateMetric {
id: String,
value: f64,
},
AddMetric(AlertMetric),
RemoveMetric(String),
SetMetrics(Vec<AlertMetric>),
SelectNext,
SelectPrev,
SelectUp,
SelectDown,
SetColumns(usize),
Select,
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum AlertPanelOutput {
StateChanged {
id: String,
old: AlertState,
new_state: AlertState,
},
MetricSelected(String),
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(
feature = "serialization",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct AlertPanelState {
metrics: Vec<AlertMetric>,
columns: usize,
selected: Option<usize>,
title: Option<String>,
show_sparklines: bool,
show_thresholds: bool,
}
impl Default for AlertPanelState {
fn default() -> Self {
Self {
metrics: Vec::new(),
columns: 2,
selected: None,
title: None,
show_sparklines: true,
show_thresholds: false,
}
}
}
impl AlertPanelState {
pub fn new() -> Self {
Self::default()
}
pub fn with_metrics(mut self, metrics: Vec<AlertMetric>) -> Self {
self.selected = if metrics.is_empty() { None } else { Some(0) };
self.metrics = metrics;
self
}
pub fn with_columns(mut self, columns: usize) -> Self {
self.columns = columns.max(1);
self
}
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
pub fn with_show_sparklines(mut self, show: bool) -> Self {
self.show_sparklines = show;
self
}
pub fn with_show_thresholds(mut self, show: bool) -> Self {
self.show_thresholds = show;
self
}
pub fn metrics(&self) -> &[AlertMetric] {
&self.metrics
}
pub fn metrics_mut(&mut self) -> &mut Vec<AlertMetric> {
&mut self.metrics
}
pub fn columns(&self) -> usize {
self.columns
}
pub fn selected(&self) -> Option<usize> {
self.selected
}
pub fn title(&self) -> Option<&str> {
self.title.as_deref()
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.title = Some(title.into());
}
pub fn show_sparklines(&self) -> bool {
self.show_sparklines
}
pub fn show_thresholds(&self) -> bool {
self.show_thresholds
}
pub fn set_show_sparklines(&mut self, show: bool) {
self.show_sparklines = show;
}
pub fn set_show_thresholds(&mut self, show: bool) {
self.show_thresholds = show;
}
pub fn add_metric(&mut self, metric: AlertMetric) {
self.metrics.push(metric);
if self.selected.is_none() {
self.selected = Some(0);
}
}
pub fn update_metric(&mut self, id: &str, value: f64) -> Option<(AlertState, AlertState)> {
if let Some(metric) = self.metrics.iter_mut().find(|m| m.id == id) {
let old_state = metric.state.clone();
metric.update_value(value);
let new_state = metric.state.clone();
if old_state != new_state {
Some((old_state, new_state))
} else {
None
}
} else {
None
}
}
pub fn metric_by_id(&self, id: &str) -> Option<&AlertMetric> {
self.metrics.iter().find(|m| m.id == id)
}
pub fn ok_count(&self) -> usize {
self.metrics
.iter()
.filter(|m| m.state == AlertState::Ok)
.count()
}
pub fn warning_count(&self) -> usize {
self.metrics
.iter()
.filter(|m| m.state == AlertState::Warning)
.count()
}
pub fn critical_count(&self) -> usize {
self.metrics
.iter()
.filter(|m| m.state == AlertState::Critical)
.count()
}
pub fn unknown_count(&self) -> usize {
self.metrics
.iter()
.filter(|m| m.state == AlertState::Unknown)
.count()
}
pub fn selected_metric(&self) -> Option<&AlertMetric> {
self.metrics.get(self.selected?)
}
pub fn rows(&self) -> usize {
if self.metrics.is_empty() {
0
} else {
self.metrics.len().div_ceil(self.columns)
}
}
pub(crate) fn title_with_counts(&self) -> String {
let base = self.title.as_deref().unwrap_or("Alerts");
let ok = self.ok_count();
let warn = self.warning_count();
let crit = self.critical_count();
let unknown = self.unknown_count();
let mut parts = Vec::new();
if ok > 0 {
parts.push(format!("{} OK", ok));
}
if warn > 0 {
parts.push(format!("{} WARN", warn));
}
if crit > 0 {
parts.push(format!("{} CRIT", crit));
}
if unknown > 0 {
parts.push(format!("{} UNKNOWN", unknown));
}
if parts.is_empty() {
base.to_string()
} else {
format!("{} ({})", base, parts.join(", "))
}
}
pub fn update(&mut self, msg: AlertPanelMessage) -> Option<AlertPanelOutput> {
AlertPanel::update(self, msg)
}
}
pub struct AlertPanel(PhantomData<()>);
impl Component for AlertPanel {
type State = AlertPanelState;
type Message = AlertPanelMessage;
type Output = AlertPanelOutput;
fn init() -> Self::State {
AlertPanelState::default()
}
fn handle_event(
_state: &Self::State,
event: &Event,
ctx: &EventContext,
) -> Option<Self::Message> {
if !ctx.focused || ctx.disabled {
return None;
}
let key = event.as_key()?;
match key.code {
Key::Left | Key::Char('h') => Some(AlertPanelMessage::SelectPrev),
Key::Right | Key::Char('l') => Some(AlertPanelMessage::SelectNext),
Key::Up | Key::Char('k') => Some(AlertPanelMessage::SelectUp),
Key::Down | Key::Char('j') => Some(AlertPanelMessage::SelectDown),
Key::Enter => Some(AlertPanelMessage::Select),
_ => None,
}
}
fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
match msg {
AlertPanelMessage::UpdateMetric { id, value } => {
if let Some(metric) = state.metrics.iter_mut().find(|m| m.id == id) {
let old_state = metric.state.clone();
metric.update_value(value);
let new_state = metric.state.clone();
if old_state != new_state {
return Some(AlertPanelOutput::StateChanged {
id,
old: old_state,
new_state,
});
}
}
None
}
AlertPanelMessage::AddMetric(metric) => {
state.add_metric(metric);
None
}
AlertPanelMessage::RemoveMetric(id) => {
state.metrics.retain(|m| m.id != id);
if state.metrics.is_empty() {
state.selected = None;
} else if let Some(sel) = state.selected {
if sel >= state.metrics.len() {
state.selected = Some(state.metrics.len() - 1);
}
}
None
}
AlertPanelMessage::SetMetrics(metrics) => {
state.selected = if metrics.is_empty() { None } else { Some(0) };
state.metrics = metrics;
None
}
AlertPanelMessage::SelectNext => {
if state.metrics.is_empty() {
return None;
}
let current = state.selected.unwrap_or(0);
let cols = state.columns;
let current_col = current % cols;
if current_col < cols - 1 && current + 1 < state.metrics.len() {
let new_index = current + 1;
state.selected = Some(new_index);
Some(AlertPanelOutput::MetricSelected(
state.metrics[new_index].id.clone(),
))
} else {
None
}
}
AlertPanelMessage::SelectPrev => {
if state.metrics.is_empty() {
return None;
}
let current = state.selected.unwrap_or(0);
let current_col = current % state.columns;
if current_col > 0 {
let new_index = current - 1;
state.selected = Some(new_index);
Some(AlertPanelOutput::MetricSelected(
state.metrics[new_index].id.clone(),
))
} else {
None
}
}
AlertPanelMessage::SelectUp => {
if state.metrics.is_empty() {
return None;
}
let current = state.selected.unwrap_or(0);
let cols = state.columns;
let current_row = current / cols;
if current_row > 0 {
let new_index = (current_row - 1) * cols + (current % cols);
if new_index < state.metrics.len() {
state.selected = Some(new_index);
return Some(AlertPanelOutput::MetricSelected(
state.metrics[new_index].id.clone(),
));
}
}
None
}
AlertPanelMessage::SelectDown => {
if state.metrics.is_empty() {
return None;
}
let current = state.selected.unwrap_or(0);
let cols = state.columns;
let new_index = (current / cols + 1) * cols + (current % cols);
if new_index < state.metrics.len() {
state.selected = Some(new_index);
Some(AlertPanelOutput::MetricSelected(
state.metrics[new_index].id.clone(),
))
} else {
None
}
}
AlertPanelMessage::SetColumns(columns) => {
state.columns = columns.max(1);
None
}
AlertPanelMessage::Select => state
.selected_metric()
.map(|metric| AlertPanelOutput::MetricSelected(metric.id.clone())),
}
}
fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
render::render_alert_panel(
state,
ctx.frame,
ctx.area,
ctx.theme,
ctx.focused,
ctx.disabled,
);
}
}
#[cfg(test)]
mod snapshot_tests;
#[cfg(test)]
mod tests;