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,163 @@
|
||||
use ely_gpui_component::{
|
||||
charts::{LineChart, Series},
|
||||
dashboard::{
|
||||
DashboardCard, DashboardFilter, DashboardFilterBar, DashboardGrid, RefreshIntervalSelector,
|
||||
Tile, TimeWindow,
|
||||
},
|
||||
data_display::{Gauge, Statistic, TrendIndicator},
|
||||
forms::Choice,
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, SharedString, Styled, Window, div, px};
|
||||
use jiff::Timestamp;
|
||||
|
||||
use crate::ui::{change, keep, section};
|
||||
|
||||
/// The board demo's tiles, window, filters and refresh.
|
||||
struct Wall {
|
||||
tiles: Vec<Tile>,
|
||||
window: TimeWindow,
|
||||
environment: Option<SharedString>,
|
||||
service: Option<SharedString>,
|
||||
every: Option<u32>,
|
||||
/// Seconds since the last refresh, at the demo's fixed clock.
|
||||
since: i64,
|
||||
}
|
||||
|
||||
impl Wall {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
tiles: vec![
|
||||
Tile::new("requests", (0, 0), (4, 2)),
|
||||
Tile::new("errors", (4, 0), (4, 2)),
|
||||
Tile::new("cpu", (8, 0), (4, 2)),
|
||||
Tile::new("latency", (0, 2), (8, 3)),
|
||||
Tile::new("saturation", (8, 2), (4, 3)),
|
||||
],
|
||||
window: TimeWindow::Hour,
|
||||
environment: Some("production".into()),
|
||||
service: None,
|
||||
every: Some(30),
|
||||
since: 40,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn grid(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let wall = keep("dashboard-wall", Wall::new, window, cx);
|
||||
let now = wall.read(cx);
|
||||
let (tiles, time, environment, service, every, since) = (
|
||||
now.tiles.clone(),
|
||||
now.window,
|
||||
now.environment.clone(),
|
||||
now.service.clone(),
|
||||
now.every,
|
||||
now.since,
|
||||
);
|
||||
let [moved, windowed, filtered, timed, refreshed] = [(); 5].map(|_| wall.clone());
|
||||
let clock: Timestamp = "2026-09-27T12:00:00Z".parse().expect("a time");
|
||||
let hours: Vec<String> = (0..24).map(|hour| format!("{hour:02}:00")).collect();
|
||||
let latency = Series::new(
|
||||
"p95",
|
||||
(0..24)
|
||||
.map(|ix| 120.0 + 40.0 * ((ix as f64) * 0.5).sin() + if ix > 18 { 60.0 } else { 0.0 }),
|
||||
);
|
||||
let filters = [
|
||||
DashboardFilter {
|
||||
key: "environment".into(),
|
||||
name: "Environment".into(),
|
||||
choices: vec![
|
||||
Choice::new("production", "Production"),
|
||||
Choice::new("staging", "Staging"),
|
||||
],
|
||||
picked: environment,
|
||||
},
|
||||
DashboardFilter {
|
||||
key: "service".into(),
|
||||
name: "Service".into(),
|
||||
choices: vec![
|
||||
Choice::new("web", "Web"),
|
||||
Choice::new("api", "API"),
|
||||
Choice::new("worker", "Worker"),
|
||||
],
|
||||
picked: service,
|
||||
},
|
||||
];
|
||||
let grid = DashboardGrid::new("dashboard-grid", 12, tiles)
|
||||
.card(
|
||||
"requests",
|
||||
DashboardCard::new("Requests").subtitle("per minute").body(
|
||||
Statistic::new("dashboard-requests", "This hour", 12_400.0)
|
||||
.trend(TrendIndicator::new(0.082)),
|
||||
),
|
||||
)
|
||||
.card(
|
||||
"errors",
|
||||
DashboardCard::new("Errors").subtitle("5xx share").body(
|
||||
Statistic::new("dashboard-errors", "This hour", 0.42)
|
||||
.decimals(2)
|
||||
.suffix("%")
|
||||
.trend(TrendIndicator::new(-0.12)),
|
||||
),
|
||||
)
|
||||
.card(
|
||||
"cpu",
|
||||
DashboardCard::new("CPU").body(Gauge::new(
|
||||
"dashboard-cpu",
|
||||
"percent busy",
|
||||
62.0,
|
||||
0.0,
|
||||
100.0,
|
||||
)),
|
||||
)
|
||||
.card(
|
||||
"latency",
|
||||
DashboardCard::new("Latency")
|
||||
.subtitle("p95, milliseconds")
|
||||
.body(LineChart::new("dashboard-latency", hours).series(latency)),
|
||||
)
|
||||
.card(
|
||||
"saturation",
|
||||
DashboardCard::new("Saturation").body(Gauge::new(
|
||||
"dashboard-memory",
|
||||
"memory in use",
|
||||
81.0,
|
||||
0.0,
|
||||
100.0,
|
||||
)),
|
||||
)
|
||||
.on_change(move |next, _, cx| change(&moved, cx, |wall| wall.tiles = next));
|
||||
section(
|
||||
"Dashboard Grid · Widget / DashboardCard · RefreshIntervalSelector · DashboardFilterBar · StatCard → data_display::Statistic · MetricsChart → charts::LineChart · ResourceGauge → data_display::Gauge",
|
||||
"Widgets on a grid of twelve columns: a tile's top strip drags it and its corner resizes it, a cell at a time, while the others make room and rise to fill gaps; a focused tile steps with the arrows. Over it, the window of time and the filters, and how often it refreshes.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.justify_between()
|
||||
.gap_3()
|
||||
.child(
|
||||
DashboardFilterBar::new("dashboard-filters", time, filters)
|
||||
.on_window(move |next, _, cx| change(&windowed, cx, |wall| wall.window = next))
|
||||
.on_filter(move |key, value, _, cx| {
|
||||
change(&filtered, cx, |wall| match key.as_ref() {
|
||||
"environment" => wall.environment = value,
|
||||
_ => wall.service = value,
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
RefreshIntervalSelector::new("dashboard-refresh", every, clock - jiff::SignedDuration::from_secs(since), clock)
|
||||
.on_change(move |next, _, cx| change(&timed, cx, |wall| wall.every = next))
|
||||
.on_refresh(move |_, cx| change(&refreshed, cx, |wall| wall.since = 0)),
|
||||
),
|
||||
)
|
||||
.child(div().w(px(900.)).child(grid)),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use gpui::{AnyElement, App, Window, div, prelude::*};
|
||||
|
||||
mod board;
|
||||
mod monitor;
|
||||
|
||||
use super::Page;
|
||||
use crate::script::Step;
|
||||
|
||||
pub const PAGE: Page = Page {
|
||||
number: 34,
|
||||
slug: "dashboard",
|
||||
title: "Dashboard & Monitoring",
|
||||
summary: "Widgets on a grid you arrange, what is firing and what is up, and the stream of what happens.",
|
||||
render,
|
||||
script: SCRIPT,
|
||||
};
|
||||
|
||||
const SCRIPT: &[Step] = &[];
|
||||
|
||||
fn render(window: &mut Window, cx: &mut App) -> AnyElement {
|
||||
div()
|
||||
.child(board::grid(window, cx))
|
||||
.child(monitor::health(window, cx))
|
||||
.child(monitor::incidents(window, cx))
|
||||
.child(monitor::stream(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
use ely_gpui_component::{
|
||||
dashboard::{
|
||||
AlertList, AlertState, Check, EventStream, Health, HealthCheck, Incident, IncidentCard,
|
||||
IncidentPhase, IncidentUpdate, MonitorAlert, Service, ServiceStatus, StreamEvent,
|
||||
UptimeBar,
|
||||
},
|
||||
data_display::Tone,
|
||||
primitives::Severity,
|
||||
};
|
||||
use gpui::{App, IntoElement, ParentElement, Styled, Window, div, px};
|
||||
use jiff::{SignedDuration, Timestamp, civil::date};
|
||||
|
||||
use crate::ui::{change, keep, section};
|
||||
|
||||
fn now() -> Timestamp {
|
||||
"2026-09-27T12:00:00Z".parse().expect("a time")
|
||||
}
|
||||
|
||||
fn ago(minutes: i64) -> Timestamp {
|
||||
now() - SignedDuration::from_mins(minutes)
|
||||
}
|
||||
|
||||
pub fn health(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let days = |dip: usize| {
|
||||
(0..60).map(move |ix| {
|
||||
if ix == dip {
|
||||
Some(0.982)
|
||||
} else if ix == dip + 3 {
|
||||
Some(0.9975)
|
||||
} else if ix < 4 {
|
||||
None
|
||||
} else {
|
||||
Some(1.0)
|
||||
}
|
||||
})
|
||||
};
|
||||
section(
|
||||
"UptimeBar · ServiceStatus · HealthCheck Indicator",
|
||||
"Sixty days of a service, a bar a day in the tone of its uptime; services under a line for the whole system; and checks with what each found, when it ran and how long it took.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_6()
|
||||
.child(
|
||||
div()
|
||||
.w(px(420.))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_4()
|
||||
.child(UptimeBar::new("dashboard-uptime-api", "API", date(2026, 7, 30), days(41)))
|
||||
.child(UptimeBar::new("dashboard-uptime-web", "Website", date(2026, 7, 30), days(12))),
|
||||
)
|
||||
.child(
|
||||
div().w(px(360.)).child(ServiceStatus::new([
|
||||
Service { name: "API".into(), health: Health::Operational, uptime: Some(0.9998) },
|
||||
Service { name: "Website".into(), health: Health::Operational, uptime: Some(0.9992) },
|
||||
Service { name: "Payments".into(), health: Health::Degraded, uptime: Some(0.9971) },
|
||||
Service { name: "Email".into(), health: Health::Maintenance, uptime: None },
|
||||
])),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.w(px(320.))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(HealthCheck::new("GET /health", Check::Passing).ran(ago(1), now()).took(42))
|
||||
.child(HealthCheck::new("Database", Check::Passing).ran(ago(1), now()).took(8))
|
||||
.child(HealthCheck::new("Queue depth", Check::Warning).ran(ago(2), now()))
|
||||
.child(HealthCheck::new("Payments provider", Check::Failing).ran(ago(3), now()).took(3_020))
|
||||
.child(HealthCheck::new("Search index", Check::Pending)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn incidents(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let alerts = keep(
|
||||
"dashboard-alerts",
|
||||
|| {
|
||||
let alert =
|
||||
|key: &str, title: &str, source: &str, severity, state, minutes| MonitorAlert {
|
||||
key: key.to_string().into(),
|
||||
title: title.to_string().into(),
|
||||
source: source.to_string().into(),
|
||||
severity,
|
||||
state,
|
||||
since: ago(minutes),
|
||||
};
|
||||
vec![
|
||||
alert(
|
||||
"a1",
|
||||
"Payments p95 over 2s",
|
||||
"payments · prometheus",
|
||||
Severity::Danger,
|
||||
AlertState::Firing,
|
||||
12,
|
||||
),
|
||||
alert(
|
||||
"a2",
|
||||
"Queue depth rising",
|
||||
"worker · prometheus",
|
||||
Severity::Warning,
|
||||
AlertState::Firing,
|
||||
25,
|
||||
),
|
||||
alert(
|
||||
"a3",
|
||||
"Disk 85% on db-2",
|
||||
"db-2 · node exporter",
|
||||
Severity::Warning,
|
||||
AlertState::Acknowledged,
|
||||
95,
|
||||
),
|
||||
alert(
|
||||
"a4",
|
||||
"Certificate renewed",
|
||||
"edge · cert-manager",
|
||||
Severity::Info,
|
||||
AlertState::Resolved,
|
||||
300,
|
||||
),
|
||||
]
|
||||
},
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let list = alerts.read(cx).clone();
|
||||
let incident = Incident {
|
||||
title: "Card payments fail for some customers".into(),
|
||||
severity: Severity::Danger,
|
||||
services: vec!["Payments".into(), "Checkout".into()],
|
||||
started: ago(48),
|
||||
updates: vec![
|
||||
IncidentUpdate {
|
||||
phase: IncidentPhase::Investigating,
|
||||
text: "Payments time out for about one in ten cards.".into(),
|
||||
at: ago(46),
|
||||
},
|
||||
IncidentUpdate {
|
||||
phase: IncidentPhase::Identified,
|
||||
text: "The provider's EU region is slow; traffic moves to the US region.".into(),
|
||||
at: ago(30),
|
||||
},
|
||||
IncidentUpdate {
|
||||
phase: IncidentPhase::Monitoring,
|
||||
text: "Payments pass again; we watch error rates.".into(),
|
||||
at: ago(9),
|
||||
},
|
||||
],
|
||||
};
|
||||
section(
|
||||
"AlertList · IncidentCard",
|
||||
"Alerts with their severity, what raised them and since when; a firing one is acknowledged from its row. An incident with its severity and phase, what it touches, and its updates newest first.",
|
||||
cx,
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_6()
|
||||
.child(
|
||||
div().w(px(460.)).child(
|
||||
AlertList::new("dashboard-alert-list", list, now()).on_acknowledge(move |key, _, cx| {
|
||||
change(&alerts, cx, |alerts| {
|
||||
alerts.iter_mut().find(|alert| alert.key == *key).expect("a listed alert").state = AlertState::Acknowledged
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
.child(div().w(px(420.)).child(IncidentCard::new(incident, now()))),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn stream(_: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
|
||||
let event = |key: &str, kind: &str, text: &str, tone, minutes| StreamEvent {
|
||||
key: key.to_string().into(),
|
||||
kind: kind.to_string().into(),
|
||||
text: text.to_string().into(),
|
||||
tone,
|
||||
at: ago(minutes),
|
||||
};
|
||||
let events = vec![
|
||||
event(
|
||||
"e1",
|
||||
"deploy",
|
||||
"web 1.8.1 rolled out to production",
|
||||
Tone::Info,
|
||||
58,
|
||||
),
|
||||
event(
|
||||
"e2",
|
||||
"scale",
|
||||
"worker scaled from 4 to 6 replicas",
|
||||
Tone::Neutral,
|
||||
41,
|
||||
),
|
||||
event("e3", "alert", "Payments p95 over 2s", Tone::Danger, 12),
|
||||
event(
|
||||
"e4",
|
||||
"config",
|
||||
"feature flag new-checkout at 20%",
|
||||
Tone::Accent,
|
||||
7,
|
||||
),
|
||||
event(
|
||||
"e5",
|
||||
"deploy",
|
||||
"web 1.8.2 rolled out to production",
|
||||
Tone::Info,
|
||||
2,
|
||||
),
|
||||
];
|
||||
section(
|
||||
"EventStream · LogStream → terminal::LogViewer · TraceWaterfall → debug::TimelineProfiler · SystemMonitor → devtools::ResourceMonitor · ProcessTable → terminal::ProcessList · NetworkGraph (throughput) → charts::RealtimeChart · MapView / GeoHeatmap → maps (T41) · WorldMap → maps (T41)",
|
||||
"Events as they come, newest first, kept by kind, with Pause to hold the list while new ones count up. Logs, traces, a machine's readings, its processes and live throughput have their homes in the terminal, agent, dev tools and charts chapters; the maps come with the maps chapter.",
|
||||
cx,
|
||||
)
|
||||
.child(div().w(px(620.)).child(EventStream::new("dashboard-events", events, now())))
|
||||
}
|
||||
@@ -5,6 +5,7 @@ mod canvas;
|
||||
mod charts;
|
||||
mod chat;
|
||||
mod collab;
|
||||
mod dashboard;
|
||||
mod data;
|
||||
mod debug;
|
||||
mod devtools;
|
||||
@@ -81,6 +82,7 @@ pub const ALL: &[Page] = &[
|
||||
project::PAGE,
|
||||
canvas::PAGE,
|
||||
devtools::PAGE,
|
||||
dashboard::PAGE,
|
||||
theme::PAGE,
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window, div,
|
||||
};
|
||||
use jiff::Timestamp;
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
data_display::{Badge, Timeline, TimelineItem, Tone},
|
||||
lists::{ListItem, SelectableList, row_action},
|
||||
primitives::{Icon, Severity},
|
||||
theme::{ActiveTheme, IconSize, Radius, TextSize},
|
||||
typography::{Ellipsis, format},
|
||||
};
|
||||
|
||||
/// Where an alert stands.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AlertState {
|
||||
Firing,
|
||||
Acknowledged,
|
||||
Resolved,
|
||||
}
|
||||
|
||||
/// An alert from a monitor: its key and title, what raised it, how grave it is, where it stands, and since when.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MonitorAlert {
|
||||
pub key: SharedString,
|
||||
pub title: SharedString,
|
||||
pub source: SharedString,
|
||||
pub severity: Severity,
|
||||
pub state: AlertState,
|
||||
pub since: Timestamp,
|
||||
}
|
||||
|
||||
type OnKey = Rc<dyn Fn(&SharedString, &mut Window, &mut App)>;
|
||||
|
||||
/// Alerts, each with its severity's icon, what raised it and since when, and where it stands; a firing one can be acknowledged from its row. Enter or a double press opens one.
|
||||
#[derive(IntoElement)]
|
||||
pub struct AlertList {
|
||||
id: ElementId,
|
||||
alerts: Vec<MonitorAlert>,
|
||||
now: Timestamp,
|
||||
on_open: Option<OnKey>,
|
||||
on_acknowledge: Option<OnKey>,
|
||||
}
|
||||
|
||||
impl AlertList {
|
||||
/// `now` dates each alert.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
alerts: impl IntoIterator<Item = MonitorAlert>,
|
||||
now: Timestamp,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
alerts: alerts.into_iter().collect(),
|
||||
now,
|
||||
on_open: None,
|
||||
on_acknowledge: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_open(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_open = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_acknowledge(
|
||||
mut self,
|
||||
handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_acknowledge = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for AlertList {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let id = self.id;
|
||||
let theme = cx.theme();
|
||||
let list = self.alerts.iter().fold(
|
||||
SelectableList::new((id.clone(), "alerts")),
|
||||
|list, alert| {
|
||||
let (tone, words) = match alert.state {
|
||||
AlertState::Firing => (Tone::from(alert.severity), "Firing"),
|
||||
AlertState::Acknowledged => (Tone::Neutral, "Acknowledged"),
|
||||
AlertState::Resolved => (Tone::Success, "Resolved"),
|
||||
};
|
||||
let key = alert.key.clone();
|
||||
let firing = alert.state == AlertState::Firing;
|
||||
let button = self
|
||||
.on_acknowledge
|
||||
.clone()
|
||||
.filter(|_| firing)
|
||||
.map(|acknowledge| {
|
||||
row_action(
|
||||
Button::new((id.clone(), format!("ack-{}", alert.key)), "Acknowledge")
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.on_click(move |_, window, cx| {
|
||||
log::info!("alert list: acknowledge {key}");
|
||||
acknowledge(&key, window, cx);
|
||||
}),
|
||||
)
|
||||
});
|
||||
list.row(
|
||||
alert.key.clone(),
|
||||
ListItem::new(
|
||||
(id.clone(), format!("row-{}", alert.key)),
|
||||
alert.title.clone(),
|
||||
)
|
||||
.description(format!(
|
||||
"{} · {}",
|
||||
alert.source,
|
||||
format::relative(alert.since, self.now)
|
||||
))
|
||||
.leading(
|
||||
Icon::new(alert.severity.icon())
|
||||
.size(IconSize::Sm)
|
||||
.color(alert.severity.color(&theme.colors)),
|
||||
)
|
||||
.trailing(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.child(Badge::new(words).tone(tone))
|
||||
.children(button),
|
||||
),
|
||||
)
|
||||
},
|
||||
);
|
||||
match self.on_open {
|
||||
Some(on_open) => list.on_activate(move |key, window, cx| {
|
||||
log::info!("alert list: open {key}");
|
||||
on_open(key, window, cx)
|
||||
}),
|
||||
None => list,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How far an incident has come.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum IncidentPhase {
|
||||
Investigating,
|
||||
Identified,
|
||||
Monitoring,
|
||||
Resolved,
|
||||
}
|
||||
|
||||
impl IncidentPhase {
|
||||
fn words(self) -> &'static str {
|
||||
match self {
|
||||
IncidentPhase::Investigating => "Investigating",
|
||||
IncidentPhase::Identified => "Identified",
|
||||
IncidentPhase::Monitoring => "Monitoring",
|
||||
IncidentPhase::Resolved => "Resolved",
|
||||
}
|
||||
}
|
||||
|
||||
fn tone(self) -> Tone {
|
||||
match self {
|
||||
IncidentPhase::Investigating => Tone::Danger,
|
||||
IncidentPhase::Identified => Tone::Warning,
|
||||
IncidentPhase::Monitoring => Tone::Info,
|
||||
IncidentPhase::Resolved => Tone::Success,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How grave an incident is, in words.
|
||||
fn grave(severity: Severity) -> &'static str {
|
||||
match severity {
|
||||
Severity::Info | Severity::Success => "Minor",
|
||||
Severity::Warning => "Major",
|
||||
Severity::Danger => "Critical",
|
||||
}
|
||||
}
|
||||
|
||||
/// A word on an incident: the phase it marks, what it says, and when.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct IncidentUpdate {
|
||||
pub phase: IncidentPhase,
|
||||
pub text: SharedString,
|
||||
pub at: Timestamp,
|
||||
}
|
||||
|
||||
/// An incident: its title, how grave it is, the services it touches, when it began, and its updates oldest first.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Incident {
|
||||
pub title: SharedString,
|
||||
pub severity: Severity,
|
||||
pub services: Vec<SharedString>,
|
||||
pub started: Timestamp,
|
||||
pub updates: Vec<IncidentUpdate>,
|
||||
}
|
||||
|
||||
impl Incident {
|
||||
/// Its phase, the latest update's.
|
||||
pub fn phase(&self) -> IncidentPhase {
|
||||
self.updates
|
||||
.last()
|
||||
.map_or(IncidentPhase::Investigating, |update| update.phase)
|
||||
}
|
||||
}
|
||||
|
||||
/// An incident: its title under its severity and phase, when it began and what it touches, then its updates newest first.
|
||||
#[derive(IntoElement)]
|
||||
pub struct IncidentCard {
|
||||
incident: Incident,
|
||||
now: Timestamp,
|
||||
}
|
||||
|
||||
impl IncidentCard {
|
||||
/// `now` dates the incident and its updates.
|
||||
pub fn new(incident: Incident, now: Timestamp) -> Self {
|
||||
Self { incident, now }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for IncidentCard {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let (incident, now) = (self.incident, self.now);
|
||||
let phase = incident.phase();
|
||||
let touched = if incident.services.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" · {}", incident.services.join(", "))
|
||||
};
|
||||
let timeline = incident
|
||||
.updates
|
||||
.iter()
|
||||
.rev()
|
||||
.fold(Timeline::new(), |timeline, update| {
|
||||
let when = format!(
|
||||
"{} · {}",
|
||||
update.phase.words(),
|
||||
format::relative(update.at, now)
|
||||
);
|
||||
timeline.item(
|
||||
TimelineItem::new(
|
||||
div().child(update.text.clone()).child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(when),
|
||||
),
|
||||
)
|
||||
.tone(update.phase.tone()),
|
||||
)
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.p_4()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.bg(theme.colors.surface)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(Badge::new(grave(incident.severity)).tone(Tone::from(incident.severity)))
|
||||
.child(Badge::new(phase.words()).tone(phase.tone()).dot()),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(Ellipsis::new(incident.title)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(format!(
|
||||
"Began {}{touched}",
|
||||
format::relative(incident.started, now)
|
||||
)),
|
||||
)
|
||||
.child(timeline)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Incident, IncidentPhase, IncidentUpdate, grave};
|
||||
use crate::primitives::Severity;
|
||||
|
||||
#[test]
|
||||
fn an_incident_reads_its_latest_phase_and_its_gravity() {
|
||||
let at: jiff::Timestamp = "2026-09-27T12:00:00Z".parse().expect("a time");
|
||||
let mut incident = Incident {
|
||||
title: "Slow checkout".into(),
|
||||
severity: Severity::Warning,
|
||||
services: vec!["api".into()],
|
||||
started: at,
|
||||
updates: Vec::new(),
|
||||
};
|
||||
assert_eq!(
|
||||
incident.phase(),
|
||||
IncidentPhase::Investigating,
|
||||
"nothing said yet"
|
||||
);
|
||||
incident.updates = vec![
|
||||
IncidentUpdate {
|
||||
phase: IncidentPhase::Identified,
|
||||
text: "A slow query".into(),
|
||||
at,
|
||||
},
|
||||
IncidentUpdate {
|
||||
phase: IncidentPhase::Monitoring,
|
||||
text: "A fix is out".into(),
|
||||
at,
|
||||
},
|
||||
];
|
||||
assert_eq!(incident.phase(), IncidentPhase::Monitoring);
|
||||
assert_eq!(
|
||||
[Severity::Info, Severity::Warning, Severity::Danger].map(grave),
|
||||
["Minor", "Major", "Critical"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
use gpui::{
|
||||
AnyElement, App, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, Styled,
|
||||
Window, div,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
theme::{ActiveTheme, Radius, TextSize},
|
||||
typography::Ellipsis,
|
||||
};
|
||||
|
||||
/// A widget's frame: its title with a line under it, actions at the end of the title's line, its body, and an optional footer. It fills the box it sits in, as a dashboard tile.
|
||||
#[derive(IntoElement)]
|
||||
pub struct DashboardCard {
|
||||
title: SharedString,
|
||||
subtitle: Option<SharedString>,
|
||||
actions: Vec<AnyElement>,
|
||||
body: Option<AnyElement>,
|
||||
footer: Option<AnyElement>,
|
||||
}
|
||||
|
||||
impl DashboardCard {
|
||||
pub fn new(title: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
title: title.into(),
|
||||
subtitle: None,
|
||||
actions: Vec::new(),
|
||||
body: None,
|
||||
footer: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subtitle(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.subtitle = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn action(mut self, action: impl IntoElement) -> Self {
|
||||
self.actions.push(action.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn body(mut self, body: impl IntoElement) -> Self {
|
||||
self.body = Some(body.into_any_element());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn footer(mut self, footer: impl IntoElement) -> Self {
|
||||
self.footer = Some(footer.into_any_element());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for DashboardCard {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.h_full()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_3()
|
||||
.p_4()
|
||||
.rounded(theme.radius(Radius::Lg))
|
||||
.border_1()
|
||||
.border_color(theme.colors.border)
|
||||
.bg(theme.colors.surface)
|
||||
.overflow_hidden()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_start()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(Ellipsis::new(self.title)),
|
||||
)
|
||||
.children(self.subtitle.map(|text| {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(Ellipsis::new(text))
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.children(self.actions),
|
||||
),
|
||||
)
|
||||
.child(div().flex_1().min_h_0().children(self.body))
|
||||
.children(self.footer.map(|footer| {
|
||||
div()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(footer)
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
use gpui::{
|
||||
App, ElementId, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
|
||||
Styled, Window, div,
|
||||
};
|
||||
use jiff::Timestamp;
|
||||
|
||||
use crate::{
|
||||
buttons::{Button, ButtonVariant},
|
||||
data_display::{Badge, Tone},
|
||||
forms::{Choice, ChoiceChips},
|
||||
theme::{ActiveTheme, TextSize},
|
||||
typography::{Ellipsis, format},
|
||||
};
|
||||
|
||||
/// Something that happened: its key, its kind, what it says, its tone, and when.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct StreamEvent {
|
||||
pub key: SharedString,
|
||||
pub kind: SharedString,
|
||||
pub text: SharedString,
|
||||
pub tone: Tone,
|
||||
pub at: Timestamp,
|
||||
}
|
||||
|
||||
/// The stream's own state: the kinds shown, and while paused how many events there were.
|
||||
#[derive(Default)]
|
||||
struct Hold {
|
||||
kinds: Vec<SharedString>,
|
||||
held: Option<usize>,
|
||||
}
|
||||
|
||||
/// Events as they come, newest first, each with its kind, its words and its time. Chips keep the kinds shown; Pause holds the list still while new events count up on a button that shows them.
|
||||
#[derive(IntoElement)]
|
||||
pub struct EventStream {
|
||||
id: ElementId,
|
||||
events: Vec<StreamEvent>,
|
||||
now: Timestamp,
|
||||
}
|
||||
|
||||
impl EventStream {
|
||||
/// `events` oldest first, dated against `now`.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
events: impl IntoIterator<Item = StreamEvent>,
|
||||
now: Timestamp,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
events: events.into_iter().collect(),
|
||||
now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The events shown: those up to the hold, of the kinds kept, newest first.
|
||||
pub(crate) fn shown<'a>(
|
||||
events: &'a [StreamEvent],
|
||||
kinds: &[SharedString],
|
||||
held: Option<usize>,
|
||||
) -> Vec<&'a StreamEvent> {
|
||||
let upto = held.unwrap_or(events.len()).min(events.len());
|
||||
events[..upto]
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|event| kinds.is_empty() || kinds.contains(&event.kind))
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl RenderOnce for EventStream {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let id = self.id;
|
||||
let hold = window.use_keyed_state((id.clone(), "hold"), cx, |_, _| Hold::default());
|
||||
let (kinds, held) = {
|
||||
let hold = hold.read(cx);
|
||||
(hold.kinds.clone(), hold.held)
|
||||
};
|
||||
let mut all: Vec<SharedString> = Vec::new();
|
||||
for event in &self.events {
|
||||
if !all.contains(&event.kind) {
|
||||
all.push(event.kind.clone());
|
||||
}
|
||||
}
|
||||
let fresh = held.map(|held| self.events.len().saturating_sub(held));
|
||||
let theme = cx.theme();
|
||||
let rows = shown(&self.events, &kinds, held).into_iter().map(|event| {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.py_1p5()
|
||||
.border_b_1()
|
||||
.border_color(theme.colors.border)
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.child(Badge::new(event.kind.clone()).tone(event.tone)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.child(Ellipsis::new(event.text.clone())),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.text_color(theme.colors.fg_subtle)
|
||||
.child(format::relative(event.at, self.now)),
|
||||
)
|
||||
});
|
||||
let count = self.events.len();
|
||||
let (picked, paused) = (hold.clone(), hold);
|
||||
let toggle = move |_: &gpui::ClickEvent, _: &mut Window, cx: &mut App| {
|
||||
paused.update(cx, |hold, cx| {
|
||||
hold.held = match hold.held {
|
||||
Some(_) => None,
|
||||
None => Some(count),
|
||||
};
|
||||
log::info!(
|
||||
"event stream: {}",
|
||||
if hold.held.is_some() {
|
||||
"paused"
|
||||
} else {
|
||||
"running"
|
||||
}
|
||||
);
|
||||
cx.notify();
|
||||
})
|
||||
};
|
||||
let label = match fresh {
|
||||
Some(0) => "Resume".to_string(),
|
||||
Some(new) => format!("{new} new · Resume"),
|
||||
None => "Pause".to_string(),
|
||||
};
|
||||
let state = match fresh {
|
||||
Some(new) => format!("event-stream-held-{new}"),
|
||||
None => "event-stream-live".to_string(),
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.child(
|
||||
ChoiceChips::new(
|
||||
(id.clone(), "kinds"),
|
||||
all.iter()
|
||||
.map(|kind| Choice::new(kind.clone(), kind.clone())),
|
||||
)
|
||||
.multiple()
|
||||
.selected(kinds)
|
||||
.on_change(move |chosen, _, cx| {
|
||||
picked.update(cx, |hold, cx| {
|
||||
hold.kinds = chosen.to_vec();
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div().debug_selector(move || state).child(
|
||||
Button::new((id, "pause"), label)
|
||||
.variant(ButtonVariant::Secondary)
|
||||
.on_click(toggle),
|
||||
),
|
||||
),
|
||||
)
|
||||
.child(div().flex().flex_col().children(rows))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{StreamEvent, shown};
|
||||
use crate::data_display::Tone;
|
||||
|
||||
#[test]
|
||||
fn a_hold_and_the_kinds_keep_what_shows_newest_first() {
|
||||
let at: jiff::Timestamp = "2026-09-27T12:00:00Z".parse().expect("a time");
|
||||
let event = |key: &str, kind: &str| StreamEvent {
|
||||
key: key.to_string().into(),
|
||||
kind: kind.to_string().into(),
|
||||
text: "x".into(),
|
||||
tone: Tone::Neutral,
|
||||
at,
|
||||
};
|
||||
let events = [
|
||||
event("1", "deploy"),
|
||||
event("2", "alert"),
|
||||
event("3", "deploy"),
|
||||
event("4", "deploy"),
|
||||
];
|
||||
let keys = |shown: Vec<&StreamEvent>| {
|
||||
shown
|
||||
.iter()
|
||||
.map(|event| event.key.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(keys(shown(&events, &[], None)), ["4", "3", "2", "1"]);
|
||||
assert_eq!(
|
||||
keys(shown(&events, &["deploy".into()], Some(3))),
|
||||
["3", "1"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext as _, ElementId, EmptyView, EntityId, InteractiveElement,
|
||||
IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce, SharedString,
|
||||
StatefulInteractiveElement, Styled, Window, canvas, div,
|
||||
};
|
||||
|
||||
use super::tiles::{Tile, arranged, stepped};
|
||||
use crate::{
|
||||
primitives::{Icon, IconName, tab_stop},
|
||||
theme::{ActiveTheme, IconSize, Radius},
|
||||
};
|
||||
|
||||
type OnTiles = Rc<dyn Fn(Vec<Tile>, &mut Window, &mut App)>;
|
||||
|
||||
/// What a drag on a tile does.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Grip {
|
||||
Move,
|
||||
Size,
|
||||
}
|
||||
|
||||
/// A drag on a grid's tile: whose grid, which tile, what it does, and where the pointer was pressed.
|
||||
struct Pull {
|
||||
owner: EntityId,
|
||||
}
|
||||
|
||||
/// The grid's own state: its width, and a drag under way with the tile as it would land.
|
||||
#[derive(Default)]
|
||||
struct Board {
|
||||
width: f32,
|
||||
pull: Option<(SharedString, Grip, Point<Pixels>, Tile)>,
|
||||
landing: Option<Tile>,
|
||||
}
|
||||
|
||||
/// Widgets on a grid of columns. A tile's top strip drags it and its corner resizes it, a cell at a time; the tiles in the way move down and every tile rises to fill a gap, while the drag shows where it will land. A focused tile moves a column with Left and Right, below the next tile under it with Down or into the place of the one above with Up, and grows or shrinks a cell with Shift. The owner keeps the tiles. Under the theme's `stack_below` width the tiles stand in one column in reading order, each its own height, and moving waits for room.
|
||||
#[derive(IntoElement)]
|
||||
pub struct DashboardGrid {
|
||||
id: ElementId,
|
||||
columns: u32,
|
||||
tiles: Vec<Tile>,
|
||||
cards: Vec<(SharedString, AnyElement)>,
|
||||
on_change: Option<OnTiles>,
|
||||
}
|
||||
|
||||
impl DashboardGrid {
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
columns: u32,
|
||||
tiles: impl IntoIterator<Item = Tile>,
|
||||
) -> Self {
|
||||
assert!(columns > 0, "a grid of no columns");
|
||||
Self {
|
||||
id: id.into(),
|
||||
columns,
|
||||
tiles: tiles.into_iter().collect(),
|
||||
cards: Vec::new(),
|
||||
on_change: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the tile of `key` shows.
|
||||
pub fn card(mut self, key: impl Into<SharedString>, card: impl IntoElement) -> Self {
|
||||
self.cards.push((key.into(), card.into_any_element()));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn on_change(
|
||||
mut self,
|
||||
handler: impl Fn(Vec<Tile>, &mut Window, &mut App) + 'static,
|
||||
) -> Self {
|
||||
self.on_change = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// The tile a drag of `by` pixels makes of `start`, a cell at a time.
|
||||
fn pulled(start: &Tile, grip: Grip, by: (f32, f32), cell: (f32, f32)) -> Tile {
|
||||
let step = |pixels: f32, size: f32| (pixels / size).round() as i64;
|
||||
let (dx, dy) = (step(by.0, cell.0), step(by.1, cell.1));
|
||||
let moved = |value: u32, by: i64, least: i64| (value as i64 + by).max(least) as u32;
|
||||
match grip {
|
||||
Grip::Move => Tile {
|
||||
x: moved(start.x, dx, 0),
|
||||
y: moved(start.y, dy, 0),
|
||||
..start.clone()
|
||||
},
|
||||
Grip::Size => Tile {
|
||||
w: moved(start.w, dx, 1),
|
||||
h: moved(start.h, dy, 1),
|
||||
..start.clone()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A canvas that keeps the grid's width in its board.
|
||||
fn measure(board: gpui::Entity<Board>) -> impl IntoElement {
|
||||
canvas(
|
||||
move |bounds, window, cx| {
|
||||
let now = f32::from(bounds.size.width);
|
||||
if board.read(cx).width != now {
|
||||
board.update(cx, |board, _| board.width = now);
|
||||
window.request_animation_frame();
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full()
|
||||
}
|
||||
|
||||
/// The tiles in one column in reading order, each as tall as its rows: a grid too narrow for its columns.
|
||||
fn stacked(
|
||||
id: ElementId,
|
||||
mut tiles: Vec<Tile>,
|
||||
mut cards: Vec<(SharedString, AnyElement)>,
|
||||
board: gpui::Entity<Board>,
|
||||
cx: &App,
|
||||
) -> AnyElement {
|
||||
let sizes = cx.theme().dashboard();
|
||||
tiles.sort_by_key(|tile| (tile.y, tile.x));
|
||||
let column: Vec<_> = tiles
|
||||
.into_iter()
|
||||
.map(|tile| {
|
||||
let at = cards
|
||||
.iter()
|
||||
.position(|(key, _)| *key == tile.key)
|
||||
.unwrap_or_else(|| panic!("dashboard grid {id:?}: no card for tile {}", tile.key));
|
||||
let card = cards.remove(at).1;
|
||||
let key = tile.key.clone();
|
||||
div()
|
||||
.debug_selector(move || format!("stacked-{key}"))
|
||||
.h((sizes.row + sizes.gap) * tile.h as f32 - sizes.gap)
|
||||
.child(card)
|
||||
})
|
||||
.collect();
|
||||
div()
|
||||
.id(id)
|
||||
.relative()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(sizes.gap)
|
||||
.child(measure(board))
|
||||
.children(column)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
impl RenderOnce for DashboardGrid {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let id = self.id;
|
||||
let board = window.use_keyed_state((id.clone(), "board"), cx, |_, _| Board::default());
|
||||
let owner = board.entity_id();
|
||||
let theme = cx.theme();
|
||||
let rem = window.rem_size();
|
||||
let (row, gap) = (
|
||||
f32::from(theme.dashboard().row.to_pixels(rem)),
|
||||
f32::from(theme.dashboard().gap.to_pixels(rem)),
|
||||
);
|
||||
let columns = self.columns;
|
||||
let width = board.read(cx).width;
|
||||
if width > 0.0 && width < f32::from(theme.dashboard().stack_below.to_pixels(rem)) {
|
||||
return stacked(id, self.tiles, self.cards, board, cx);
|
||||
}
|
||||
let cell = ((width + gap) / columns as f32, row + gap);
|
||||
let landing = board.read(cx).landing.clone();
|
||||
let shown = match &landing {
|
||||
Some(tile) => arranged(&self.tiles, tile.clone(), columns),
|
||||
None => self.tiles.clone(),
|
||||
};
|
||||
let height = shown.iter().map(|tile| tile.y + tile.h).max().unwrap_or(0) as f32 * cell.1;
|
||||
let tiles = Rc::new(self.tiles);
|
||||
let on_change = self
|
||||
.on_change
|
||||
.unwrap_or_else(|| panic!("dashboard grid {id:?} has no on_change"));
|
||||
let (colors, radius) = (theme.colors.clone(), theme.radius(Radius::Lg));
|
||||
let grip = theme.control_height(crate::theme::ControlSize::Sm);
|
||||
let mut cards = self.cards;
|
||||
let placed: Vec<AnyElement> = shown
|
||||
.iter()
|
||||
.map(|tile| {
|
||||
let at = cards
|
||||
.iter()
|
||||
.position(|(key, _)| *key == tile.key)
|
||||
.unwrap_or_else(|| {
|
||||
panic!("dashboard grid {id:?}: no card for tile {}", tile.key)
|
||||
});
|
||||
let card = cards.remove(at).1;
|
||||
let focus = tab_stop(
|
||||
(id.clone(), format!("tile-{}", tile.key)).into(),
|
||||
true,
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
let focused = focus.is_focused(window);
|
||||
let key = tile.key.clone();
|
||||
let start = |grip: Grip| {
|
||||
let (board, key, tiles) = (board.clone(), key.clone(), tiles.clone());
|
||||
move |event: &gpui::MouseDownEvent, _: &mut Window, cx: &mut App| {
|
||||
cx.stop_propagation();
|
||||
let tile = tiles
|
||||
.iter()
|
||||
.find(|tile| tile.key == key)
|
||||
.expect("a tile of the grid")
|
||||
.clone();
|
||||
board.update(cx, |board, _| {
|
||||
board.pull = Some((key.clone(), grip, event.position, tile))
|
||||
});
|
||||
}
|
||||
};
|
||||
let (keys, keyed_tiles, keyed_change, own) =
|
||||
(key.clone(), tiles.clone(), on_change.clone(), focus.clone());
|
||||
div()
|
||||
.id((id.clone(), format!("tile-{}", tile.key)))
|
||||
.track_focus(&focus)
|
||||
.absolute()
|
||||
.left(Pixels::from(tile.x as f32 * cell.0))
|
||||
.top(Pixels::from(tile.y as f32 * cell.1))
|
||||
.w(Pixels::from(tile.w as f32 * cell.0 - gap))
|
||||
.h(Pixels::from(tile.h as f32 * cell.1 - gap))
|
||||
.rounded(radius)
|
||||
.border_1()
|
||||
.border_color(if focused {
|
||||
colors.focus
|
||||
} else {
|
||||
gpui::transparent_black()
|
||||
})
|
||||
.on_key_down(move |event, window, cx| {
|
||||
if !own.is_focused(window) {
|
||||
return;
|
||||
}
|
||||
let grow = event.keystroke.modifiers.shift;
|
||||
let key = event.keystroke.key.as_str();
|
||||
let (dx, dy): (i64, i64) = match key {
|
||||
"left" => (-1, 0),
|
||||
"right" => (1, 0),
|
||||
"up" => (0, -1),
|
||||
"down" => (0, 1),
|
||||
_ => return,
|
||||
};
|
||||
cx.stop_propagation();
|
||||
let tile = keyed_tiles
|
||||
.iter()
|
||||
.find(|tile| tile.key == keys)
|
||||
.expect("a tile of the grid");
|
||||
let to = match (grow, dy) {
|
||||
(false, 0) => pulled(tile, Grip::Move, (dx as f32, 0.0), (1.0, 1.0)),
|
||||
(false, _) => match stepped(&keyed_tiles, &keys, dy > 0) {
|
||||
Some(to) => to,
|
||||
None => return,
|
||||
},
|
||||
(true, _) => {
|
||||
pulled(tile, Grip::Size, (dx as f32, dy as f32), (1.0, 1.0))
|
||||
}
|
||||
};
|
||||
log::info!("dashboard grid: {} to {to:?}", keys);
|
||||
keyed_change(arranged(&keyed_tiles, to, columns), window, cx);
|
||||
})
|
||||
.child(div().size_full().child(card))
|
||||
.child(
|
||||
div()
|
||||
.id((id.clone(), format!("move-{}", tile.key)))
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.right(grip)
|
||||
.h(grip)
|
||||
.cursor_grab()
|
||||
.on_mouse_down(MouseButton::Left, start(Grip::Move))
|
||||
.on_drag(Pull { owner }, |_, _, _, cx| cx.new(|_| EmptyView)),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.id((id.clone(), format!("size-{}", tile.key)))
|
||||
.absolute()
|
||||
.bottom_0()
|
||||
.right_0()
|
||||
.size(grip)
|
||||
.flex()
|
||||
.items_end()
|
||||
.justify_end()
|
||||
.cursor(gpui::CursorStyle::ResizeUpLeftDownRight)
|
||||
.on_mouse_down(MouseButton::Left, start(Grip::Size))
|
||||
.on_drag(Pull { owner }, |_, _, _, cx| cx.new(|_| EmptyView))
|
||||
.child(
|
||||
Icon::new(IconName::GripHorizontal)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.fg_subtle),
|
||||
),
|
||||
)
|
||||
.into_any_element()
|
||||
})
|
||||
.collect();
|
||||
let ghost = landing.as_ref().map(|tile| {
|
||||
div()
|
||||
.absolute()
|
||||
.left(Pixels::from(tile.x as f32 * cell.0))
|
||||
.top(Pixels::from(tile.y as f32 * cell.1))
|
||||
.w(Pixels::from(tile.w as f32 * cell.0 - gap))
|
||||
.h(Pixels::from(tile.h as f32 * cell.1 - gap))
|
||||
.rounded(radius)
|
||||
.bg(colors.accent.alpha(0.06))
|
||||
.border_1()
|
||||
.border_color(colors.accent.alpha(0.4))
|
||||
});
|
||||
let (moved, measured, dropped, released, left) = (
|
||||
board.clone(),
|
||||
board.clone(),
|
||||
board.clone(),
|
||||
board.clone(),
|
||||
board.clone(),
|
||||
);
|
||||
let end = move |board: &gpui::Entity<Board>,
|
||||
tiles: &[Tile],
|
||||
on_change: &OnTiles,
|
||||
window: &mut Window,
|
||||
cx: &mut App| {
|
||||
let landing = board.update(cx, |board, cx| {
|
||||
board.pull = None;
|
||||
cx.notify();
|
||||
board.landing.take()
|
||||
});
|
||||
if let Some(to) = landing {
|
||||
log::info!("dashboard grid: {} lands at {to:?}", to.key);
|
||||
on_change(arranged(tiles, to, columns), window, cx);
|
||||
}
|
||||
};
|
||||
let end = Rc::new(end);
|
||||
let (drop_end, up_end, out_end) = (end.clone(), end.clone(), end);
|
||||
let (drop_tiles, up_tiles, out_tiles) = (tiles.clone(), tiles.clone(), tiles);
|
||||
let (drop_change, up_change, out_change) =
|
||||
(on_change.clone(), on_change.clone(), on_change);
|
||||
div()
|
||||
.id(id)
|
||||
.relative()
|
||||
.h(Pixels::from(height.max(cell.1)))
|
||||
.child(measure(measured))
|
||||
.on_drag_move::<Pull>(move |event, _, cx| {
|
||||
if event.drag(cx).owner != owner {
|
||||
return;
|
||||
}
|
||||
let Some((_, grip, from, start)) = moved.read(cx).pull.clone() else {
|
||||
return;
|
||||
};
|
||||
let by = event.event.position - from;
|
||||
let to = pulled(&start, grip, (f32::from(by.x), f32::from(by.y)), cell);
|
||||
if moved.read(cx).landing.as_ref() != Some(&to) {
|
||||
moved.update(cx, |board, cx| {
|
||||
board.landing = Some(to);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.on_drop(move |_: &Pull, window, cx| {
|
||||
drop_end(&dropped, &drop_tiles, &drop_change, window, cx)
|
||||
})
|
||||
.on_mouse_up(MouseButton::Left, move |_, window, cx| {
|
||||
up_end(&released, &up_tiles, &up_change, window, cx)
|
||||
})
|
||||
.on_mouse_up_out(MouseButton::Left, move |_, window, cx| {
|
||||
out_end(&left, &out_tiles, &out_change, window, cx)
|
||||
})
|
||||
.children(ghost)
|
||||
.children(placed)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Grip, pulled};
|
||||
use crate::dashboard::Tile;
|
||||
|
||||
#[test]
|
||||
fn a_pull_moves_or_grows_a_tile_by_whole_cells() {
|
||||
let tile = Tile::new("a", (2, 1), (3, 2));
|
||||
assert_eq!(
|
||||
pulled(&tile, Grip::Move, (130.0, -70.0), (100.0, 80.0)),
|
||||
Tile::new("a", (3, 0), (3, 2))
|
||||
);
|
||||
assert_eq!(
|
||||
pulled(&tile, Grip::Move, (-900.0, 0.0), (100.0, 80.0)),
|
||||
Tile::new("a", (0, 1), (3, 2)),
|
||||
"not past the left edge"
|
||||
);
|
||||
assert_eq!(
|
||||
pulled(&tile, Grip::Size, (-400.0, 90.0), (100.0, 80.0)),
|
||||
Tile::new("a", (2, 1), (1, 3)),
|
||||
"at least a cell"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
mod alerts;
|
||||
mod card;
|
||||
mod controls;
|
||||
mod events;
|
||||
mod grid;
|
||||
mod status;
|
||||
mod tiles;
|
||||
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
|
||||
pub use alerts::{
|
||||
AlertList, AlertState, Incident, IncidentCard, IncidentPhase, IncidentUpdate, MonitorAlert,
|
||||
};
|
||||
pub use card::DashboardCard;
|
||||
pub use controls::{DashboardFilter, DashboardFilterBar, RefreshIntervalSelector, TimeWindow};
|
||||
pub use events::{EventStream, StreamEvent};
|
||||
pub use grid::DashboardGrid;
|
||||
pub use status::{Check, Health, HealthCheck, Service, ServiceStatus, UptimeBar, overall, uptime};
|
||||
pub use tiles::{Tile, arranged, stepped};
|
||||
@@ -0,0 +1,358 @@
|
||||
use gpui::{
|
||||
App, ElementId, FontWeight, Hsla, InteractiveElement, IntoElement, ParentElement, RenderOnce,
|
||||
SharedString, StatefulInteractiveElement, Styled, Window, div,
|
||||
};
|
||||
use jiff::{Timestamp, civil::Date};
|
||||
|
||||
use crate::{
|
||||
data_display::{Badge, Tone},
|
||||
primitives::Tooltip,
|
||||
theme::{ActiveTheme, Palette, Radius, TextSize},
|
||||
typography::{Ellipsis, format},
|
||||
};
|
||||
|
||||
/// The tone a day's uptime wears: whole, a little down, or down.
|
||||
fn day_tone(uptime: f32) -> Tone {
|
||||
match uptime {
|
||||
up if up >= 0.999 => Tone::Success,
|
||||
up if up >= 0.99 => Tone::Warning,
|
||||
_ => Tone::Danger,
|
||||
}
|
||||
}
|
||||
|
||||
fn tone_color(tone: Tone, colors: &Palette) -> Hsla {
|
||||
match tone {
|
||||
Tone::Success => colors.success,
|
||||
Tone::Warning => colors.warning,
|
||||
Tone::Danger => colors.danger,
|
||||
Tone::Info => colors.info,
|
||||
Tone::Accent => colors.accent,
|
||||
Tone::Neutral => colors.border_strong,
|
||||
}
|
||||
}
|
||||
|
||||
/// The share of time up across days with readings.
|
||||
pub fn uptime(days: &[Option<f32>]) -> Option<f32> {
|
||||
let read: Vec<f32> = days.iter().flatten().copied().collect();
|
||||
(!read.is_empty()).then(|| read.iter().sum::<f32>() / read.len() as f32)
|
||||
}
|
||||
|
||||
/// A service's days as bars, oldest first, each in the tone of its uptime, a quiet bar where there is no reading, with each day's date and share in its tip; the share up across all of them heads it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct UptimeBar {
|
||||
id: ElementId,
|
||||
name: SharedString,
|
||||
first: Date,
|
||||
days: Vec<Option<f32>>,
|
||||
}
|
||||
|
||||
impl UptimeBar {
|
||||
/// `days` are shares up from 0 to 1, oldest first from `first`.
|
||||
pub fn new(
|
||||
id: impl Into<ElementId>,
|
||||
name: impl Into<SharedString>,
|
||||
first: Date,
|
||||
days: impl IntoIterator<Item = Option<f32>>,
|
||||
) -> Self {
|
||||
let days: Vec<Option<f32>> = days.into_iter().collect();
|
||||
for share in days.iter().flatten() {
|
||||
assert!((0.0..=1.0).contains(share), "an uptime of {share}");
|
||||
}
|
||||
Self {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
first,
|
||||
days,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for UptimeBar {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let colors = theme.colors.clone();
|
||||
let whole = uptime(&self.days).map_or("No readings".to_string(), |share| {
|
||||
format::percent(share as f64, 2, false)
|
||||
});
|
||||
let bars = self.days.iter().enumerate().map(|(ix, day)| {
|
||||
let date = self.first.saturating_add(jiff::Span::new().days(ix as i64));
|
||||
let tip = match day {
|
||||
Some(share) => format!(
|
||||
"{} · {}",
|
||||
date.strftime("%b %-d"),
|
||||
format::percent(*share as f64, 2, false)
|
||||
),
|
||||
None => format!("{} · no reading", date.strftime("%b %-d")),
|
||||
};
|
||||
let color = day.map_or(colors.border, |share| tone_color(day_tone(share), &colors));
|
||||
div()
|
||||
.id((self.id.clone(), format!("day-{ix}")))
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.h_full()
|
||||
.rounded(theme.radius(Radius::Sm))
|
||||
.bg(color)
|
||||
.tooltip(Tooltip::text(tip))
|
||||
});
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(div().flex_1().min_w_0().child(Ellipsis::new(self.name)))
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(whole),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.gap_0p5()
|
||||
.h(theme.control_height(crate::theme::ControlSize::Sm))
|
||||
.children(bars),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a service stands.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Health {
|
||||
Operational,
|
||||
Maintenance,
|
||||
Degraded,
|
||||
PartialOutage,
|
||||
MajorOutage,
|
||||
}
|
||||
|
||||
impl Health {
|
||||
pub fn words(self) -> &'static str {
|
||||
match self {
|
||||
Health::Operational => "Operational",
|
||||
Health::Maintenance => "Under maintenance",
|
||||
Health::Degraded => "Degraded",
|
||||
Health::PartialOutage => "Partial outage",
|
||||
Health::MajorOutage => "Major outage",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tone(self) -> Tone {
|
||||
match self {
|
||||
Health::Operational => Tone::Success,
|
||||
Health::Maintenance => Tone::Info,
|
||||
Health::Degraded => Tone::Warning,
|
||||
Health::PartialOutage | Health::MajorOutage => Tone::Danger,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A service: its name, where it stands, and its uptime share when known.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Service {
|
||||
pub name: SharedString,
|
||||
pub health: Health,
|
||||
pub uptime: Option<f32>,
|
||||
}
|
||||
|
||||
/// The line over a set of services: the worst of them.
|
||||
pub fn overall(services: &[Service]) -> Health {
|
||||
services
|
||||
.iter()
|
||||
.map(|service| service.health)
|
||||
.max()
|
||||
.expect("a status lists a service")
|
||||
}
|
||||
|
||||
/// Services under a line for the whole system, each with where it stands and its uptime.
|
||||
#[derive(IntoElement)]
|
||||
pub struct ServiceStatus {
|
||||
services: Vec<Service>,
|
||||
}
|
||||
|
||||
impl ServiceStatus {
|
||||
pub fn new(services: impl IntoIterator<Item = Service>) -> Self {
|
||||
let services: Vec<_> = services.into_iter().collect();
|
||||
assert!(!services.is_empty(), "service status: no service");
|
||||
Self { services }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for ServiceStatus {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let whole = overall(&self.services);
|
||||
let headline = match whole {
|
||||
Health::Operational => "All systems operational".to_string(),
|
||||
worst => format!("Some systems: {}", worst.words().to_lowercase()),
|
||||
};
|
||||
div()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.pb_3()
|
||||
.font_weight(FontWeight::SEMIBOLD)
|
||||
.child(Badge::new(whole.words()).tone(whole.tone()).dot())
|
||||
.child(div().min_w_0().child(Ellipsis::new(headline))),
|
||||
)
|
||||
.children(self.services.into_iter().map(|service| {
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.gap_2()
|
||||
.py_2()
|
||||
.border_t_1()
|
||||
.border_color(theme.colors.border)
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(div().flex_1().min_w_0().child(Ellipsis::new(service.name)))
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.children(service.uptime.map(|share| {
|
||||
div()
|
||||
.text_color(theme.colors.fg_muted)
|
||||
.child(format::percent(share as f64, 2, false))
|
||||
}))
|
||||
.child(
|
||||
Badge::new(service.health.words())
|
||||
.tone(service.health.tone())
|
||||
.dot(),
|
||||
),
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// What a health check found.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Check {
|
||||
Passing,
|
||||
Warning,
|
||||
Failing,
|
||||
Pending,
|
||||
}
|
||||
|
||||
/// A health check's state as a dot and a word, with when it last ran and how long it took.
|
||||
#[derive(IntoElement)]
|
||||
pub struct HealthCheck {
|
||||
name: SharedString,
|
||||
state: Check,
|
||||
ran: Option<(Timestamp, Timestamp)>,
|
||||
took_ms: Option<u32>,
|
||||
}
|
||||
|
||||
impl HealthCheck {
|
||||
pub fn new(name: impl Into<SharedString>, state: Check) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
state,
|
||||
ran: None,
|
||||
took_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// When it last ran, dated against `now`.
|
||||
pub fn ran(mut self, at: Timestamp, now: Timestamp) -> Self {
|
||||
self.ran = Some((at, now));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn took(mut self, ms: u32) -> Self {
|
||||
self.took_ms = Some(ms);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for HealthCheck {
|
||||
fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let theme = cx.theme();
|
||||
let (tone, words) = match self.state {
|
||||
Check::Passing => (Tone::Success, "Passing"),
|
||||
Check::Warning => (Tone::Warning, "Warning"),
|
||||
Check::Failing => (Tone::Danger, "Failing"),
|
||||
Check::Pending => (Tone::Neutral, "Pending"),
|
||||
};
|
||||
let mut detail = Vec::new();
|
||||
if let Some((at, now)) = self.ran {
|
||||
detail.push(format!("ran {}", format::relative(at, now)));
|
||||
}
|
||||
if let Some(ms) = self.took_ms {
|
||||
detail.push(format!("{ms} ms"));
|
||||
}
|
||||
div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.items_center()
|
||||
.gap_x_2()
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w(theme.label_width())
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.size(theme.dashboard().dot)
|
||||
.rounded_full()
|
||||
.bg(tone_color(tone, &theme.colors)),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().child(Ellipsis::new(self.name))),
|
||||
)
|
||||
.child(div().flex_none().text_color(theme.colors.fg_muted).child(
|
||||
if detail.is_empty() {
|
||||
words.to_string()
|
||||
} else {
|
||||
format!("{words} · {}", detail.join(" · "))
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Health, Service, overall, uptime};
|
||||
|
||||
#[test]
|
||||
fn uptime_averages_the_days_with_readings_and_the_worst_service_leads() {
|
||||
assert_eq!(uptime(&[Some(1.0), None, Some(0.98)]), Some(0.99));
|
||||
assert_eq!(uptime(&[None]), None);
|
||||
let service = |health| Service {
|
||||
name: "s".into(),
|
||||
health,
|
||||
uptime: None,
|
||||
};
|
||||
assert_eq!(
|
||||
overall(&[
|
||||
service(Health::Operational),
|
||||
service(Health::Degraded),
|
||||
service(Health::Maintenance)
|
||||
]),
|
||||
Health::Degraded
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_days_tone_turns_at_three_nines_and_two() {
|
||||
use crate::data_display::Tone;
|
||||
assert_eq!(super::day_tone(0.9995), Tone::Success);
|
||||
assert_eq!(super::day_tone(0.995), Tone::Warning);
|
||||
assert_eq!(super::day_tone(0.985), Tone::Danger);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, IntoElement, KeyBinding, ParentElement, Render, Styled,
|
||||
TestAppContext, VisualTestContext, Window, div, px,
|
||||
};
|
||||
|
||||
use super::{
|
||||
AlertList, AlertState, DashboardCard, DashboardFilter, DashboardFilterBar, DashboardGrid,
|
||||
EventStream, MonitorAlert, StreamEvent, Tile, TimeWindow,
|
||||
};
|
||||
use crate::{
|
||||
data_display::Tone,
|
||||
forms::Choice,
|
||||
primitives::{FocusNext, Severity},
|
||||
theme::Theme,
|
||||
};
|
||||
|
||||
/// A view that shows a grid of three tiles and keeps the tiles it heard.
|
||||
struct Wall {
|
||||
tiles: Vec<Tile>,
|
||||
heard: Vec<Vec<Tile>>,
|
||||
width: f32,
|
||||
}
|
||||
|
||||
impl Render for Wall {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let owner = cx.entity();
|
||||
let grid = self.tiles.iter().fold(
|
||||
DashboardGrid::new("grid", 12, self.tiles.clone()).on_change(move |tiles, _, cx| {
|
||||
owner.update(cx, |wall, cx| {
|
||||
wall.tiles = tiles.clone();
|
||||
wall.heard.push(tiles);
|
||||
cx.notify();
|
||||
})
|
||||
}),
|
||||
|grid, tile| grid.card(tile.key.clone(), DashboardCard::new(tile.key.clone())),
|
||||
);
|
||||
div().w(px(self.width)).child(grid)
|
||||
}
|
||||
}
|
||||
|
||||
fn settle(cx: &mut VisualTestContext) {
|
||||
for _ in 0..3 {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
}
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
fn wall(cx: &mut TestAppContext) -> (Entity<Wall>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
|
||||
});
|
||||
let (host, cx) = cx.add_window_view(|_, _| Wall {
|
||||
tiles: vec![
|
||||
Tile::new("a", (0, 0), (6, 2)),
|
||||
Tile::new("b", (6, 0), (6, 2)),
|
||||
Tile::new("c", (0, 2), (12, 2)),
|
||||
],
|
||||
heard: Vec::new(),
|
||||
width: 1200.0,
|
||||
});
|
||||
settle(cx);
|
||||
(host, cx)
|
||||
}
|
||||
|
||||
fn place(host: &Entity<Wall>, key: &str, cx: &mut VisualTestContext) -> (u32, u32, u32, u32) {
|
||||
host.read_with(cx, |wall, _| {
|
||||
let tile = wall
|
||||
.tiles
|
||||
.iter()
|
||||
.find(|tile| tile.key == key)
|
||||
.expect("a tile");
|
||||
(tile.x, tile.y, tile.w, tile.h)
|
||||
})
|
||||
}
|
||||
|
||||
fn press(key: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes(key);
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
/// The first Tab stop is the first tile the grid draws, a.
|
||||
#[gpui::test]
|
||||
fn arrows_step_a_focused_tile_past_its_neighbours_and_shift_grows_it(cx: &mut TestAppContext) {
|
||||
let (host, cx) = wall(cx);
|
||||
cx.update(|window, _| window.focus_next());
|
||||
settle(cx);
|
||||
press("down", cx);
|
||||
assert_eq!(place(&host, "a", cx), (0, 4, 6, 2), "a went below c");
|
||||
press("up", cx);
|
||||
assert_eq!(place(&host, "a", cx), (0, 0, 6, 2), "and back on top");
|
||||
press("shift-right", cx);
|
||||
assert_eq!(place(&host, "a", cx), (0, 0, 7, 2), "a grew a column");
|
||||
assert_eq!(place(&host, "b", cx), (6, 2, 6, 2), "pushing b below it");
|
||||
assert_eq!(place(&host, "c", cx), (0, 4, 12, 2), "and c below b");
|
||||
}
|
||||
|
||||
type Part = fn(&Board, Entity<Board>) -> AnyElement;
|
||||
|
||||
/// A view that shows one dashboard part, keeps what it heard, and holds how many events have come.
|
||||
struct Board {
|
||||
part: Part,
|
||||
said: Vec<String>,
|
||||
events: usize,
|
||||
}
|
||||
|
||||
impl Render for Board {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
div().w(px(640.0)).child((self.part)(self, cx.entity()))
|
||||
}
|
||||
}
|
||||
|
||||
fn board(part: Part, cx: &mut TestAppContext) -> (Entity<Board>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
Theme::update(cx, |theme| theme.reduced_motion = true);
|
||||
crate::forms::bind_keys(cx);
|
||||
cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]);
|
||||
});
|
||||
let (host, cx) = cx.add_window_view(|_, _| Board {
|
||||
part,
|
||||
said: Vec::new(),
|
||||
events: 2,
|
||||
});
|
||||
settle(cx);
|
||||
(host, cx)
|
||||
}
|
||||
|
||||
fn say(owner: &Entity<Board>, words: String, cx: &mut App) {
|
||||
owner.update(cx, |board, cx| {
|
||||
board.said.push(words);
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
fn said(host: &Entity<Board>, cx: &mut VisualTestContext) -> Vec<String> {
|
||||
host.read_with(cx, |board, _| board.said.clone())
|
||||
}
|
||||
|
||||
fn tab(stops: usize, cx: &mut VisualTestContext) {
|
||||
cx.update(|window, _| {
|
||||
window.blur();
|
||||
(0..stops).for_each(|_| window.focus_next());
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn tap(key: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes(key);
|
||||
cx.simulate_event(gpui::KeyUpEvent {
|
||||
keystroke: gpui::Keystroke::parse(key).expect("a key"),
|
||||
});
|
||||
settle(cx);
|
||||
}
|
||||
|
||||
fn now() -> jiff::Timestamp {
|
||||
"2026-09-27T12:00:00Z".parse().expect("a time")
|
||||
}
|
||||
|
||||
fn alerts(_: &Board, owner: Entity<Board>) -> AnyElement {
|
||||
let alert = |key: &str, state| MonitorAlert {
|
||||
key: key.to_string().into(),
|
||||
title: key.to_string().into(),
|
||||
source: "prometheus".into(),
|
||||
severity: Severity::Danger,
|
||||
state,
|
||||
since: now(),
|
||||
};
|
||||
let opened = owner.clone();
|
||||
AlertList::new(
|
||||
"alerts",
|
||||
[
|
||||
alert("cpu", AlertState::Firing),
|
||||
alert("disk", AlertState::Resolved),
|
||||
],
|
||||
now(),
|
||||
)
|
||||
.on_acknowledge(move |key, _, cx| say(&owner, format!("ack {key}"), cx))
|
||||
.on_open(move |key, _, cx| say(&opened, format!("open {key}"), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Stops: the list, then the firing alert's Acknowledge; a resolved alert has none.
|
||||
#[gpui::test]
|
||||
fn a_firing_alert_is_acknowledged_from_its_row_and_enter_opens_it(cx: &mut TestAppContext) {
|
||||
let (host, cx) = board(alerts, cx);
|
||||
tab(2, cx);
|
||||
tap("space", cx);
|
||||
tab(3, cx);
|
||||
tap("enter", cx);
|
||||
assert_eq!(
|
||||
said(&host, cx),
|
||||
["ack cpu", "open cpu"],
|
||||
"the third stop wraps to the list"
|
||||
);
|
||||
}
|
||||
|
||||
fn stream(board: &Board, _: Entity<Board>) -> AnyElement {
|
||||
let events = (0..board.events).map(|ix| StreamEvent {
|
||||
key: format!("e{ix}").into(),
|
||||
kind: "deploy".into(),
|
||||
text: format!("event {ix}").into(),
|
||||
tone: Tone::Neutral,
|
||||
at: now(),
|
||||
});
|
||||
EventStream::new("stream", events, now()).into_any_element()
|
||||
}
|
||||
|
||||
/// Stops: the deploy chip, then Pause.
|
||||
#[gpui::test]
|
||||
fn a_paused_stream_counts_what_came_since(cx: &mut TestAppContext) {
|
||||
let (host, cx) = board(stream, cx);
|
||||
assert!(cx.debug_bounds("event-stream-live").is_some());
|
||||
tab(2, cx);
|
||||
tap("space", cx);
|
||||
assert!(
|
||||
cx.debug_bounds("event-stream-held-0").is_some(),
|
||||
"nothing new yet"
|
||||
);
|
||||
host.update(cx, |board, cx| {
|
||||
board.events = 4;
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
assert!(
|
||||
cx.debug_bounds("event-stream-held-2").is_some(),
|
||||
"two came while it held"
|
||||
);
|
||||
}
|
||||
|
||||
fn filters(_: &Board, owner: Entity<Board>) -> AnyElement {
|
||||
let filter = |key: &str| DashboardFilter {
|
||||
key: key.to_string().into(),
|
||||
name: key.to_string().into(),
|
||||
choices: vec![Choice::new("a", "A"), Choice::new("b", "B")],
|
||||
picked: Some("a".into()),
|
||||
};
|
||||
DashboardFilterBar::new(
|
||||
"filters",
|
||||
TimeWindow::ALL[0],
|
||||
[filter("region"), filter("env")],
|
||||
)
|
||||
.on_window(|_, _, _| {})
|
||||
.on_filter(move |key, value, _, cx| say(&owner, format!("{key} {value:?}"), cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// Stops: the window, the two filters, then Clear.
|
||||
#[gpui::test]
|
||||
fn clear_lets_every_filter_go(cx: &mut TestAppContext) {
|
||||
let (host, cx) = board(filters, cx);
|
||||
tab(4, cx);
|
||||
tap("space", cx);
|
||||
assert_eq!(said(&host, cx), ["region None", "env None"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_narrow_grid_stacks_its_tiles_in_reading_order(cx: &mut TestAppContext) {
|
||||
let (host, cx) = wall(cx);
|
||||
host.update(cx, |wall, cx| {
|
||||
wall.width = 300.0;
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
let mut bounds = |key: &'static str| cx.debug_bounds(key).expect("a stacked tile");
|
||||
let (a, b, c) = (
|
||||
bounds("stacked-a"),
|
||||
bounds("stacked-b"),
|
||||
bounds("stacked-c"),
|
||||
);
|
||||
assert_eq!(
|
||||
(a.left(), b.left(), c.left()),
|
||||
(a.left(), a.left(), a.left()),
|
||||
"one column"
|
||||
);
|
||||
assert!(
|
||||
a.top() < b.top() && b.top() < c.top(),
|
||||
"a, b, then c: {a:?} {b:?} {c:?}"
|
||||
);
|
||||
assert_eq!(a.size.width, px(300.0), "each fills the width");
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use gpui::SharedString;
|
||||
|
||||
/// A tile's place on a dashboard grid: its key, and its column, row, width and height in cells.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Tile {
|
||||
pub key: SharedString,
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
pub w: u32,
|
||||
pub h: u32,
|
||||
}
|
||||
|
||||
impl Tile {
|
||||
pub fn new(key: impl Into<SharedString>, (x, y): (u32, u32), (w, h): (u32, u32)) -> Self {
|
||||
assert!(w > 0 && h > 0, "a tile of {w} by {h} cells");
|
||||
Self {
|
||||
key: key.into(),
|
||||
x,
|
||||
y,
|
||||
w,
|
||||
h,
|
||||
}
|
||||
}
|
||||
|
||||
fn overlaps(&self, other: &Tile) -> bool {
|
||||
self.x < other.x + other.w
|
||||
&& other.x < self.x + self.w
|
||||
&& self.y < other.y + other.h
|
||||
&& other.y < self.y + self.h
|
||||
}
|
||||
|
||||
/// This tile kept inside `columns`: at least a cell each way, no wider than the grid, and not past its right edge.
|
||||
fn fitted(mut self, columns: u32) -> Tile {
|
||||
self.w = self.w.clamp(1, columns);
|
||||
self.h = self.h.max(1);
|
||||
self.x = self.x.min(columns - self.w);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Tiles risen as far as the ones above allow, top first.
|
||||
fn compacted(mut tiles: Vec<Tile>) -> Vec<Tile> {
|
||||
tiles.sort_by_key(|tile| (tile.y, tile.x));
|
||||
let mut out: Vec<Tile> = Vec::new();
|
||||
for mut tile in tiles {
|
||||
while tile.y > 0 {
|
||||
let higher = Tile {
|
||||
y: tile.y - 1,
|
||||
..tile.clone()
|
||||
};
|
||||
if out.iter().any(|placed| placed.overlaps(&higher)) {
|
||||
break;
|
||||
}
|
||||
tile = higher;
|
||||
}
|
||||
out.push(tile);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The tiles once `to` takes the place of the tile of its key: it goes where it was put, the tiles it lands on move down below it, then every tile, it too, rises as far as the ones above allow.
|
||||
pub fn arranged(tiles: &[Tile], to: Tile, columns: u32) -> Vec<Tile> {
|
||||
assert!(columns > 0, "a grid of no columns");
|
||||
assert!(
|
||||
tiles.iter().any(|tile| tile.key == to.key),
|
||||
"no tile {} on the grid",
|
||||
to.key
|
||||
);
|
||||
let mut placed = vec![to.clone().fitted(columns)];
|
||||
let mut rest: Vec<Tile> = tiles
|
||||
.iter()
|
||||
.filter(|tile| tile.key != to.key)
|
||||
.cloned()
|
||||
.collect();
|
||||
rest.sort_by_key(|tile| (tile.y, tile.x));
|
||||
for mut tile in rest {
|
||||
while let Some(hit) = placed.iter().find(|placed| placed.overlaps(&tile)) {
|
||||
tile.y = hit.y + hit.h;
|
||||
}
|
||||
placed.push(tile);
|
||||
}
|
||||
compacted(placed)
|
||||
}
|
||||
|
||||
/// Where a tile goes on a step down or up: below the next tile under it, or into the place of the next one above; none at the edge. Both overlap its columns.
|
||||
pub fn stepped(tiles: &[Tile], key: &str, down: bool) -> Option<Tile> {
|
||||
let tile = tiles
|
||||
.iter()
|
||||
.find(|tile| tile.key == key)
|
||||
.unwrap_or_else(|| panic!("no tile {key} on the grid"));
|
||||
let beside = |other: &&Tile| {
|
||||
other.key != tile.key && other.x < tile.x + tile.w && tile.x < other.x + other.w
|
||||
};
|
||||
if down {
|
||||
tiles
|
||||
.iter()
|
||||
.filter(beside)
|
||||
.filter(|other| other.y >= tile.y + tile.h)
|
||||
.min_by_key(|other| other.y)
|
||||
.map(|next| Tile {
|
||||
y: next.y + next.h,
|
||||
..tile.clone()
|
||||
})
|
||||
} else {
|
||||
tiles
|
||||
.iter()
|
||||
.filter(beside)
|
||||
.filter(|other| other.y + other.h <= tile.y)
|
||||
.max_by_key(|other| other.y)
|
||||
.map(|above| Tile {
|
||||
y: above.y,
|
||||
..tile.clone()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{Tile, arranged, stepped};
|
||||
|
||||
fn at(tiles: &[Tile], key: &str) -> (u32, u32, u32, u32) {
|
||||
let tile = tiles.iter().find(|tile| tile.key == key).expect("a tile");
|
||||
(tile.x, tile.y, tile.w, tile.h)
|
||||
}
|
||||
|
||||
fn grid() -> Vec<Tile> {
|
||||
vec![
|
||||
Tile::new("a", (0, 0), (6, 2)),
|
||||
Tile::new("b", (6, 0), (6, 2)),
|
||||
Tile::new("c", (0, 2), (12, 3)),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_dropped_on_another_pushes_it_below() {
|
||||
let moved = arranged(&grid(), Tile::new("b", (0, 0), (6, 2)), 12);
|
||||
assert_eq!(at(&moved, "b"), (0, 0, 6, 2), "the dropped tile stays");
|
||||
assert_eq!(at(&moved, "a"), (0, 2, 6, 2), "a goes below b");
|
||||
assert_eq!(at(&moved, "c"), (0, 4, 12, 3), "and c below a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiles_rise_into_the_gap_a_tile_leaves() {
|
||||
let moved = arranged(&grid(), Tile::new("a", (0, 9), (6, 2)), 12);
|
||||
assert_eq!(at(&moved, "c"), (0, 2, 12, 3), "c keeps below b");
|
||||
assert_eq!(at(&moved, "a"), (0, 5, 6, 2), "a rises to rest on c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_stays_inside_the_grid() {
|
||||
let moved = arranged(&grid(), Tile::new("a", (10, 0), (6, 2)), 12);
|
||||
assert_eq!(
|
||||
at(&moved, "a"),
|
||||
(6, 0, 6, 2),
|
||||
"pulled back from the right edge"
|
||||
);
|
||||
let wide = arranged(&grid(), Tile::new("b", (0, 0), (20, 1)), 12);
|
||||
assert_eq!(at(&wide, "b"), (0, 0, 12, 1), "no wider than the grid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_step_down_passes_the_tile_under_and_a_step_up_takes_its_place() {
|
||||
let down = stepped(&grid(), "a", true).expect("c lies under a");
|
||||
assert_eq!((down.x, down.y), (0, 5), "a steps under the bottom of c");
|
||||
let moved = arranged(&grid(), down, 12);
|
||||
assert_eq!(at(&moved, "c"), (0, 2, 12, 3), "c rests under b");
|
||||
assert_eq!(at(&moved, "a"), (0, 5, 6, 2), "a rests under c");
|
||||
let up = stepped(&moved, "a", false).expect("c lies over a");
|
||||
assert_eq!(
|
||||
at(&arranged(&moved, up, 12), "a"),
|
||||
(0, 0, 6, 2),
|
||||
"back on top"
|
||||
);
|
||||
assert_eq!(stepped(&grid(), "b", false), None, "nothing over b");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod canvas;
|
||||
pub mod charts;
|
||||
pub mod chat;
|
||||
pub mod collab;
|
||||
pub mod dashboard;
|
||||
pub mod data_display;
|
||||
pub mod debug;
|
||||
pub mod devtools;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
use gpui::Rems;
|
||||
|
||||
use super::{Theme, tokens::px_to_rems};
|
||||
|
||||
/// Dashboard measures.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct DashboardSizes {
|
||||
/// A grid row, tall.
|
||||
pub row: Rems,
|
||||
/// Between tiles, either way.
|
||||
pub gap: Rems,
|
||||
/// A status dot, square.
|
||||
pub dot: Rems,
|
||||
/// Under this width the grid stacks its tiles.
|
||||
pub stack_below: Rems,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn dashboard(&self) -> DashboardSizes {
|
||||
DashboardSizes {
|
||||
row: px_to_rems(88.0),
|
||||
gap: px_to_rems(16.0),
|
||||
dot: px_to_rems(8.0),
|
||||
stack_below: px_to_rems(560.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod calendar;
|
||||
mod canvas;
|
||||
mod chart;
|
||||
mod dashboard;
|
||||
mod files;
|
||||
mod generative;
|
||||
mod media;
|
||||
@@ -17,6 +18,7 @@ use crate::motion;
|
||||
pub use calendar::CalendarSizes;
|
||||
pub use canvas::CanvasSizes;
|
||||
pub use chart::ChartSizes;
|
||||
pub use dashboard::DashboardSizes;
|
||||
pub use files::FileSizes;
|
||||
pub use generative::GenerativeSizes;
|
||||
pub use media::MediaSizes;
|
||||
|
||||
+18
-18
@@ -123,26 +123,26 @@
|
||||
|
||||
## T34 Dashboard — `src/dashboard`
|
||||
|
||||
- [ ] Dashboard Grid
|
||||
- [ ] Widget / DashboardCard
|
||||
- [ ] StatCard → `data_display::Statistic`
|
||||
- [ ] MetricsChart → `charts::LineChart`
|
||||
- [ ] AlertList
|
||||
- [ ] IncidentCard
|
||||
- [ ] UptimeBar
|
||||
- [ ] ServiceStatus
|
||||
- [ ] HealthCheck Indicator
|
||||
- [ ] LogStream → `terminal::LogViewer`
|
||||
- [ ] TraceWaterfall → `agent::TraceViewer`
|
||||
- [ ] EventStream
|
||||
- [ ] ResourceGauge → `data_display::Gauge`
|
||||
- [ ] SystemMonitor
|
||||
- [ ] ProcessTable → `terminal::ProcessList`
|
||||
- [ ] NetworkGraph (throughput) → `charts::RealtimeChart`
|
||||
- [x] Dashboard Grid
|
||||
- [x] Widget / DashboardCard
|
||||
- [x] StatCard → `data_display::Statistic`
|
||||
- [x] MetricsChart → `charts::LineChart`
|
||||
- [x] AlertList
|
||||
- [x] IncidentCard
|
||||
- [x] UptimeBar
|
||||
- [x] ServiceStatus
|
||||
- [x] HealthCheck Indicator
|
||||
- [x] LogStream → `terminal::LogViewer`
|
||||
- [x] TraceWaterfall → `debug::TimelineProfiler`
|
||||
- [x] EventStream
|
||||
- [x] ResourceGauge → `data_display::Gauge`
|
||||
- [x] SystemMonitor → `devtools::ResourceMonitor`
|
||||
- [x] ProcessTable → `terminal::ProcessList`
|
||||
- [x] NetworkGraph (throughput) → `charts::RealtimeChart`
|
||||
- [ ] MapView / GeoHeatmap → `maps::MapView`, `maps::GeoHeatmap` (T41)
|
||||
- [ ] WorldMap → `maps::WorldMap` (T41)
|
||||
- [ ] RefreshIntervalSelector
|
||||
- [ ] DashboardFilterBar
|
||||
- [x] RefreshIntervalSelector
|
||||
- [x] DashboardFilterBar
|
||||
|
||||
## T35 Settings — `src/settings`
|
||||
|
||||
|
||||
Reference in new issue
Block a user