Skip to main content

envision/component/alert_panel/
mod.rs

1//! A threshold-based alert panel for metric monitoring.
2//!
3//! [`AlertPanel`] displays a grid of metrics, each with configurable
4//! thresholds that determine alert states (OK, Warning, Critical, Unknown).
5//! Each metric card shows a state indicator, current value with units, and
6//! an optional sparkline history. The title bar summarizes aggregate counts.
7//!
8//! State is stored in [`AlertPanelState`], updated via [`AlertPanelMessage`],
9//! and produces [`AlertPanelOutput`].
10//!
11//!
12//! # Example
13//!
14//! ```rust
15//! use envision::component::{
16//!     AlertPanel, AlertPanelState, AlertMetric, AlertThreshold, AlertState,
17//!     Component,
18//! };
19//!
20//! let metrics = vec![
21//!     AlertMetric::new("cpu", "CPU Usage", AlertThreshold::new(70.0, 90.0))
22//!         .with_units("%")
23//!         .with_value(45.0),
24//!     AlertMetric::new("mem", "Memory", AlertThreshold::new(80.0, 95.0))
25//!         .with_units("%")
26//!         .with_value(82.0),
27//! ];
28//!
29//! let state = AlertPanelState::new()
30//!     .with_metrics(metrics)
31//!     .with_columns(2);
32//!
33//! assert_eq!(state.metrics().len(), 2);
34//! assert_eq!(state.ok_count(), 1);
35//! assert_eq!(state.warning_count(), 1);
36//! ```
37
38mod metric;
39mod render;
40
41pub use metric::{AlertMetric, AlertState, AlertThreshold};
42
43use std::marker::PhantomData;
44
45use super::{Component, EventContext, RenderContext};
46use crate::input::{Event, Key};
47
48/// Messages that can be sent to an AlertPanel.
49///
50/// # Example
51///
52/// ```rust
53/// use envision::component::{
54///     AlertPanel, AlertPanelState, AlertPanelMessage, AlertMetric, AlertThreshold,
55///     Component,
56/// };
57///
58/// let mut state = AlertPanelState::new().with_metrics(vec![
59///     AlertMetric::new("cpu", "CPU", AlertThreshold::new(70.0, 90.0)).with_value(50.0),
60/// ]);
61/// let output = state.update(AlertPanelMessage::UpdateMetric {
62///     id: "cpu".into(),
63///     value: 80.0,
64/// });
65/// assert!(output.is_some());
66/// ```
67#[derive(Clone, Debug, PartialEq)]
68#[cfg_attr(
69    feature = "serialization",
70    derive(serde::Serialize, serde::Deserialize)
71)]
72pub enum AlertPanelMessage {
73    /// Update a metric's value by id.
74    UpdateMetric {
75        /// The metric identifier.
76        id: String,
77        /// The new value.
78        value: f64,
79    },
80    /// Add a new metric.
81    AddMetric(AlertMetric),
82    /// Remove a metric by id.
83    RemoveMetric(String),
84    /// Replace all metrics.
85    SetMetrics(Vec<AlertMetric>),
86    /// Select the next metric.
87    SelectNext,
88    /// Select the previous metric.
89    SelectPrev,
90    /// Navigate up in the grid.
91    SelectUp,
92    /// Navigate down in the grid.
93    SelectDown,
94    /// Set the number of grid columns.
95    SetColumns(usize),
96    /// Confirm selection of the current metric.
97    Select,
98}
99
100/// Output messages from an AlertPanel.
101///
102/// # Example
103///
104/// ```rust
105/// use envision::component::{AlertPanelOutput, AlertState};
106///
107/// let output = AlertPanelOutput::StateChanged {
108///     id: "cpu".into(),
109///     old: AlertState::Ok,
110///     new_state: AlertState::Warning,
111/// };
112/// assert!(matches!(output, AlertPanelOutput::StateChanged { .. }));
113/// ```
114#[derive(Clone, Debug, PartialEq)]
115#[cfg_attr(
116    feature = "serialization",
117    derive(serde::Serialize, serde::Deserialize)
118)]
119pub enum AlertPanelOutput {
120    /// A metric changed alert state.
121    StateChanged {
122        /// The metric identifier.
123        id: String,
124        /// The previous alert state.
125        old: AlertState,
126        /// The new alert state.
127        new_state: AlertState,
128    },
129    /// A metric was selected (Enter pressed).
130    MetricSelected(String),
131}
132
133/// State for the AlertPanel component.
134///
135/// Contains the metrics, layout configuration, and navigation state.
136///
137/// # Example
138///
139/// ```rust
140/// use envision::component::{
141///     AlertPanelState, AlertMetric, AlertThreshold,
142/// };
143///
144/// let state = AlertPanelState::new()
145///     .with_metrics(vec![
146///         AlertMetric::new("cpu", "CPU", AlertThreshold::new(70.0, 90.0))
147///             .with_value(45.0),
148///     ])
149///     .with_columns(2)
150///     .with_title("Alerts");
151///
152/// assert_eq!(state.metrics().len(), 1);
153/// assert_eq!(state.ok_count(), 1);
154/// ```
155#[derive(Clone, Debug, PartialEq)]
156#[cfg_attr(
157    feature = "serialization",
158    derive(serde::Serialize, serde::Deserialize)
159)]
160pub struct AlertPanelState {
161    /// The alert metrics.
162    metrics: Vec<AlertMetric>,
163    /// Number of columns in the grid layout.
164    columns: usize,
165    /// Currently selected metric index.
166    selected: Option<usize>,
167    /// Optional title.
168    title: Option<String>,
169    /// Whether to show sparkline history.
170    show_sparklines: bool,
171    /// Whether to show threshold values.
172    show_thresholds: bool,
173}
174
175impl Default for AlertPanelState {
176    fn default() -> Self {
177        Self {
178            metrics: Vec::new(),
179            columns: 2,
180            selected: None,
181            title: None,
182            show_sparklines: true,
183            show_thresholds: false,
184        }
185    }
186}
187
188impl AlertPanelState {
189    /// Creates a new empty alert panel state.
190    ///
191    /// # Example
192    ///
193    /// ```rust
194    /// use envision::component::AlertPanelState;
195    ///
196    /// let state = AlertPanelState::new();
197    /// assert!(state.metrics().is_empty());
198    /// assert_eq!(state.columns(), 2);
199    /// ```
200    pub fn new() -> Self {
201        Self::default()
202    }
203
204    /// Sets the initial metrics (builder pattern).
205    ///
206    /// # Example
207    ///
208    /// ```rust
209    /// use envision::component::{AlertPanelState, AlertMetric, AlertThreshold};
210    ///
211    /// let state = AlertPanelState::new().with_metrics(vec![
212    ///     AlertMetric::new("cpu", "CPU", AlertThreshold::new(70.0, 90.0)),
213    /// ]);
214    /// assert_eq!(state.metrics().len(), 1);
215    /// ```
216    pub fn with_metrics(mut self, metrics: Vec<AlertMetric>) -> Self {
217        self.selected = if metrics.is_empty() { None } else { Some(0) };
218        self.metrics = metrics;
219        self
220    }
221
222    /// Sets the number of grid columns (builder pattern).
223    ///
224    /// # Example
225    ///
226    /// ```rust
227    /// use envision::component::AlertPanelState;
228    ///
229    /// let state = AlertPanelState::new().with_columns(3);
230    /// assert_eq!(state.columns(), 3);
231    /// ```
232    pub fn with_columns(mut self, columns: usize) -> Self {
233        self.columns = columns.max(1);
234        self
235    }
236
237    /// Sets the title (builder pattern).
238    ///
239    /// # Example
240    ///
241    /// ```rust
242    /// use envision::component::AlertPanelState;
243    ///
244    /// let state = AlertPanelState::new().with_title("System Alerts");
245    /// assert_eq!(state.title(), Some("System Alerts"));
246    /// ```
247    pub fn with_title(mut self, title: impl Into<String>) -> Self {
248        self.title = Some(title.into());
249        self
250    }
251
252    /// Sets whether to show sparklines (builder pattern).
253    ///
254    /// # Example
255    ///
256    /// ```rust
257    /// use envision::component::AlertPanelState;
258    ///
259    /// let state = AlertPanelState::new().with_show_sparklines(false);
260    /// assert!(!state.show_sparklines());
261    /// ```
262    pub fn with_show_sparklines(mut self, show: bool) -> Self {
263        self.show_sparklines = show;
264        self
265    }
266
267    /// Sets whether to show threshold values (builder pattern).
268    ///
269    /// # Example
270    ///
271    /// ```rust
272    /// use envision::component::AlertPanelState;
273    ///
274    /// let state = AlertPanelState::new().with_show_thresholds(true);
275    /// assert!(state.show_thresholds());
276    /// ```
277    pub fn with_show_thresholds(mut self, show: bool) -> Self {
278        self.show_thresholds = show;
279        self
280    }
281
282    // ---- Accessors ----
283
284    /// Returns the metrics.
285    ///
286    /// # Example
287    ///
288    /// ```rust
289    /// use envision::component::{AlertPanelState, AlertMetric, AlertThreshold};
290    ///
291    /// let state = AlertPanelState::new().with_metrics(vec![
292    ///     AlertMetric::new("cpu", "CPU", AlertThreshold::new(70.0, 90.0)),
293    /// ]);
294    /// assert_eq!(state.metrics().len(), 1);
295    /// ```
296    pub fn metrics(&self) -> &[AlertMetric] {
297        &self.metrics
298    }
299
300    /// Returns a mutable reference to the alert metrics.
301    ///
302    /// This is safe because metrics are simple data containers.
303    /// Selection state is index-based and unaffected by value mutation.
304    ///
305    /// # Example
306    ///
307    /// ```rust
308    /// use envision::component::{AlertPanelState, AlertMetric, AlertThreshold};
309    ///
310    /// let mut state = AlertPanelState::new().with_metrics(vec![
311    ///     AlertMetric::new("cpu", "CPU", AlertThreshold::new(70.0, 90.0))
312    ///         .with_value(50.0),
313    /// ]);
314    /// state.metrics_mut()[0].update_value(85.0);
315    /// assert_eq!(state.metrics()[0].value(), 85.0);
316    /// ```
317    pub fn metrics_mut(&mut self) -> &mut Vec<AlertMetric> {
318        &mut self.metrics
319    }
320
321    /// Returns the number of grid columns.
322    ///
323    /// # Example
324    ///
325    /// ```rust
326    /// use envision::component::AlertPanelState;
327    ///
328    /// let state = AlertPanelState::new().with_columns(4);
329    /// assert_eq!(state.columns(), 4);
330    /// ```
331    pub fn columns(&self) -> usize {
332        self.columns
333    }
334
335    /// Returns the selected metric index.
336    ///
337    /// # Example
338    ///
339    /// ```rust
340    /// use envision::component::{AlertPanelState, AlertMetric, AlertThreshold};
341    ///
342    /// let state = AlertPanelState::new().with_metrics(vec![
343    ///     AlertMetric::new("cpu", "CPU", AlertThreshold::new(70.0, 90.0)),
344    /// ]);
345    /// assert_eq!(state.selected(), Some(0));
346    /// ```
347    pub fn selected(&self) -> Option<usize> {
348        self.selected
349    }
350
351    /// Returns the title.
352    ///
353    /// # Example
354    ///
355    /// ```rust
356    /// use envision::component::AlertPanelState;
357    ///
358    /// let state = AlertPanelState::new().with_title("Alerts");
359    /// assert_eq!(state.title(), Some("Alerts"));
360    /// ```
361    pub fn title(&self) -> Option<&str> {
362        self.title.as_deref()
363    }
364
365    /// Sets the title.
366    ///
367    /// # Example
368    ///
369    /// ```rust
370    /// use envision::component::AlertPanelState;
371    ///
372    /// let mut state = AlertPanelState::new();
373    /// state.set_title("System Alerts");
374    /// assert_eq!(state.title(), Some("System Alerts"));
375    /// ```
376    pub fn set_title(&mut self, title: impl Into<String>) {
377        self.title = Some(title.into());
378    }
379
380    /// Returns whether sparklines are shown.
381    ///
382    /// # Example
383    ///
384    /// ```rust
385    /// use envision::component::AlertPanelState;
386    ///
387    /// let state = AlertPanelState::new();
388    /// assert!(state.show_sparklines());
389    /// ```
390    pub fn show_sparklines(&self) -> bool {
391        self.show_sparklines
392    }
393
394    /// Returns whether threshold values are shown.
395    ///
396    /// # Example
397    ///
398    /// ```rust
399    /// use envision::component::AlertPanelState;
400    ///
401    /// let state = AlertPanelState::new();
402    /// assert!(!state.show_thresholds());
403    /// ```
404    pub fn show_thresholds(&self) -> bool {
405        self.show_thresholds
406    }
407
408    /// Sets whether sparklines are shown.
409    ///
410    /// # Example
411    ///
412    /// ```rust
413    /// use envision::component::AlertPanelState;
414    ///
415    /// let mut state = AlertPanelState::new();
416    /// state.set_show_sparklines(false);
417    /// assert!(!state.show_sparklines());
418    /// ```
419    pub fn set_show_sparklines(&mut self, show: bool) {
420        self.show_sparklines = show;
421    }
422
423    /// Sets whether threshold values are shown.
424    ///
425    /// # Example
426    ///
427    /// ```rust
428    /// use envision::component::AlertPanelState;
429    ///
430    /// let mut state = AlertPanelState::new();
431    /// state.set_show_thresholds(true);
432    /// assert!(state.show_thresholds());
433    /// ```
434    pub fn set_show_thresholds(&mut self, show: bool) {
435        self.show_thresholds = show;
436    }
437
438    /// Adds a metric to the panel.
439    ///
440    /// # Example
441    ///
442    /// ```rust
443    /// use envision::component::{AlertPanelState, AlertMetric, AlertThreshold};
444    ///
445    /// let mut state = AlertPanelState::new();
446    /// state.add_metric(
447    ///     AlertMetric::new("cpu", "CPU", AlertThreshold::new(70.0, 90.0))
448    /// );
449    /// assert_eq!(state.metrics().len(), 1);
450    /// assert_eq!(state.selected(), Some(0));
451    /// ```
452    pub fn add_metric(&mut self, metric: AlertMetric) {
453        self.metrics.push(metric);
454        if self.selected.is_none() {
455            self.selected = Some(0);
456        }
457    }
458
459    /// Updates a metric's value by id.
460    ///
461    /// Returns `Some((old_state, new_state))` if the alert state changed,
462    /// or `None` if the state did not change or the metric was not found.
463    ///
464    /// # Example
465    ///
466    /// ```rust
467    /// use envision::component::{AlertPanelState, AlertMetric, AlertThreshold, AlertState};
468    ///
469    /// let mut state = AlertPanelState::new().with_metrics(vec![
470    ///     AlertMetric::new("cpu", "CPU", AlertThreshold::new(70.0, 90.0))
471    ///         .with_value(50.0),
472    /// ]);
473    /// let result = state.update_metric("cpu", 80.0);
474    /// assert_eq!(result, Some((AlertState::Ok, AlertState::Warning)));
475    /// ```
476    pub fn update_metric(&mut self, id: &str, value: f64) -> Option<(AlertState, AlertState)> {
477        if let Some(metric) = self.metrics.iter_mut().find(|m| m.id == id) {
478            let old_state = metric.state.clone();
479            metric.update_value(value);
480            let new_state = metric.state.clone();
481            if old_state != new_state {
482                Some((old_state, new_state))
483            } else {
484                None
485            }
486        } else {
487            None
488        }
489    }
490
491    /// Returns a reference to a metric by id.
492    ///
493    /// # Example
494    ///
495    /// ```rust
496    /// use envision::component::{AlertPanelState, AlertMetric, AlertThreshold};
497    ///
498    /// let state = AlertPanelState::new().with_metrics(vec![
499    ///     AlertMetric::new("cpu", "CPU", AlertThreshold::new(70.0, 90.0)),
500    /// ]);
501    /// assert!(state.metric_by_id("cpu").is_some());
502    /// assert!(state.metric_by_id("unknown").is_none());
503    /// ```
504    pub fn metric_by_id(&self, id: &str) -> Option<&AlertMetric> {
505        self.metrics.iter().find(|m| m.id == id)
506    }
507
508    /// Returns the count of metrics in OK state.
509    ///
510    /// # Example
511    ///
512    /// ```rust
513    /// use envision::component::{AlertPanelState, AlertMetric, AlertThreshold};
514    ///
515    /// let state = AlertPanelState::new().with_metrics(vec![
516    ///     AlertMetric::new("cpu", "CPU", AlertThreshold::new(70.0, 90.0))
517    ///         .with_value(50.0),
518    ///     AlertMetric::new("mem", "Memory", AlertThreshold::new(80.0, 95.0))
519    ///         .with_value(30.0),
520    /// ]);
521    /// assert_eq!(state.ok_count(), 2);
522    /// ```
523    pub fn ok_count(&self) -> usize {
524        self.metrics
525            .iter()
526            .filter(|m| m.state == AlertState::Ok)
527            .count()
528    }
529
530    /// Returns the count of metrics in Warning state.
531    ///
532    /// # Example
533    ///
534    /// ```rust
535    /// use envision::component::{AlertPanelState, AlertMetric, AlertThreshold};
536    ///
537    /// let state = AlertPanelState::new().with_metrics(vec![
538    ///     AlertMetric::new("cpu", "CPU", AlertThreshold::new(70.0, 90.0))
539    ///         .with_value(80.0),
540    /// ]);
541    /// assert_eq!(state.warning_count(), 1);
542    /// ```
543    pub fn warning_count(&self) -> usize {
544        self.metrics
545            .iter()
546            .filter(|m| m.state == AlertState::Warning)
547            .count()
548    }
549
550    /// Returns the count of metrics in Critical state.
551    ///
552    /// # Example
553    ///
554    /// ```rust
555    /// use envision::component::{AlertPanelState, AlertMetric, AlertThreshold};
556    ///
557    /// let state = AlertPanelState::new().with_metrics(vec![
558    ///     AlertMetric::new("cpu", "CPU", AlertThreshold::new(70.0, 90.0))
559    ///         .with_value(95.0),
560    /// ]);
561    /// assert_eq!(state.critical_count(), 1);
562    /// ```
563    pub fn critical_count(&self) -> usize {
564        self.metrics
565            .iter()
566            .filter(|m| m.state == AlertState::Critical)
567            .count()
568    }
569
570    /// Returns the count of metrics in Unknown state.
571    ///
572    /// # Example
573    ///
574    /// ```rust
575    /// use envision::component::AlertPanelState;
576    ///
577    /// let state = AlertPanelState::new();
578    /// assert_eq!(state.unknown_count(), 0);
579    /// ```
580    pub fn unknown_count(&self) -> usize {
581        self.metrics
582            .iter()
583            .filter(|m| m.state == AlertState::Unknown)
584            .count()
585    }
586
587    /// Returns a reference to the currently selected metric.
588    ///
589    /// # Example
590    ///
591    /// ```rust
592    /// use envision::component::{AlertPanelState, AlertMetric, AlertThreshold};
593    ///
594    /// let state = AlertPanelState::new().with_metrics(vec![
595    ///     AlertMetric::new("cpu", "CPU", AlertThreshold::new(70.0, 90.0)),
596    /// ]);
597    /// assert_eq!(state.selected_metric().unwrap().id(), "cpu");
598    /// ```
599    pub fn selected_metric(&self) -> Option<&AlertMetric> {
600        self.metrics.get(self.selected?)
601    }
602
603    /// Returns the number of rows in the grid.
604    pub fn rows(&self) -> usize {
605        if self.metrics.is_empty() {
606            0
607        } else {
608            self.metrics.len().div_ceil(self.columns)
609        }
610    }
611
612    /// Builds the title string with aggregate state counts.
613    pub(crate) fn title_with_counts(&self) -> String {
614        let base = self.title.as_deref().unwrap_or("Alerts");
615        let ok = self.ok_count();
616        let warn = self.warning_count();
617        let crit = self.critical_count();
618        let unknown = self.unknown_count();
619
620        let mut parts = Vec::new();
621        if ok > 0 {
622            parts.push(format!("{} OK", ok));
623        }
624        if warn > 0 {
625            parts.push(format!("{} WARN", warn));
626        }
627        if crit > 0 {
628            parts.push(format!("{} CRIT", crit));
629        }
630        if unknown > 0 {
631            parts.push(format!("{} UNKNOWN", unknown));
632        }
633
634        if parts.is_empty() {
635            base.to_string()
636        } else {
637            format!("{} ({})", base, parts.join(", "))
638        }
639    }
640
641    // ---- Instance methods ----
642
643    /// Updates the state with a message, returning any output.
644    ///
645    /// # Example
646    ///
647    /// ```rust
648    /// use envision::component::{
649    ///     AlertPanelState, AlertPanelMessage, AlertPanelOutput,
650    ///     AlertMetric, AlertThreshold,
651    /// };
652    ///
653    /// let mut state = AlertPanelState::new().with_metrics(vec![
654    ///     AlertMetric::new("cpu", "CPU", AlertThreshold::new(70.0, 90.0)),
655    /// ]);
656    /// let output = state.update(AlertPanelMessage::Select);
657    /// assert_eq!(output, Some(AlertPanelOutput::MetricSelected("cpu".into())));
658    /// ```
659    pub fn update(&mut self, msg: AlertPanelMessage) -> Option<AlertPanelOutput> {
660        AlertPanel::update(self, msg)
661    }
662}
663
664/// A threshold-based alert panel component.
665///
666/// Displays metrics in a grid layout with visual state indicators,
667/// sparkline history, and keyboard navigation.
668///
669/// # Key Bindings
670///
671/// - `Left` / `h` -- Move selection left
672/// - `Right` / `l` -- Move selection right
673/// - `Up` / `k` -- Move selection up
674/// - `Down` / `j` -- Move selection down
675/// - `Enter` -- Confirm selection
676pub struct AlertPanel(PhantomData<()>);
677
678impl Component for AlertPanel {
679    type State = AlertPanelState;
680    type Message = AlertPanelMessage;
681    type Output = AlertPanelOutput;
682
683    fn init() -> Self::State {
684        AlertPanelState::default()
685    }
686
687    fn handle_event(
688        _state: &Self::State,
689        event: &Event,
690        ctx: &EventContext,
691    ) -> Option<Self::Message> {
692        if !ctx.focused || ctx.disabled {
693            return None;
694        }
695
696        let key = event.as_key()?;
697
698        match key.code {
699            Key::Left | Key::Char('h') => Some(AlertPanelMessage::SelectPrev),
700            Key::Right | Key::Char('l') => Some(AlertPanelMessage::SelectNext),
701            Key::Up | Key::Char('k') => Some(AlertPanelMessage::SelectUp),
702            Key::Down | Key::Char('j') => Some(AlertPanelMessage::SelectDown),
703            Key::Enter => Some(AlertPanelMessage::Select),
704            _ => None,
705        }
706    }
707
708    fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
709        match msg {
710            AlertPanelMessage::UpdateMetric { id, value } => {
711                if let Some(metric) = state.metrics.iter_mut().find(|m| m.id == id) {
712                    let old_state = metric.state.clone();
713                    metric.update_value(value);
714                    let new_state = metric.state.clone();
715                    if old_state != new_state {
716                        return Some(AlertPanelOutput::StateChanged {
717                            id,
718                            old: old_state,
719                            new_state,
720                        });
721                    }
722                }
723                None
724            }
725            AlertPanelMessage::AddMetric(metric) => {
726                state.add_metric(metric);
727                None
728            }
729            AlertPanelMessage::RemoveMetric(id) => {
730                state.metrics.retain(|m| m.id != id);
731                if state.metrics.is_empty() {
732                    state.selected = None;
733                } else if let Some(sel) = state.selected {
734                    if sel >= state.metrics.len() {
735                        state.selected = Some(state.metrics.len() - 1);
736                    }
737                }
738                None
739            }
740            AlertPanelMessage::SetMetrics(metrics) => {
741                state.selected = if metrics.is_empty() { None } else { Some(0) };
742                state.metrics = metrics;
743                None
744            }
745            AlertPanelMessage::SelectNext => {
746                if state.metrics.is_empty() {
747                    return None;
748                }
749                let current = state.selected.unwrap_or(0);
750                let cols = state.columns;
751                let current_col = current % cols;
752                if current_col < cols - 1 && current + 1 < state.metrics.len() {
753                    let new_index = current + 1;
754                    state.selected = Some(new_index);
755                    Some(AlertPanelOutput::MetricSelected(
756                        state.metrics[new_index].id.clone(),
757                    ))
758                } else {
759                    None
760                }
761            }
762            AlertPanelMessage::SelectPrev => {
763                if state.metrics.is_empty() {
764                    return None;
765                }
766                let current = state.selected.unwrap_or(0);
767                let current_col = current % state.columns;
768                if current_col > 0 {
769                    let new_index = current - 1;
770                    state.selected = Some(new_index);
771                    Some(AlertPanelOutput::MetricSelected(
772                        state.metrics[new_index].id.clone(),
773                    ))
774                } else {
775                    None
776                }
777            }
778            AlertPanelMessage::SelectUp => {
779                if state.metrics.is_empty() {
780                    return None;
781                }
782                let current = state.selected.unwrap_or(0);
783                let cols = state.columns;
784                let current_row = current / cols;
785                if current_row > 0 {
786                    let new_index = (current_row - 1) * cols + (current % cols);
787                    if new_index < state.metrics.len() {
788                        state.selected = Some(new_index);
789                        return Some(AlertPanelOutput::MetricSelected(
790                            state.metrics[new_index].id.clone(),
791                        ));
792                    }
793                }
794                None
795            }
796            AlertPanelMessage::SelectDown => {
797                if state.metrics.is_empty() {
798                    return None;
799                }
800                let current = state.selected.unwrap_or(0);
801                let cols = state.columns;
802                let new_index = (current / cols + 1) * cols + (current % cols);
803                if new_index < state.metrics.len() {
804                    state.selected = Some(new_index);
805                    Some(AlertPanelOutput::MetricSelected(
806                        state.metrics[new_index].id.clone(),
807                    ))
808                } else {
809                    None
810                }
811            }
812            AlertPanelMessage::SetColumns(columns) => {
813                state.columns = columns.max(1);
814                None
815            }
816            AlertPanelMessage::Select => state
817                .selected_metric()
818                .map(|metric| AlertPanelOutput::MetricSelected(metric.id.clone())),
819        }
820    }
821
822    fn view(state: &Self::State, ctx: &mut RenderContext<'_, '_>) {
823        render::render_alert_panel(
824            state,
825            ctx.frame,
826            ctx.area,
827            ctx.theme,
828            ctx.focused,
829            ctx.disabled,
830        );
831    }
832}
833
834#[cfg(test)]
835mod snapshot_tests;
836#[cfg(test)]
837mod tests;