diff --git a/examples/gallery/pages/dashboard/board.rs b/examples/gallery/pages/dashboard/board.rs new file mode 100644 index 0000000..6221981 --- /dev/null +++ b/examples/gallery/pages/dashboard/board.rs @@ -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, + window: TimeWindow, + environment: Option, + service: Option, + every: Option, + /// 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 = (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)), + ) +} diff --git a/examples/gallery/pages/dashboard/mod.rs b/examples/gallery/pages/dashboard/mod.rs new file mode 100644 index 0000000..a16941a --- /dev/null +++ b/examples/gallery/pages/dashboard/mod.rs @@ -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() +} diff --git a/examples/gallery/pages/dashboard/monitor.rs b/examples/gallery/pages/dashboard/monitor.rs new file mode 100644 index 0000000..b02f571 --- /dev/null +++ b/examples/gallery/pages/dashboard/monitor.rs @@ -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()))) +} diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index 001e6bb..aa56159 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -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, ]; diff --git a/src/dashboard/alerts.rs b/src/dashboard/alerts.rs new file mode 100644 index 0000000..5df8f15 --- /dev/null +++ b/src/dashboard/alerts.rs @@ -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; + +/// 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, + now: Timestamp, + on_open: Option, + on_acknowledge: Option, +} + +impl AlertList { + /// `now` dates each alert. + pub fn new( + id: impl Into, + alerts: impl IntoIterator, + 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, + pub started: Timestamp, + pub updates: Vec, +} + +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"] + ); + } +} diff --git a/src/dashboard/card.rs b/src/dashboard/card.rs new file mode 100644 index 0000000..fefe4b1 --- /dev/null +++ b/src/dashboard/card.rs @@ -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, + actions: Vec, + body: Option, + footer: Option, +} + +impl DashboardCard { + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + subtitle: None, + actions: Vec::new(), + body: None, + footer: None, + } + } + + pub fn subtitle(mut self, text: impl Into) -> 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) + })) + } +} diff --git a/src/dashboard/controls.rs b/src/dashboard/controls.rs new file mode 100644 index 0000000..562830b --- /dev/null +++ b/src/dashboard/controls.rs @@ -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, &mut Window, &mut App)>; +type Run = Rc; + +/// 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, + last: Timestamp, + now: Timestamp, + on_change: Option, + on_refresh: Option, +} + +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, + every: Option, + 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, &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 { + 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, + pub picked: Option, +} + +type OnWindow = Rc; +type OnFilter = Rc, &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, + on_window: Option, + on_filter: Option, +} + +impl DashboardFilterBar { + pub fn new( + id: impl Into, + window: TimeWindow, + filters: impl IntoIterator, + ) -> 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, &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 = 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)); + } +} diff --git a/src/dashboard/events.rs b/src/dashboard/events.rs new file mode 100644 index 0000000..456899a --- /dev/null +++ b/src/dashboard/events.rs @@ -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, + held: Option, +} + +/// 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, + now: Timestamp, +} + +impl EventStream { + /// `events` oldest first, dated against `now`. + pub fn new( + id: impl Into, + events: impl IntoIterator, + 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, +) -> 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 = 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::>() + }; + assert_eq!(keys(shown(&events, &[], None)), ["4", "3", "2", "1"]); + assert_eq!( + keys(shown(&events, &["deploy".into()], Some(3))), + ["3", "1"] + ); + } +} diff --git a/src/dashboard/grid.rs b/src/dashboard/grid.rs new file mode 100644 index 0000000..f1b874e --- /dev/null +++ b/src/dashboard/grid.rs @@ -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, &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, Tile)>, + landing: Option, +} + +/// 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, + cards: Vec<(SharedString, AnyElement)>, + on_change: Option, +} + +impl DashboardGrid { + pub fn new( + id: impl Into, + columns: u32, + tiles: impl IntoIterator, + ) -> 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, card: impl IntoElement) -> Self { + self.cards.push((key.into(), card.into_any_element())); + self + } + + pub fn on_change( + mut self, + handler: impl Fn(Vec, &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) -> 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, + mut cards: Vec<(SharedString, AnyElement)>, + board: gpui::Entity, + 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 = 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, + 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::(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" + ); + } +} diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs new file mode 100644 index 0000000..64a0e47 --- /dev/null +++ b/src/dashboard/mod.rs @@ -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}; diff --git a/src/dashboard/status.rs b/src/dashboard/status.rs new file mode 100644 index 0000000..1c74256 --- /dev/null +++ b/src/dashboard/status.rs @@ -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]) -> Option { + let read: Vec = days.iter().flatten().copied().collect(); + (!read.is_empty()).then(|| read.iter().sum::() / 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>, +} + +impl UptimeBar { + /// `days` are shares up from 0 to 1, oldest first from `first`. + pub fn new( + id: impl Into, + name: impl Into, + first: Date, + days: impl IntoIterator>, + ) -> Self { + let days: Vec> = 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, +} + +/// 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, +} + +impl ServiceStatus { + pub fn new(services: impl IntoIterator) -> 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, +} + +impl HealthCheck { + pub fn new(name: impl Into, 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); + } +} diff --git a/src/dashboard/tests.rs b/src/dashboard/tests.rs new file mode 100644 index 0000000..fdc9444 --- /dev/null +++ b/src/dashboard/tests.rs @@ -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, + heard: Vec>, + width: f32, +} + +impl Render for Wall { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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, &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, 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) -> 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, + events: usize, +} + +impl Render for Board { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div().w(px(640.0)).child((self.part)(self, cx.entity())) + } +} + +fn board(part: Part, cx: &mut TestAppContext) -> (Entity, &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, words: String, cx: &mut App) { + owner.update(cx, |board, cx| { + board.said.push(words); + cx.notify(); + }); +} + +fn said(host: &Entity, cx: &mut VisualTestContext) -> Vec { + 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) -> 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) -> 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) -> 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"); +} diff --git a/src/dashboard/tiles.rs b/src/dashboard/tiles.rs new file mode 100644 index 0000000..b138417 --- /dev/null +++ b/src/dashboard/tiles.rs @@ -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, (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) -> Vec { + tiles.sort_by_key(|tile| (tile.y, tile.x)); + let mut out: Vec = 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 { + 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 = 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 { + 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 { + 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"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 95b9d73..e89c3ed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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; diff --git a/src/theme/dashboard.rs b/src/theme/dashboard.rs new file mode 100644 index 0000000..5adf54c --- /dev/null +++ b/src/theme/dashboard.rs @@ -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), + } + } +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index a6316e9..8f6b9d0 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -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; diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index ed8630a..ac5f083 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -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`