envision/component/metrics_dashboard/
mod.rs1use std::marker::PhantomData;
27
28use ratatui::prelude::*;
29use ratatui::widgets::{Block, Borders, Paragraph, Sparkline};
30
31use super::{Component, Focusable};
32use crate::input::{Event, KeyCode};
33use crate::theme::Theme;
34
35#[derive(Clone, Debug, PartialEq)]
37pub enum MetricKind {
38 Counter {
40 value: i64,
42 },
43 Gauge {
45 value: u64,
47 max: u64,
49 },
50 Status {
52 up: bool,
54 },
55 Text {
57 text: String,
59 },
60}
61
62#[derive(Clone, Debug, PartialEq)]
64pub struct MetricWidget {
65 label: String,
67 kind: MetricKind,
69 history: Vec<u64>,
71 max_history: usize,
73}
74
75impl MetricWidget {
76 pub fn counter(label: impl Into<String>, value: i64) -> Self {
88 Self {
89 label: label.into(),
90 kind: MetricKind::Counter { value },
91 history: Vec::new(),
92 max_history: 20,
93 }
94 }
95
96 pub fn gauge(label: impl Into<String>, value: u64, max: u64) -> Self {
107 Self {
108 label: label.into(),
109 kind: MetricKind::Gauge { value, max },
110 history: Vec::new(),
111 max_history: 20,
112 }
113 }
114
115 pub fn status(label: impl Into<String>, up: bool) -> Self {
117 Self {
118 label: label.into(),
119 kind: MetricKind::Status { up },
120 history: Vec::new(),
121 max_history: 0,
122 }
123 }
124
125 pub fn text(label: impl Into<String>, text: impl Into<String>) -> Self {
127 Self {
128 label: label.into(),
129 kind: MetricKind::Text { text: text.into() },
130 history: Vec::new(),
131 max_history: 0,
132 }
133 }
134
135 pub fn with_max_history(mut self, max: usize) -> Self {
137 self.max_history = max;
138 self
139 }
140
141 pub fn label(&self) -> &str {
143 &self.label
144 }
145
146 pub fn kind(&self) -> &MetricKind {
148 &self.kind
149 }
150
151 pub fn history(&self) -> &[u64] {
153 &self.history
154 }
155
156 pub fn display_value(&self) -> String {
158 match &self.kind {
159 MetricKind::Counter { value } => value.to_string(),
160 MetricKind::Gauge { value, max } => format!("{}/{}", value, max),
161 MetricKind::Status { up } => {
162 if *up {
163 "UP".to_string()
164 } else {
165 "DOWN".to_string()
166 }
167 }
168 MetricKind::Text { text } => text.clone(),
169 }
170 }
171
172 pub fn set_counter_value(&mut self, value: i64) {
174 if let MetricKind::Counter { value: ref mut v } = self.kind {
175 *v = value;
176 if self.max_history > 0 {
177 self.history.push(value.unsigned_abs());
178 while self.history.len() > self.max_history {
179 self.history.remove(0);
180 }
181 }
182 }
183 }
184
185 pub fn set_gauge_value(&mut self, value: u64) {
187 if let MetricKind::Gauge {
188 value: ref mut v,
189 max,
190 } = self.kind
191 {
192 *v = value.min(max);
193 if self.max_history > 0 {
194 self.history.push(value);
195 while self.history.len() > self.max_history {
196 self.history.remove(0);
197 }
198 }
199 }
200 }
201
202 pub fn set_status(&mut self, up: bool) {
204 if let MetricKind::Status { up: ref mut u } = self.kind {
205 *u = up;
206 }
207 }
208
209 pub fn set_text(&mut self, text: impl Into<String>) {
211 if let MetricKind::Text { text: ref mut t } = self.kind {
212 *t = text.into();
213 }
214 }
215
216 pub fn increment(&mut self, amount: i64) {
218 if let MetricKind::Counter { ref mut value } = self.kind {
219 *value += amount;
220 if self.max_history > 0 {
221 self.history.push(value.unsigned_abs());
222 while self.history.len() > self.max_history {
223 self.history.remove(0);
224 }
225 }
226 }
227 }
228
229 pub fn gauge_percentage(&self) -> Option<f64> {
231 match &self.kind {
232 MetricKind::Gauge { value, max } if *max > 0 => Some(*value as f64 / *max as f64),
233 _ => None,
234 }
235 }
236}
237
238#[derive(Clone, Debug, PartialEq, Eq)]
240pub enum MetricsDashboardMessage {
241 Left,
243 Right,
245 Up,
247 Down,
249 First,
251 Last,
253 Select,
255}
256
257#[derive(Clone, Debug, PartialEq, Eq)]
259pub enum MetricsDashboardOutput {
260 SelectionChanged(usize),
262 Selected(usize),
264}
265
266#[derive(Clone, Debug, PartialEq)]
270pub struct MetricsDashboardState {
271 widgets: Vec<MetricWidget>,
273 columns: usize,
275 selected: Option<usize>,
277 focused: bool,
279 disabled: bool,
281 title: Option<String>,
283}
284
285impl Default for MetricsDashboardState {
286 fn default() -> Self {
287 Self {
288 widgets: Vec::new(),
289 columns: 3,
290 selected: None,
291 focused: false,
292 disabled: false,
293 title: None,
294 }
295 }
296}
297
298impl MetricsDashboardState {
299 pub fn new(widgets: Vec<MetricWidget>, columns: usize) -> Self {
314 let selected = if widgets.is_empty() { None } else { Some(0) };
315 Self {
316 widgets,
317 columns: columns.max(1),
318 selected,
319 focused: false,
320 disabled: false,
321 title: None,
322 }
323 }
324
325 pub fn with_title(mut self, title: impl Into<String>) -> Self {
327 self.title = Some(title.into());
328 self
329 }
330
331 pub fn with_disabled(mut self, disabled: bool) -> Self {
333 self.disabled = disabled;
334 self
335 }
336
337 pub fn widgets(&self) -> &[MetricWidget] {
341 &self.widgets
342 }
343
344 pub fn widgets_mut(&mut self) -> &mut [MetricWidget] {
346 &mut self.widgets
347 }
348
349 pub fn widget(&self, index: usize) -> Option<&MetricWidget> {
351 self.widgets.get(index)
352 }
353
354 pub fn widget_mut(&mut self, index: usize) -> Option<&mut MetricWidget> {
356 self.widgets.get_mut(index)
357 }
358
359 pub fn widget_count(&self) -> usize {
361 self.widgets.len()
362 }
363
364 pub fn columns(&self) -> usize {
366 self.columns
367 }
368
369 pub fn set_columns(&mut self, columns: usize) {
371 self.columns = columns.max(1);
372 }
373
374 pub fn rows(&self) -> usize {
376 if self.widgets.is_empty() {
377 0
378 } else {
379 self.widgets.len().div_ceil(self.columns)
380 }
381 }
382
383 pub fn selected_index(&self) -> Option<usize> {
385 self.selected
386 }
387
388 pub fn set_selected(&mut self, index: usize) {
406 if self.widgets.is_empty() {
407 return;
408 }
409 self.selected = Some(index.min(self.widgets.len() - 1));
410 }
411
412 pub fn selected_widget(&self) -> Option<&MetricWidget> {
414 self.widgets.get(self.selected?)
415 }
416
417 pub fn selected_position(&self) -> Option<(usize, usize)> {
419 let selected = self.selected?;
420 Some((selected / self.columns, selected % self.columns))
421 }
422
423 pub fn title(&self) -> Option<&str> {
425 self.title.as_deref()
426 }
427
428 pub fn set_title(&mut self, title: Option<String>) {
430 self.title = title;
431 }
432
433 pub fn is_empty(&self) -> bool {
435 self.widgets.is_empty()
436 }
437
438 pub fn is_focused(&self) -> bool {
442 self.focused
443 }
444
445 pub fn set_focused(&mut self, focused: bool) {
447 self.focused = focused;
448 }
449
450 pub fn is_disabled(&self) -> bool {
452 self.disabled
453 }
454
455 pub fn set_disabled(&mut self, disabled: bool) {
457 self.disabled = disabled;
458 }
459
460 pub fn handle_event(&self, event: &Event) -> Option<MetricsDashboardMessage> {
462 MetricsDashboard::handle_event(self, event)
463 }
464
465 pub fn dispatch_event(&mut self, event: &Event) -> Option<MetricsDashboardOutput> {
467 MetricsDashboard::dispatch_event(self, event)
468 }
469
470 pub fn update(&mut self, msg: MetricsDashboardMessage) -> Option<MetricsDashboardOutput> {
472 MetricsDashboard::update(self, msg)
473 }
474}
475
476pub struct MetricsDashboard(PhantomData<()>);
490
491impl Component for MetricsDashboard {
492 type State = MetricsDashboardState;
493 type Message = MetricsDashboardMessage;
494 type Output = MetricsDashboardOutput;
495
496 fn init() -> Self::State {
497 MetricsDashboardState::default()
498 }
499
500 fn handle_event(state: &Self::State, event: &Event) -> Option<Self::Message> {
501 if !state.focused || state.disabled {
502 return None;
503 }
504
505 let key = event.as_key()?;
506
507 match key.code {
508 KeyCode::Left | KeyCode::Char('h') => Some(MetricsDashboardMessage::Left),
509 KeyCode::Right | KeyCode::Char('l') => Some(MetricsDashboardMessage::Right),
510 KeyCode::Up | KeyCode::Char('k') => Some(MetricsDashboardMessage::Up),
511 KeyCode::Down | KeyCode::Char('j') => Some(MetricsDashboardMessage::Down),
512 KeyCode::Home => Some(MetricsDashboardMessage::First),
513 KeyCode::End => Some(MetricsDashboardMessage::Last),
514 KeyCode::Enter => Some(MetricsDashboardMessage::Select),
515 _ => None,
516 }
517 }
518
519 fn update(state: &mut Self::State, msg: Self::Message) -> Option<Self::Output> {
520 if state.disabled || state.widgets.is_empty() {
521 return None;
522 }
523
524 let len = state.widgets.len();
525 let cols = state.columns;
526 let current = state.selected.unwrap_or(0);
527 let current_row = current / cols;
528 let current_col = current % cols;
529
530 match msg {
531 MetricsDashboardMessage::Left => {
532 if current_col > 0 {
533 let new_index = current - 1;
534 state.selected = Some(new_index);
535 Some(MetricsDashboardOutput::SelectionChanged(new_index))
536 } else {
537 None
538 }
539 }
540 MetricsDashboardMessage::Right => {
541 if current_col < cols - 1 && current + 1 < len {
542 let new_index = current + 1;
543 state.selected = Some(new_index);
544 Some(MetricsDashboardOutput::SelectionChanged(new_index))
545 } else {
546 None
547 }
548 }
549 MetricsDashboardMessage::Up => {
550 if current_row > 0 {
551 let new_index = (current_row - 1) * cols + current_col;
552 if new_index < len {
553 state.selected = Some(new_index);
554 Some(MetricsDashboardOutput::SelectionChanged(new_index))
555 } else {
556 None
557 }
558 } else {
559 None
560 }
561 }
562 MetricsDashboardMessage::Down => {
563 let new_index = (current_row + 1) * cols + current_col;
564 if new_index < len {
565 state.selected = Some(new_index);
566 Some(MetricsDashboardOutput::SelectionChanged(new_index))
567 } else {
568 None
569 }
570 }
571 MetricsDashboardMessage::First => {
572 if current != 0 {
573 state.selected = Some(0);
574 Some(MetricsDashboardOutput::SelectionChanged(0))
575 } else {
576 None
577 }
578 }
579 MetricsDashboardMessage::Last => {
580 let last = len - 1;
581 if current != last {
582 state.selected = Some(last);
583 Some(MetricsDashboardOutput::SelectionChanged(last))
584 } else {
585 None
586 }
587 }
588 MetricsDashboardMessage::Select => Some(MetricsDashboardOutput::Selected(current)),
589 }
590 }
591
592 fn view(state: &Self::State, frame: &mut Frame, area: Rect, theme: &Theme) {
593 if state.widgets.is_empty() || area.height < 3 || area.width < 3 {
594 return;
595 }
596
597 let rows = state.rows();
598 let cols = state.columns;
599
600 let row_constraints: Vec<Constraint> = (0..rows)
602 .map(|_| Constraint::Ratio(1, rows as u32))
603 .collect();
604
605 let row_areas = Layout::default()
606 .direction(Direction::Vertical)
607 .constraints(row_constraints)
608 .split(area);
609
610 let col_constraints: Vec<Constraint> = (0..cols)
612 .map(|_| Constraint::Ratio(1, cols as u32))
613 .collect();
614
615 for (row_idx, row_area) in row_areas.iter().enumerate() {
616 let col_areas = Layout::default()
617 .direction(Direction::Horizontal)
618 .constraints(col_constraints.clone())
619 .split(*row_area);
620
621 for (col_idx, col_area) in col_areas.iter().enumerate() {
622 let widget_idx = row_idx * cols + col_idx;
623 if let Some(widget) = state.widgets.get(widget_idx) {
624 let is_selected = state.selected == Some(widget_idx);
625 render_widget(widget, is_selected, state, frame, *col_area, theme);
626 }
627 }
628 }
629 }
630}
631
632impl Focusable for MetricsDashboard {
633 fn is_focused(state: &Self::State) -> bool {
634 state.focused
635 }
636
637 fn set_focused(state: &mut Self::State, focused: bool) {
638 state.focused = focused;
639 }
640}
641
642fn render_widget(
644 widget: &MetricWidget,
645 is_selected: bool,
646 state: &MetricsDashboardState,
647 frame: &mut Frame,
648 area: Rect,
649 theme: &Theme,
650) {
651 let border_style = if state.disabled {
652 theme.disabled_style()
653 } else if is_selected && state.focused {
654 theme.focused_border_style()
655 } else {
656 theme.border_style()
657 };
658
659 let block = Block::default()
660 .title(widget.label())
661 .borders(Borders::ALL)
662 .border_style(border_style);
663
664 let inner = block.inner(area);
665 frame.render_widget(block, area);
666
667 if inner.height == 0 || inner.width == 0 {
668 return;
669 }
670
671 let value_style = if state.disabled {
672 theme.disabled_style()
673 } else {
674 value_color(widget, theme)
675 };
676
677 if !widget.history.is_empty() && inner.height >= 3 {
679 let chunks = Layout::default()
680 .direction(Direction::Vertical)
681 .constraints([Constraint::Length(1), Constraint::Min(1)])
682 .split(inner);
683
684 let value_text = widget.display_value();
686 let paragraph = Paragraph::new(value_text).style(value_style);
687 frame.render_widget(paragraph, chunks[0]);
688
689 let sparkline = Sparkline::default()
691 .data(&widget.history)
692 .style(value_style);
693 frame.render_widget(sparkline, chunks[1]);
694 } else {
695 let value_text = widget.display_value();
697 let paragraph = Paragraph::new(value_text)
698 .style(value_style)
699 .alignment(Alignment::Center);
700 frame.render_widget(paragraph, inner);
701 }
702}
703
704fn value_color(widget: &MetricWidget, theme: &Theme) -> Style {
706 match &widget.kind {
707 MetricKind::Counter { .. } => theme.info_style(),
708 MetricKind::Gauge { value, max } => {
709 let pct = if *max > 0 {
710 *value as f64 / *max as f64
711 } else {
712 0.0
713 };
714 if pct >= 0.9 {
715 theme.error_style()
716 } else if pct >= 0.7 {
717 theme.warning_style()
718 } else {
719 theme.success_style()
720 }
721 }
722 MetricKind::Status { up } => {
723 if *up {
724 theme.success_style()
725 } else {
726 theme.error_style()
727 }
728 }
729 MetricKind::Text { .. } => theme.normal_style(),
730 }
731}
732
733#[cfg(test)]
734mod tests;