Dashboard: a grid of widgets, what fires, what is up, and the stream
DashboardGrid lays widgets on columns: a tile's strip drags it and its corner resizes it a cell at a time, others move down and rise to fill gaps, a focused tile steps with the arrows, and under the theme's stack_below width the tiles stand in one column. DashboardCard frames a widget. AlertList acknowledges a firing alert from its row and opens one on Enter; IncidentCard shows an incident's gravity, phase and updates. UptimeBar, ServiceStatus and HealthCheck show what is up, a check's detail wrapping under its name when narrow. EventStream keeps kinds and holds the list while new events count up. RefreshIntervalSelector and DashboardFilterBar set the window, the filters and the refresh. StatCard, MetricsChart, ResourceGauge, LogStream, TraceWaterfall, SystemMonitor, ProcessTable and NetworkGraph point to their homes; the maps wait for T41.
This commit is contained in:
1 parent
f3332f935b
commit
9fddf4b6bf
17 files changed
+2661
-18
No files matched your search
@@ -0,0 +1,316 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, IntoElement, ParentElement, RenderOnce, SharedString, Styled, Window, div,
|
||||
};
|
||||
use jiff::Timestamp;
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant, IconButton},
|
||||
forms::{Choice, Select},
|
||||
primitives::IconName,
|
||||
theme::{ActiveTheme, TextSize},
|
||||
typography::format,
|
||||
};
|
||||
|
||||
/// How often a refresh may come, in seconds, with its words; none is off.
|
||||
const EVERY: [(u32, &str); 7] = [
|
||||
(0, "Off"),
|
||||
(5, "5s"),
|
||||
(10, "10s"),
|
||||
(30, "30s"),
|
||||
(60, "1m"),
|
||||
(300, "5m"),
|
||||
(900, "15m"),
|
||||
];
|
||||
|
||||
type OnInterval = Rc<dyn Fn(Option<u32>, &mut Window, &mut App)>;
|
||||
type Run = Rc<dyn Fn(&mut Window, &mut App)>;
|
||||
|
||||
/// How often a dashboard refreshes, from off to every fifteen minutes, beside a button that refreshes it now and when it last did. The owner runs the timer.
|
||||
#[derive(IntoElement)]
|
||||
pub struct RefreshIntervalSelector {
|
||||
id: ElementId,
|
||||
every: Option<u32>,
|
||||
last: Timestamp,
|
||||
now: Timestamp,
|
||||
on_change: Option<OnInterval>,
|
||||
on_refresh: Option<Run>,
|
||||
}
|
||||
|
||||
impl RefreshIntervalSelector {
|
||||
/// `every` is in seconds, one the selector offers, or none for off; `last` is when it last refreshed.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
every: Option<u32>,
|
||||
last: Timestamp,
|
||||
now: Timestamp,
|
||||
) -> Self {
|
||||
if let Some(every) = every {
|
||||
assert!(
|
||||
EVERY
|
||||
.iter()
|
||||
.any(|(seconds, _)| *seconds == every && every > 0),
|
||||
"no refresh every {every}s"
|
||||
);
|
||||
}
|
||||
Self {
|
||||
id: id.into(),
|
||||
every,
|
||||
last,
|
||||
now,
|
||||
on_change: None,
|
||||
on_refresh: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_change(
|
||||
mut self,
|
||||
handler: impl Fn(Option<u32>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_change = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_refresh(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_refresh = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The seconds a choice of the selector names; its 0 is Off.
|
||||
pub(crate) fn interval(value: &str) -> Option<u32> {
|
||||
let seconds: u32 = value.parse().expect("an interval the select offers");
|
||||
(seconds > 0).then_some(seconds)
|
||||
}
|
||||
|
||||
impl RenderOnce for RefreshIntervalSelector {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let id = self.id;
|
||||
let on_change = self
|
||||
.on_change
|
||||
.unwrap_or_else(|| panic!("refresh interval selector {id:?} has no on_change"));
|
||||
let on_refresh = self
|
||||
.on_refresh
|
||||
.unwrap_or_else(|| panic!("refresh interval selector {id:?} has no on_refresh"));
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
IconButton::new((id.clone(), "now"), IconName::RefreshCw)
|
||||
.tooltip("Refresh now")
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("refresh: now");
|
||||
on_refresh(window, cx);
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div().w(theme.label_width()).child(
|
||||
Select::new(
|
||||
(id, "every"),
|
||||
EVERY
|
||||
.iter()
|
||||
.map(|(seconds, words)| Choice::new(seconds.to_string(), *words)),
|
||||
)
|
||||
.selected(self.every.unwrap_or(0).to_string())
|
||||
.on_change(move |value, window, cx| {
|
||||
let every = interval(value);
|
||||
log::info!("refresh: every {every:?}");
|
||||
on_change(every, window, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(format!("Updated {}", format::relative(self.last, self.now))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// A window of time a dashboard shows, back from now.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TimeWindow {
|
||||
Minutes15,
|
||||
Hour,
|
||||
Hours6,
|
||||
Day,
|
||||
Week,
|
||||
Month,
|
||||
}
|
||||
|
||||
impl TimeWindow {
|
||||
pub const ALL: [TimeWindow; 6] = [
|
||||
TimeWindow::Minutes15,
|
||||
TimeWindow::Hour,
|
||||
TimeWindow::Hours6,
|
||||
TimeWindow::Day,
|
||||
TimeWindow::Week,
|
||||
TimeWindow::Month,
|
||||
];
|
||||
|
||||
pub fn words(self) -> &'static str {
|
||||
match self {
|
||||
TimeWindow::Minutes15 => "Last 15 minutes",
|
||||
TimeWindow::Hour => "Last hour",
|
||||
TimeWindow::Hours6 => "Last 6 hours",
|
||||
TimeWindow::Day => "Last 24 hours",
|
||||
TimeWindow::Week => "Last 7 days",
|
||||
TimeWindow::Month => "Last 30 days",
|
||||
}
|
||||
}
|
||||
|
||||
/// How far back it reaches, in seconds.
|
||||
pub fn seconds(self) -> i64 {
|
||||
match self {
|
||||
TimeWindow::Minutes15 => 15 * 60,
|
||||
TimeWindow::Hour => 3_600,
|
||||
TimeWindow::Hours6 => 6 * 3_600,
|
||||
TimeWindow::Day => 86_400,
|
||||
TimeWindow::Week => 7 * 86_400,
|
||||
TimeWindow::Month => 30 * 86_400,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A filter the bar offers: its key, its name, its choices, and the one picked, if any.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DashboardFilter {
|
||||
pub key: SharedString,
|
||||
pub name: SharedString,
|
||||
pub choices: Vec<Choice>,
|
||||
pub picked: Option<SharedString>,
|
||||
}
|
||||
|
||||
type OnWindow = Rc<dyn Fn(TimeWindow, &mut Window, &mut App)>;
|
||||
type OnFilter = Rc<dyn Fn(&SharedString, Option<SharedString>, &mut Window, &mut App)>;
|
||||
|
||||
/// A dashboard's filters in a row that wraps: a window of time, a select for each filter showing its name until one is picked, and Clear while any is picked.
|
||||
#[derive(IntoElement)]
|
||||
pub struct DashboardFilterBar {
|
||||
id: ElementId,
|
||||
window: TimeWindow,
|
||||
filters: Vec<DashboardFilter>,
|
||||
on_window: Option<OnWindow>,
|
||||
on_filter: Option<OnFilter>,
|
||||
}
|
||||
|
||||
impl DashboardFilterBar {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
window: TimeWindow,
|
||||
filters: impl IntoIterator<Item = DashboardFilter>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
window,
|
||||
filters: filters.into_iter().collect(),
|
||||
on_window: None,
|
||||
on_filter: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_window(
|
||||
mut self,
|
||||
handler: impl Fn(TimeWindow, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_window = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Gets a filter's key and what is picked in it now; Clear sends none for each one picked.
|
||||
pub fn on_filter(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, Option<SharedString>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_filter = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for DashboardFilterBar {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let id = self.id;
|
||||
let theme = cx.theme();
|
||||
let on_window = self
|
||||
.on_window
|
||||
.unwrap_or_else(|| panic!("dashboard filter bar {id:?} has no on_window"));
|
||||
let on_filter = self
|
||||
.on_filter
|
||||
.unwrap_or_else(|| panic!("dashboard filter bar {id:?} has no on_filter"));
|
||||
let picked: Vec<SharedString> = self
|
||||
.filters
|
||||
.iter()
|
||||
.filter(|filter| filter.picked.is_some())
|
||||
.map(|filter| filter.key.clone())
|
||||
.collect();
|
||||
let selects = self.filters.iter().map(|filter| {
|
||||
let (key, on_filter) = (filter.key.clone(), on_filter.clone());
|
||||
let mut select = Select::new(
|
||||
(id.clone(), format!("filter-{}", filter.key)),
|
||||
filter.choices.clone(),
|
||||
)
|
||||
.placeholder(filter.name.clone())
|
||||
.on_change(move |value, window, cx| {
|
||||
log::info!("filter bar: {key} is {value}");
|
||||
on_filter(&key, Some(value.clone()), window, cx);
|
||||
});
|
||||
if let Some(value) = &filter.picked {
|
||||
select = select.selected(value.clone());
|
||||
}
|
||||
div().w(theme.label_width()).child(select)
|
||||
});
|
||||
let clear = (!picked.is_empty()).then(|| {
|
||||
let on_filter = on_filter.clone();
|
||||
Button::new((id.clone(), "clear"), "Clear")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("filter bar: clear");
|
||||
for key in &picked {
|
||||
on_filter(key, None, window, cx);
|
||||
}
|
||||
})
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div().w(theme.label_width()).child(
|
||||
Select::new(
|
||||
(id.clone(), "window"),
|
||||
TimeWindow::ALL
|
||||
.iter()
|
||||
.map(|window| Choice::new(window.words(), window.words())),
|
||||
)
|
||||
.selected(self.window.words())
|
||||
.on_change(move |value, window, cx| {
|
||||
let chosen = *TimeWindow::ALL
|
||||
.iter()
|
||||
.find(|each| each.words() == value.as_ref())
|
||||
.expect("a window the select offers");
|
||||
log::info!("filter bar: window {}", chosen.words());
|
||||
on_window(chosen, window, cx);
|
||||
}),
|
||||
),
|
||||
)
|
||||
.children(selects)
|
||||
.children(clear)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::interval;
|
||||
|
||||
#[test]
|
||||
fn off_is_no_interval() {
|
||||
assert_eq!(interval("0"), None);
|
||||
assert_eq!(interval("30"), Some(30));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user