From dc3a02b6a390db61e2c79167703618e29a74e365 Mon Sep 17 00:00:00 2001 From: duyetbot Date: Mon, 24 Aug 2026 18:12:21 +0700 Subject: [PATCH 01/20] fix(build): compile Metal shaders at runtime on macOS Xcode's `metal` tool is missing when only Command Line Tools are installed. Enable gpui_platform/runtime_shaders so `cargo build -p chm-app` works, and keep the chm-app 0.1.1 lockfile bump. --- Cargo.lock | 2 +- Cargo.toml | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f248a78..2b53b4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -893,7 +893,7 @@ dependencies = [ [[package]] name = "chm-app" -version = "0.1.0" +version = "0.1.1" dependencies = [ "anyhow", "bezel", diff --git a/Cargo.toml b/Cargo.toml index bab1136..4b7944f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,10 @@ chm-telemetry = { path = "crates/chm-telemetry" } # bezel stack — pinned to exact revs; all gpui types flow through bezel::gpui. bezel = { git = "https://github.com/crabtalk/bezel", rev = "f86dbbfc84569cc69f3cba6ebb3d99d858dcb259" } gpui = { git = "https://github.com/crabtalk/zed", rev = "cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423", version = "0.2.2" } -gpui_platform = { git = "https://github.com/crabtalk/zed", rev = "cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423", features = ["font-kit", "x11"] } +# runtime_shaders compiles Metal shaders at launch so `cargo build` works on a +# Mac that only has Command Line Tools (no Xcode `metal` compiler). Prefer the +# precompiled path when Xcode is installed — drop this feature then. +gpui_platform = { git = "https://github.com/crabtalk/zed", rev = "cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423", features = ["font-kit", "x11", "runtime_shaders"] } # crates-io anyhow = "1" From 210efa4bff867d04dd1018a29088d7e756359331 Mon Sep 17 00:00:00 2001 From: duyetbot Date: Mon, 24 Aug 2026 18:12:21 +0700 Subject: [PATCH 02/20] feat(app): fill dashboard pages, CLI flags, and named profiles Replace the placeholder Queries/Merges/Replicas/Health/Tables/Traffic views with live tables and charts, poll the active page, and honor --connect, CHM_PROFILE, and CHM_CONFIG. Range chips only show on pages that use a time window; chart axes use stroked grid lines so labels stay readable on Metal. --- app/src/config.rs | 303 +++++++++++++++++++++++++++++ app/src/connect.rs | 4 +- app/src/lib.rs | 1 + app/src/main.rs | 17 ++ app/src/pages/health.rs | 111 +++++++++++ app/src/pages/merges.rs | 106 +++++++++++ app/src/pages/mod.rs | 53 +++++- app/src/pages/overview.rs | 205 ++++++++++---------- app/src/pages/queries.rs | 158 ++++++++++++++++ app/src/pages/replicas.rs | 118 ++++++++++++ app/src/pages/tables.rs | 111 +++++++++++ app/src/pages/traffic.rs | 94 +++++++++ app/src/shell.rs | 377 +++++++++++++++++++++++-------------- app/src/widgets/chart.rs | 48 ++--- crates/chm-core/src/lib.rs | 2 + 15 files changed, 1436 insertions(+), 272 deletions(-) create mode 100644 app/src/config.rs create mode 100644 app/src/pages/health.rs create mode 100644 app/src/pages/merges.rs create mode 100644 app/src/pages/queries.rs create mode 100644 app/src/pages/replicas.rs create mode 100644 app/src/pages/tables.rs create mode 100644 app/src/pages/traffic.rs diff --git a/app/src/config.rs b/app/src/config.rs new file mode 100644 index 0000000..f69ca12 --- /dev/null +++ b/app/src/config.rs @@ -0,0 +1,303 @@ +//! Connection profile, config.toml, CLI flags. +//! +//! Kept out of the GPUI shell so the parse/load path can be unit-tested +//! without opening a window. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use chm_clickhouse::ClickHouseClient; +use chm_cloud_api::CloudClient; +use chm_core::DataSource; + +/// Saved connection profile (`[profile]` table, or `[profiles.]`). +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct ProfileConfig { + /// "cloud" | "clickhouse" + pub mode: Option, + /// Cloud mode: API base URL. + #[serde(default)] + pub base_url: Option, + /// Cloud mode: API key. + #[serde(default)] + pub api_key: Option, + /// Direct mode: ClickHouse HTTP endpoint. + #[serde(default)] + pub url: Option, + /// Direct mode: user name. + #[serde(default)] + pub user: Option, + /// Direct mode: password. + #[serde(default)] + pub password: Option, + /// Release channel for the update check: "stable" | "beta". + #[serde(default)] + pub channel: Option, +} + +/// `[telemetry]` table — opt-in, default disabled. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct TelemetrySection { + #[serde(default)] + pub enabled: bool, +} + +/// Whole `config.toml`. +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct ConfigFile { + #[serde(default)] + pub profile: ProfileConfig, + /// Named profiles selected with `CHM_PROFILE=`. + #[serde(default)] + pub profiles: BTreeMap, + #[serde(default)] + pub telemetry: TelemetrySection, +} + +/// `/chmonitor/config.toml`, or `CHM_CONFIG` when set. +pub fn config_path() -> Option { + if let Some(path) = std::env::var_os("CHM_CONFIG").filter(|p| !p.is_empty()) { + return Some(PathBuf::from(path)); + } + dirs::config_dir().map(|d| d.join("chmonitor").join("config.toml")) +} + +/// `CHM_PROFILE` value when non-empty. +pub fn profile_name_from_env() -> Option { + std::env::var("CHM_PROFILE") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + +/// Read the saved profile, if a well-formed file exists. Any failure means +/// "no profile" and the app shows the Connect screen. +pub fn load_profile() -> Option { + load_profile_from(config_path()?.as_path(), profile_name_from_env().as_deref()) +} + +/// Load `[profile]` or `[profiles.]` from an explicit path. +pub fn load_profile_from(path: &Path, named: Option<&str>) -> Option { + let text = std::fs::read_to_string(path).ok()?; + let cfg: ConfigFile = toml::from_str(&text).ok()?; + match named.filter(|n| !n.is_empty()) { + Some(name) => cfg.profiles.get(name).cloned().filter(|p| p.mode.is_some()), + None => { + cfg.profile.mode.as_ref()?; + Some(cfg.profile) + } + } +} + +/// Build the boxed data source behind [`chm_core::DataSource`] for a saved +/// profile. `None` when required fields are missing. +pub fn source_from_profile(p: &ProfileConfig) -> Option> { + match p.mode.as_deref()? { + "cloud" => Some(Box::new(CloudClient::new( + p.base_url.clone()?, + p.api_key.clone(), + ))), + "clickhouse" => Some(Box::new(ClickHouseClient::new( + p.url.clone()?, + p.user.clone().unwrap_or_else(|| "default".into()), + p.password.clone(), + ))), + _ => None, + } +} + +/// Flags parsed from argv. Stored process-wide so `Shell::new` can read them +/// without threading gpui constructors. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Cli { + /// Force the Connect page even when a profile exists. + pub connect: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CliError { + Help, + Version, + Unknown(String), +} + +pub const HELP: &str = "\ +chmonitor desktop — ClickHouse monitoring + +Usage: + chm-app [--connect] + +Options: + --connect Open the Connect screen + -h, --help Show this help + -V, --version Print version + +Environment: + CHM_SMOKE=1 Use fixture data (no network) + CHM_PROFILE= Load [profiles.] from config.toml + CHM_CONFIG= Override config.toml path + CHM_UPDATE_URL= Override update manifest base +"; + +impl Cli { + pub fn parse(args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + let mut cli = Cli::default(); + for arg in args { + match arg.as_ref() { + "--connect" => cli.connect = true, + "-h" | "--help" => return Err(CliError::Help), + "-V" | "--version" => return Err(CliError::Version), + other => return Err(CliError::Unknown(other.to_string())), + } + } + Ok(cli) + } +} + +static CLI: OnceLock = OnceLock::new(); + +pub fn install_cli(cli: Cli) { + let _ = CLI.set(cli); +} + +pub fn cli() -> Cli { + CLI.get().cloned().unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_cfg(body: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "chm-config-tests-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&dir).unwrap(); + static N: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = dir.join(format!("{n}.toml")); + std::fs::write(&path, body).unwrap(); + path + } + + #[test] + fn cli_connect_and_help() { + assert_eq!(Cli::parse(["--connect"]).unwrap(), Cli { connect: true }); + assert_eq!(Cli::parse(Vec::<&str>::new()).unwrap(), Cli::default()); + assert_eq!(Cli::parse(["--help"]).unwrap_err(), CliError::Help); + assert_eq!(Cli::parse(["-V"]).unwrap_err(), CliError::Version); + assert!(matches!( + Cli::parse(["--nope"]), + Err(CliError::Unknown(s)) if s == "--nope" + )); + } + + #[test] + fn default_profile_requires_mode() { + let path = write_cfg("[profile]\nbase_url = \"https://x\"\n"); + assert!(load_profile_from(&path, None).is_none()); + } + + #[test] + fn default_profile_loads_cloud() { + let path = write_cfg( + "[profile]\nmode = \"cloud\"\nbase_url = \"https://acme.dash.chmonitor.dev\"\napi_key = \"k\"\n", + ); + let p = load_profile_from(&path, None).unwrap(); + assert_eq!(p.mode.as_deref(), Some("cloud")); + assert_eq!( + p.base_url.as_deref(), + Some("https://acme.dash.chmonitor.dev") + ); + let src = source_from_profile(&p).unwrap(); + assert_eq!(src.label(), "cloud: https://acme.dash.chmonitor.dev"); + } + + #[test] + fn named_profile_selected_over_default() { + let path = write_cfg( + r#" +[profile] +mode = "cloud" +base_url = "https://default.example" + +[profiles.work] +mode = "clickhouse" +url = "http://localhost:8123" +user = "alice" +"#, + ); + let def = load_profile_from(&path, None).unwrap(); + assert_eq!(def.mode.as_deref(), Some("cloud")); + let work = load_profile_from(&path, Some("work")).unwrap(); + assert_eq!(work.mode.as_deref(), Some("clickhouse")); + assert_eq!(work.user.as_deref(), Some("alice")); + let src = source_from_profile(&work).unwrap(); + assert_eq!(src.label(), "clickhouse: http://localhost:8123"); + assert!(load_profile_from(&path, Some("missing")).is_none()); + } + + #[test] + fn source_rejects_incomplete_and_unknown_modes() { + assert!(source_from_profile(&ProfileConfig::default()).is_none()); + assert!( + source_from_profile(&ProfileConfig { + mode: Some("cloud".into()), + ..Default::default() + }) + .is_none() + ); + assert!( + source_from_profile(&ProfileConfig { + mode: Some("clickhouse".into()), + ..Default::default() + }) + .is_none() + ); + assert!( + source_from_profile(&ProfileConfig { + mode: Some("ftp".into()), + url: Some("http://x".into()), + ..Default::default() + }) + .is_none() + ); + } + + #[test] + fn clickhouse_defaults_user_to_default() { + let src = source_from_profile(&ProfileConfig { + mode: Some("clickhouse".into()), + url: Some("http://ch:8123".into()), + ..Default::default() + }) + .unwrap(); + assert_eq!(src.label(), "clickhouse: http://ch:8123"); + } + + #[test] + fn save_roundtrip_keeps_named_profiles_and_telemetry() { + let mut cfg = ConfigFile::default(); + cfg.profile.mode = Some("cloud".into()); + cfg.profile.base_url = Some("https://a".into()); + cfg.profiles.insert( + "work".into(), + ProfileConfig { + mode: Some("clickhouse".into()), + url: Some("http://localhost:8123".into()), + ..Default::default() + }, + ); + cfg.telemetry.enabled = true; + let text = toml::to_string_pretty(&cfg).unwrap(); + let back: ConfigFile = toml::from_str(&text).unwrap(); + assert_eq!(back, cfg); + } +} diff --git a/app/src/connect.rs b/app/src/connect.rs index aa97cc0..e6c5abe 100644 --- a/app/src/connect.rs +++ b/app/src/connect.rs @@ -15,7 +15,7 @@ use bezel::theme::Theme; use bezel::ui::input::TextField; use bezel::ui::widgets::{ButtonStyle, Buttons}; -use crate::shell::{ProfileConfig, config_path, source_from_profile}; +use crate::config::{ProfileConfig, config_path, source_from_profile}; /// Fired after Save successfully writes config.toml. #[derive(Debug, Clone)] @@ -155,7 +155,7 @@ impl ConnectFlow { // already exists; otherwise start from defaults. let mut cfg = config_path() .and_then(|p| std::fs::read_to_string(p).ok()) - .and_then(|text| toml::from_str::(&text).ok()) + .and_then(|text| toml::from_str::(&text).ok()) .unwrap_or_default(); cfg.profile = profile.clone(); let out = match toml::to_string_pretty(&cfg) { diff --git a/app/src/lib.rs b/app/src/lib.rs index c2826ab..005e3b9 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -4,6 +4,7 @@ //! AGENT E OWNS widgets/ (chart, table, metric card). //! AGENT F/G/H fill pages/. Keep all gpui types via `bezel::gpui`. +pub mod config; pub mod connect; pub mod pages; pub mod shell; diff --git a/app/src/main.rs b/app/src/main.rs index b830535..5e826fa 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -13,11 +13,28 @@ use bezel::gpui::{ }; use bezel::theme; use bezel::ui; +use chm_app::config::{Cli, CliError}; use chm_app::shell::Shell; actions!(chm_app, [Quit]); fn main() { + match Cli::parse(std::env::args().skip(1)) { + Ok(cli) => chm_app::config::install_cli(cli), + Err(CliError::Help) => { + print!("{}", chm_app::config::HELP); + return; + } + Err(CliError::Version) => { + println!("chm-app {}", env!("CARGO_PKG_VERSION")); + return; + } + Err(CliError::Unknown(arg)) => { + eprintln!("unknown argument: {arg}\n{}", chm_app::config::HELP); + std::process::exit(2); + } + } + // Smoke gate: prove init runs to completion headless before any window is // opened. CHM_SMOKE also selects MockDataSource inside Shell (see shell.rs). let smoke = std::env::var("CHM_SMOKE").is_ok(); diff --git a/app/src/pages/health.rs b/app/src/pages/health.rs new file mode 100644 index 0000000..cb089f3 --- /dev/null +++ b/app/src/pages/health.rs @@ -0,0 +1,111 @@ +//! Cluster health summary. + +use chm_core::Health; + +use bezel::gpui::{Context, Render, div, prelude::*, px}; + +use crate::pages::status; +use crate::widgets::geometry::format_duration_ms; +use crate::widgets::metric_card; + +pub struct HealthPage { + data: Option, + error: Option, +} + +impl Default for HealthPage { + fn default() -> Self { + Self::new() + } +} + +impl HealthPage { + pub fn new() -> Self { + Self { + data: None, + error: None, + } + } + + pub fn set(&mut self, data: Result, cx: &mut Context) { + match data { + Ok(v) => { + self.data = Some(v); + self.error = None; + } + Err(e) => self.error = Some(e), + } + cx.notify(); + } +} + +impl Render for HealthPage { + fn render( + &mut self, + _window: &mut bezel::gpui::Window, + _cx: &mut Context, + ) -> impl bezel::gpui::IntoElement { + if let Some(err) = &self.error { + return status(format!("health unavailable: {err}")); + } + let Some(h) = &self.data else { + return status("loading health…"); + }; + let pool_pct = format!( + "{:.0}%", + (h.background_pool_utilization * 100.0).clamp(0.0, 999.0) + ); + div() + .flex() + .flex_col() + .gap(px(10.0)) + .w_full() + .child( + div() + .flex() + .flex_row() + .gap(px(10.0)) + .child(metric_card( + "status", + if h.ok { "ok" } else { "not ok" }, + None, + )) + .child(metric_card( + "readonly tables", + &h.readonly_tables.to_string(), + None, + )) + .child(metric_card( + "replication lag", + &format_duration_ms(h.replication_lag_max_sec * 1000.0), + None, + )) + .child(metric_card( + "zookeeper", + if h.zookeeper_available { + "available" + } else { + "unavailable" + }, + None, + )), + ) + .child( + div() + .flex() + .flex_row() + .gap(px(10.0)) + .child(metric_card( + "delayed inserts", + &h.delayed_inserts.to_string(), + None, + )) + .child(metric_card( + "distributed files", + &h.distributed_files_to_insert.to_string(), + None, + )) + .child(metric_card("background pool", &pool_pct, None)), + ) + } +} diff --git a/app/src/pages/merges.rs b/app/src/pages/merges.rs new file mode 100644 index 0000000..2e650fa --- /dev/null +++ b/app/src/pages/merges.rs @@ -0,0 +1,106 @@ +//! In-flight merges and mutations. + +use chm_core::MergeRow; + +use bezel::gpui::{Context, Render, prelude::*}; + +use crate::pages::status; +use crate::widgets::{CellVal, Column, data_table}; + +pub struct MergesPage { + data: Option>, + error: Option, +} + +impl Default for MergesPage { + fn default() -> Self { + Self::new() + } +} + +impl MergesPage { + pub fn new() -> Self { + Self { + data: None, + error: None, + } + } + + pub fn set(&mut self, data: Result, String>, cx: &mut Context) { + match data { + Ok(v) => { + self.data = Some(v); + self.error = None; + } + Err(e) => self.error = Some(e), + } + cx.notify(); + } +} + +impl Render for MergesPage { + fn render( + &mut self, + _window: &mut bezel::gpui::Window, + _cx: &mut Context, + ) -> impl bezel::gpui::IntoElement { + if let Some(err) = &self.error { + return status(format!("merges unavailable: {err}")).into_any_element(); + } + let Some(rows) = &self.data else { + return status("loading merges…").into_any_element(); + }; + if rows.is_empty() { + return status("no merges or mutations in flight").into_any_element(); + } + let columns = vec![ + Column { + name: "database".into(), + width: Some(110.0), + }, + Column { + name: "table".into(), + width: Some(140.0), + }, + Column { + name: "type".into(), + width: Some(80.0), + }, + Column { + name: "progress".into(), + width: Some(80.0), + }, + Column { + name: "parts".into(), + width: Some(72.0), + }, + Column { + name: "memory".into(), + width: Some(88.0), + }, + Column { + name: "elapsed".into(), + width: None, + }, + ]; + let body = rows + .iter() + .map(|r| { + vec![ + CellVal::Text(r.database.clone()), + CellVal::Text(r.table.clone()), + CellVal::Text(if r.is_mutation { + "mutation".into() + } else { + "merge".into() + }), + CellVal::Text(format!("{:.0}%", (r.progress * 100.0).clamp(0.0, 100.0))), + CellVal::Num(r.num_parts as f64), + CellVal::Bytes(r.total_memory_bytes), + CellVal::DurMs(r.elapsed_sec * 1000.0), + ] + }) + .collect(); + data_table(columns, body).into_any_element() + } +} diff --git a/app/src/pages/mod.rs b/app/src/pages/mod.rs index 706f01b..1117f0a 100644 --- a/app/src/pages/mod.rs +++ b/app/src/pages/mod.rs @@ -1,9 +1,15 @@ //! Page routing. AGENT D owns mod.rs; Agents F/G/H own the individual page //! files and will replace the placeholder bodies in shell.rs's `content`. -use bezel::gpui::{AnyElement, SharedString, div, prelude::*, px}; +use bezel::gpui::{AnyElement, FontWeight, SharedString, div, prelude::*, px}; +pub mod health; +pub mod merges; pub mod overview; +pub mod queries; +pub mod replicas; +pub mod tables; +pub mod traffic; /// Every sidebar destination, in keyboard-shortcut order (1-8). #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -48,6 +54,11 @@ impl Page { } } + /// Pages whose fetch takes the selected time range. + pub fn uses_range(self) -> bool { + matches!(self, Page::Overview | Page::Queries | Page::Traffic) + } + /// Sidebar glyph. Text markers until Agent E's icon widget lands; the /// sidebar renders whatever this returns, so swapping in real icons is a /// one-file change. @@ -69,3 +80,43 @@ impl Page { .into_any_element() } } + +pub(crate) fn status(text: impl Into) -> bezel::gpui::Div { + div() + .flex() + .flex_1() + .items_center() + .justify_center() + .text_color(bezel::theme::ink(0.45)) + .text_size(px(13.0)) + .child(text.into()) +} + +pub(crate) fn heading(title: &str) -> bezel::gpui::Div { + div() + .text_size(px(13.0)) + .font_weight(FontWeight::SEMIBOLD) + .child(SharedString::from(title.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_pages_have_stable_indexes_and_titles() { + assert_eq!(Page::ALL.len(), 8); + for (i, page) in Page::ALL.iter().enumerate() { + assert_eq!(page.index(), i); + assert!(!page.title().is_empty()); + } + assert_eq!(Page::Overview.title(), "Overview"); + assert_eq!(Page::Connect.title(), "Connect"); + assert_eq!(Page::Connect.index(), 7); + assert!(Page::Overview.uses_range()); + assert!(Page::Queries.uses_range()); + assert!(Page::Traffic.uses_range()); + assert!(!Page::Merges.uses_range()); + assert!(!Page::Connect.uses_range()); + } +} diff --git a/app/src/pages/overview.rs b/app/src/pages/overview.rs index a4c7fef..c5f2025 100644 --- a/app/src/pages/overview.rs +++ b/app/src/pages/overview.rs @@ -1,14 +1,17 @@ -//! Overview page — placeholder-level metric card grid. -//! AGENT D owns this stub so smoke shots have content; Agents F/G/H replace -//! pages/ wholesale later. Renders whatever `Shell` fetched (mock data under -//! CHM_SMOKE=1). +//! Overview page — metric cards plus a queries/sec sparkline. +//! Renders whatever `Shell` fetched (mock data under CHM_SMOKE=1). -use chm_core::Overview; +use chm_core::{Overview, TrafficSeries}; use bezel::gpui::{Context, Render, div, prelude::*, px}; +use crate::pages::status; +use crate::widgets::geometry::{format_bytes, format_count}; +use crate::widgets::{NamedSeries, line_chart, metric_card}; + pub struct OverviewPage { data: Option, + traffic: Option, error: Option, } @@ -22,11 +25,17 @@ impl OverviewPage { pub fn new() -> Self { Self { data: None, + traffic: None, error: None, } } - pub fn set_overview(&mut self, data: Result, cx: &mut Context) { + pub fn set_overview( + &mut self, + data: Result, + traffic: Result, + cx: &mut Context, + ) { match data { Ok(o) => { self.data = Some(o); @@ -34,28 +43,11 @@ impl OverviewPage { } Err(e) => self.error = Some(e), } + if let Ok(t) = traffic { + self.traffic = Some(t); + } cx.notify(); } - - fn card(label: &'static str, value: String) -> bezel::gpui::Div { - div() - .w(px(180.0)) - .flex() - .flex_col() - .gap(px(2.0)) - .p(px(12.0)) - .rounded(px(8.0)) - .border_1() - .border_color(bezel::theme::ink(0.10)) - .bg(bezel::theme::ink(0.03)) - .child( - div() - .text_size(px(11.0)) - .text_color(bezel::theme::ink(0.55)) - .child(label), - ) - .child(div().text_size(px(17.0)).child(value)) - } } impl Render for OverviewPage { @@ -66,99 +58,96 @@ impl Render for OverviewPage { ) -> impl bezel::gpui::IntoElement { let _ = cx; if let Some(err) = &self.error { - return div() - .flex() - .flex_1() - .items_center() - .justify_center() - .text_color(bezel::theme::ink(0.55)) - .text_size(px(13.0)) - .child(format!("overview unavailable: {err}")); + return status(format!("overview unavailable: {err}")); } let Some(o) = &self.data else { - return div() - .flex() - .flex_1() - .items_center() - .justify_center() - .text_color(bezel::theme::ink(0.45)) - .text_size(px(13.0)) - .child("loading overview…"); + return status("loading overview…"); }; - let rows: [[(&'static str, String); 4]; 3] = [ - [ - ("queries / sec", format!("{:.1}", o.qps)), - ("running", o.running_queries.to_string()), - ("slow · 24h", o.slow_queries_24h.to_string()), - ("failed · 24h", o.failed_queries_24h.to_string()), - ], - [ - ("active merges", o.active_merges.to_string()), - ( + let used_pct = 100.0 * o.disk_used_bytes as f64 / o.disk_total_bytes.max(1) as f64; + let disk_sub = format!( + "{} · {:.0}% used", + format_bytes(o.disk_total_bytes), + used_pct + ); + + let mut grid = div().flex().flex_col().gap(px(10.0)).w_full(); + grid = grid.child( + div() + .flex() + .flex_row() + .gap(px(10.0)) + .child(metric_card("queries / sec", &format!("{:.1}", o.qps), None)) + .child(metric_card("running", &o.running_queries.to_string(), None)) + .child(metric_card( + "slow · 24h", + &o.slow_queries_24h.to_string(), + None, + )) + .child(metric_card( + "failed · 24h", + &o.failed_queries_24h.to_string(), + None, + )), + ); + grid = grid.child( + div() + .flex() + .flex_row() + .gap(px(10.0)) + .child(metric_card( + "active merges", + &o.active_merges.to_string(), + None, + )) + .child(metric_card( "replicas", - format!("{} / {}", o.replicas_ok, o.replicas_total), - ), - ("tables", o.tables_total.to_string()), - ("parts", fmt_u64(o.parts_total)), - ], - [ - ("disk used", fmt_bytes(o.disk_used_bytes)), - ( - "disk total", - format!( - "{} ({:.0}% used)", - fmt_bytes(o.disk_total_bytes), - 100.0 * o.disk_used_bytes as f64 / o.disk_total_bytes.max(1) as f64 - ), - ), - ("uptime", fmt_duration(o.uptime_seconds)), - ("version", o.clickhouse_version.clone()), - ], - ]; + &format!("{} / {}", o.replicas_ok, o.replicas_total), + None, + )) + .child(metric_card( + "tables", + &format_count(o.tables_total as f64), + None, + )) + .child(metric_card( + "parts", + &format_count(o.parts_total as f64), + None, + )), + ); + grid = grid.child( + div() + .flex() + .flex_row() + .gap(px(10.0)) + .child(metric_card( + "disk used", + &format_bytes(o.disk_used_bytes), + Some(&disk_sub), + )) + .child(metric_card("uptime", &fmt_uptime(o.uptime_seconds), None)) + .child(metric_card("version", &o.clickhouse_version, None)), + ); - let mut grid = div().flex().flex_col().gap(px(10.0)); - for row in rows { - let mut line = div().flex().flex_row().gap(px(10.0)); - for (label, value) in row { - line = line.child(Self::card(label, value)); - } - grid = grid.child(line); + if let Some(t) = &self.traffic + && !t.queries_per_sec.is_empty() + { + grid = grid.child(div().w_full().h(px(220.0)).child(line_chart( + "queries / sec", + "qps", + vec![NamedSeries { + name: "qps".into(), + points: t.queries_per_sec.clone(), + accent: true, + }], + ))); } grid } } -/// Thousands separators, no external crate. -fn fmt_u64(n: u64) -> String { - let s = n.to_string(); - let bytes = s.as_bytes(); - let mut out = String::with_capacity(s.len() + s.len() / 3); - for (i, b) in bytes.iter().enumerate() { - if i > 0 && (bytes.len() - i).is_multiple_of(3) { - out.push('\''); - } - out.push(*b as char); - } - out -} - -fn fmt_bytes(n: u64) -> String { - const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; - let mut v = n as f64; - let mut u = 0; - while v >= 1024.0 && u < UNITS.len() - 1 { - v /= 1024.0; - u += 1; - } - if u == 0 { - format!("{n} B") - } else { - format!("{v:.1} {}", UNITS[u]) - } -} - -fn fmt_duration(secs: u64) -> String { +fn fmt_uptime(secs: u64) -> String { let d = secs / 86_400; let h = (secs % 86_400) / 3_600; match (d, h) { diff --git a/app/src/pages/queries.rs b/app/src/pages/queries.rs new file mode 100644 index 0000000..ce19167 --- /dev/null +++ b/app/src/pages/queries.rs @@ -0,0 +1,158 @@ +//! Running / slow / failed query lists. + +use chm_core::QueryRow; + +use bezel::gpui::{AnyElement, Context, Render, div, prelude::*, px}; + +use crate::pages::{heading, status}; +use crate::widgets::{CellVal, Column, data_table}; + +pub struct QueriesPage { + running: Option>, + slow: Option>, + failed: Option>, + error: Option, +} + +impl Default for QueriesPage { + fn default() -> Self { + Self::new() + } +} + +impl QueriesPage { + pub fn new() -> Self { + Self { + running: None, + slow: None, + failed: None, + error: None, + } + } + + pub fn set( + &mut self, + running: Result, String>, + slow: Result, String>, + failed: Result, String>, + cx: &mut Context, + ) { + let mut errors = Vec::new(); + match running { + Ok(v) => self.running = Some(v), + Err(e) => errors.push(format!("running: {e}")), + } + match slow { + Ok(v) => self.slow = Some(v), + Err(e) => errors.push(format!("slow: {e}")), + } + match failed { + Ok(v) => self.failed = Some(v), + Err(e) => errors.push(format!("failed: {e}")), + } + self.error = if errors.is_empty() { + None + } else { + Some(errors.join(" · ")) + }; + cx.notify(); + } +} + +fn query_columns(with_exception: bool) -> Vec { + let mut cols = vec![ + Column { + name: "user".into(), + width: Some(96.0), + }, + Column { + name: "elapsed".into(), + width: Some(80.0), + }, + Column { + name: "memory".into(), + width: Some(80.0), + }, + Column { + name: "rows".into(), + width: Some(72.0), + }, + ]; + if with_exception { + cols.push(Column { + name: "exception".into(), + width: Some(220.0), + }); + } + cols.push(Column { + name: "query".into(), + width: None, + }); + cols +} + +fn query_rows(rows: &[QueryRow], with_exception: bool) -> Vec> { + rows.iter() + .map(|r| { + let mut cells = vec![ + CellVal::Text(r.user.clone()), + CellVal::DurMs(r.elapsed_ms), + CellVal::Bytes(r.memory_bytes), + CellVal::Num(r.read_rows as f64), + ]; + if with_exception { + cells.push(CellVal::Text( + r.exception.clone().unwrap_or_else(|| "—".into()), + )); + } + cells.push(CellVal::Text(r.normalized_sql.clone())); + cells + }) + .collect() +} + +fn section(title: &str, rows: Option<&Vec>, with_exception: bool) -> AnyElement { + let body: AnyElement = match rows { + None => status("loading…").into_any_element(), + Some(rows) if rows.is_empty() => status("none").into_any_element(), + Some(rows) => data_table( + query_columns(with_exception), + query_rows(rows, with_exception), + ) + .into_any_element(), + }; + div() + .flex() + .flex_col() + .gap(px(6.0)) + .child(heading(title)) + .child(body) + .into_any_element() +} + +impl Render for QueriesPage { + fn render( + &mut self, + _window: &mut bezel::gpui::Window, + _cx: &mut Context, + ) -> impl bezel::gpui::IntoElement { + if self.running.is_none() && self.slow.is_none() && self.failed.is_none() { + if let Some(err) = &self.error { + return status(format!("queries unavailable: {err}")); + } + return status("loading queries…"); + } + let mut col = div().flex().flex_col().gap(px(16.0)).w_full(); + if let Some(err) = &self.error { + col = col.child( + div() + .text_size(px(12.0)) + .text_color(bezel::theme::ink(0.6)) + .child(format!("partial: {err}")), + ); + } + col.child(section("Running", self.running.as_ref(), false)) + .child(section("Slow", self.slow.as_ref(), false)) + .child(section("Failed", self.failed.as_ref(), true)) + } +} diff --git a/app/src/pages/replicas.rs b/app/src/pages/replicas.rs new file mode 100644 index 0000000..c7a2a31 --- /dev/null +++ b/app/src/pages/replicas.rs @@ -0,0 +1,118 @@ +//! Replica health table. + +use chm_core::ReplicaRow; + +use bezel::gpui::{Context, Render, prelude::*}; + +use crate::pages::status; +use crate::widgets::{CellVal, Column, data_table}; + +pub struct ReplicasPage { + data: Option>, + error: Option, +} + +impl Default for ReplicasPage { + fn default() -> Self { + Self::new() + } +} + +impl ReplicasPage { + pub fn new() -> Self { + Self { + data: None, + error: None, + } + } + + pub fn set(&mut self, data: Result, String>, cx: &mut Context) { + match data { + Ok(v) => { + self.data = Some(v); + self.error = None; + } + Err(e) => self.error = Some(e), + } + cx.notify(); + } +} + +impl Render for ReplicasPage { + fn render( + &mut self, + _window: &mut bezel::gpui::Window, + _cx: &mut Context, + ) -> impl bezel::gpui::IntoElement { + if let Some(err) = &self.error { + return status(format!("replicas unavailable: {err}")).into_any_element(); + } + let Some(rows) = &self.data else { + return status("loading replicas…").into_any_element(); + }; + if rows.is_empty() { + return status("no replicas").into_any_element(); + } + let columns = vec![ + Column { + name: "replica".into(), + width: Some(110.0), + }, + Column { + name: "database".into(), + width: Some(110.0), + }, + Column { + name: "table".into(), + width: Some(140.0), + }, + Column { + name: "state".into(), + width: Some(90.0), + }, + Column { + name: "delay".into(), + width: Some(80.0), + }, + Column { + name: "queue".into(), + width: Some(64.0), + }, + Column { + name: "inserts".into(), + width: Some(72.0), + }, + Column { + name: "merges".into(), + width: Some(72.0), + }, + ]; + let body = rows + .iter() + .map(|r| { + let state = if r.is_session_expired { + "expired" + } else if r.is_readonly { + "readonly" + } else { + "ok" + }; + vec![ + CellVal::Text(r.replica_name.clone()), + CellVal::Text(r.database.clone()), + CellVal::Text(r.table.clone()), + CellVal::Text(state.into()), + CellVal::Text(if r.absolute_delay_sec < 0.001 { + "0s".into() + } else { + crate::widgets::geometry::format_duration_ms(r.absolute_delay_sec * 1000.0) + }), + CellVal::Num(r.queue_size as f64), + CellVal::Num(r.inserts_in_queue as f64), + CellVal::Num(r.merges_in_queue as f64), + ] + }) + .collect(); + data_table(columns, body).into_any_element() + } +} diff --git a/app/src/pages/tables.rs b/app/src/pages/tables.rs new file mode 100644 index 0000000..0ebcae1 --- /dev/null +++ b/app/src/pages/tables.rs @@ -0,0 +1,111 @@ +//! Table stats. + +use chm_core::TableStat; + +use bezel::gpui::{Context, Render, prelude::*}; + +use crate::pages::status; +use crate::widgets::{CellVal, Column, data_table}; + +pub struct TablesPage { + data: Option>, + error: Option, +} + +impl Default for TablesPage { + fn default() -> Self { + Self::new() + } +} + +impl TablesPage { + pub fn new() -> Self { + Self { + data: None, + error: None, + } + } + + pub fn set(&mut self, data: Result, String>, cx: &mut Context) { + match data { + Ok(v) => { + self.data = Some(v); + self.error = None; + } + Err(e) => self.error = Some(e), + } + cx.notify(); + } +} + +impl Render for TablesPage { + fn render( + &mut self, + _window: &mut bezel::gpui::Window, + _cx: &mut Context, + ) -> impl bezel::gpui::IntoElement { + if let Some(err) = &self.error { + return status(format!("tables unavailable: {err}")).into_any_element(); + } + let Some(rows) = &self.data else { + return status("loading tables…").into_any_element(); + }; + if rows.is_empty() { + return status("no tables").into_any_element(); + } + let columns = vec![ + Column { + name: "database".into(), + width: Some(110.0), + }, + Column { + name: "table".into(), + width: Some(160.0), + }, + Column { + name: "engine".into(), + width: Some(160.0), + }, + Column { + name: "parts".into(), + width: Some(72.0), + }, + Column { + name: "rows".into(), + width: Some(88.0), + }, + Column { + name: "size".into(), + width: Some(88.0), + }, + Column { + name: "ratio".into(), + width: Some(64.0), + }, + Column { + name: "modified".into(), + width: None, + }, + ]; + let body = rows + .iter() + .map(|r| { + let modified = r + .last_modified + .map(|t| t.format("%Y-%m-%d %H:%M").to_string()) + .unwrap_or_else(|| "—".into()); + vec![ + CellVal::Text(r.database.clone()), + CellVal::Text(r.name.clone()), + CellVal::Text(r.engine.clone()), + CellVal::Num(r.parts as f64), + CellVal::Num(r.rows as f64), + CellVal::Bytes(r.bytes_on_disk), + CellVal::Text(format!("{:.1}×", r.compressed_ratio)), + CellVal::Text(modified), + ] + }) + .collect(); + data_table(columns, body).into_any_element() + } +} diff --git a/app/src/pages/traffic.rs b/app/src/pages/traffic.rs new file mode 100644 index 0000000..4794621 --- /dev/null +++ b/app/src/pages/traffic.rs @@ -0,0 +1,94 @@ +//! Traffic charts (qps, rows, network). + +use chm_core::TrafficSeries; + +use bezel::gpui::{Context, Render, div, prelude::*, px}; + +use crate::pages::status; +use crate::widgets::{NamedSeries, line_chart}; + +pub struct TrafficPage { + data: Option, + error: Option, +} + +impl Default for TrafficPage { + fn default() -> Self { + Self::new() + } +} + +impl TrafficPage { + pub fn new() -> Self { + Self { + data: None, + error: None, + } + } + + pub fn set(&mut self, data: Result, cx: &mut Context) { + match data { + Ok(v) => { + self.data = Some(v); + self.error = None; + } + Err(e) => self.error = Some(e), + } + cx.notify(); + } +} + +fn chart(title: &str, unit: &str, points: &[chm_core::SeriesPoint]) -> bezel::gpui::Div { + div().flex_1().min_w_0().h(px(200.0)).child(line_chart( + title, + unit, + vec![NamedSeries { + name: title.into(), + points: points.to_vec(), + accent: true, + }], + )) +} + +impl Render for TrafficPage { + fn render( + &mut self, + _window: &mut bezel::gpui::Window, + _cx: &mut Context, + ) -> impl bezel::gpui::IntoElement { + if let Some(err) = &self.error { + return status(format!("traffic unavailable: {err}")); + } + let Some(t) = &self.data else { + return status("loading traffic…"); + }; + if t.queries_per_sec.is_empty() + && t.rows_read_per_sec.is_empty() + && t.network_rx_bps.is_empty() + && t.network_tx_bps.is_empty() + { + return status("no traffic in this range"); + } + div() + .flex() + .flex_col() + .gap(px(10.0)) + .w_full() + .child( + div() + .flex() + .flex_row() + .gap(px(10.0)) + .child(chart("queries / sec", "qps", &t.queries_per_sec)) + .child(chart("rows read / sec", "rows/s", &t.rows_read_per_sec)), + ) + .child( + div() + .flex() + .flex_row() + .gap(px(10.0)) + .child(chart("network in", "bit/s", &t.network_rx_bps)) + .child(chart("network out", "bit/s", &t.network_tx_bps)), + ) + } +} diff --git a/app/src/shell.rs b/app/src/shell.rs index becb65f..a5c77c7 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -8,9 +8,10 @@ use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; -use chm_clickhouse::ClickHouseClient; -use chm_cloud_api::CloudClient; -use chm_core::{DataSource, MockDataSource}; +use chm_core::{ + DataSource, Health, MergeRow, MockDataSource, Overview, QueryRow, ReplicaRow, TableStat, + TimeRange, TrafficSeries, +}; use bezel::gpui::{ App, AppContext as _, AsyncApp, Context, Entity, FocusHandle, Focusable, Hsla, KeyBinding, @@ -19,9 +20,16 @@ use bezel::gpui::{ use bezel::theme::Theme; use bezel::ui::widgets::status_dot; +use crate::config::{ConfigFile, cli, config_path, load_profile, source_from_profile}; use crate::connect::{ConnectEvent, ConnectFlow}; use crate::pages::Page; +use crate::pages::health::HealthPage; +use crate::pages::merges::MergesPage; use crate::pages::overview::OverviewPage; +use crate::pages::queries::QueriesPage; +use crate::pages::replicas::ReplicasPage; +use crate::pages::tables::TablesPage; +use crate::pages::traffic::TrafficPage; actions!(chm_shell, [Refresh]); @@ -40,83 +48,6 @@ fn perf() -> &'static chm_telemetry::PerfMetrics { PERF.get_or_init(chm_telemetry::PerfMetrics::new) } -// --------------------------------------------------------------------------- -// config.toml schema -// --------------------------------------------------------------------------- - -/// Saved connection profile (`[profile]` table). Written by the Connect -/// screen's Save button, read at startup. -#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)] -pub struct ProfileConfig { - /// "cloud" | "clickhouse" - pub mode: Option, - /// Cloud mode: API base URL. - #[serde(default)] - pub base_url: Option, - /// Cloud mode: API key. - #[serde(default)] - pub api_key: Option, - /// Direct mode: ClickHouse HTTP endpoint. - #[serde(default)] - pub url: Option, - /// Direct mode: user name. - #[serde(default)] - pub user: Option, - /// Direct mode: password. - #[serde(default)] - pub password: Option, - /// Release channel for the update check: "stable" | "beta". - #[serde(default)] - pub channel: Option, -} - -/// `[telemetry]` table — opt-in, default disabled. -#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)] -pub struct TelemetrySection { - #[serde(default)] - pub enabled: bool, -} - -/// Whole `config.toml`. -#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)] -pub struct ConfigFile { - #[serde(default)] - pub profile: ProfileConfig, - #[serde(default)] - pub telemetry: TelemetrySection, -} - -/// `/chmonitor/config.toml`. -pub fn config_path() -> Option { - dirs::config_dir().map(|d| d.join("chmonitor").join("config.toml")) -} - -/// Read the saved profile, if a well-formed file exists. Any failure means -/// "no profile" and the app shows the Connect screen. -pub fn load_profile() -> Option { - let text = std::fs::read_to_string(config_path()?).ok()?; - let cfg: ConfigFile = toml::from_str(&text).ok()?; - cfg.profile.mode.as_ref()?; - Some(cfg.profile) -} - -/// Build the boxed data source behind [`chm_core::DataSource`] for a saved -/// profile. `None` when required fields are missing. -pub fn source_from_profile(p: &ProfileConfig) -> Option> { - match p.mode.as_deref()? { - "cloud" => Some(Box::new(CloudClient::new( - p.base_url.clone()?, - p.api_key.clone(), - ))), - "clickhouse" => Some(Box::new(ClickHouseClient::new( - p.url.clone()?, - p.user.clone().unwrap_or_else(|| "default".into()), - p.password.clone(), - ))), - _ => None, - } -} - // --------------------------------------------------------------------------- // Shell // --------------------------------------------------------------------------- @@ -147,10 +78,18 @@ struct UpdateNote(SharedString); pub struct Shell { focus: FocusHandle, page: Page, + range: TimeRange, source: Option>>, conn: ConnState, last_refresh: Option>, - overview: Option>, + last_error: Option, + overview: Entity, + queries: Entity, + merges: Entity, + replicas: Entity, + health: Entity, + tables: Entity, + traffic: Entity, connect: Entity, update_note: Option, } @@ -208,13 +147,16 @@ impl Shell { }; cx.spawn(async move |this, cx| { let checker = chm_update::UpdateChecker::production(); - let current = semver::Version::new(0, 1, 0); + let current = semver::Version::parse(env!("CARGO_PKG_VERSION")) + .unwrap_or_else(|_| semver::Version::new(0, 1, 1)); let note = match chm_core::tokio_block_on(checker.check(channel, ¤t)) { Ok(Some(release)) => { UpdateNote(format!("update available: v{}", release.version()).into()) } Ok(None) => UpdateNote("up to date".into()), - Err(_) => return, + // Keep the status bar from sitting on "update check…" forever + // when the manifest host is unreachable. + Err(_) => UpdateNote(SharedString::default()), }; let _ = this.update(cx, |shell, cx| { shell.update_note = Some(note); @@ -223,13 +165,28 @@ impl Shell { }) .detach(); + let force_connect = cli().connect; + let page = if force_connect || source.is_none() { + Page::Connect + } else { + Page::Overview + }; + let mut shell = Self { focus: cx.focus_handle(), - page: Page::Overview, + page, + range: TimeRange::TwentyFourHours, source, conn, last_refresh: None, - overview: None, + last_error: None, + overview: cx.new(|_| OverviewPage::new()), + queries: cx.new(|_| QueriesPage::new()), + merges: cx.new(|_| MergesPage::new()), + replicas: cx.new(|_| ReplicasPage::new()), + health: cx.new(|_| HealthPage::new()), + tables: cx.new(|_| TablesPage::new()), + traffic: cx.new(|_| TrafficPage::new()), connect: cx.new(|cx| ConnectFlow::new(load_profile(), cx)), update_note: None, }; @@ -247,6 +204,7 @@ impl Shell { } else { ConnState::Error }; + this.page = Page::Overview; this.refresh_now(cx); cx.notify(); }) @@ -257,6 +215,15 @@ impl Shell { shell } + fn goto(&mut self, page: Page, cx: &mut Context) { + if self.page == page { + return; + } + self.page = page; + self.refresh_now(cx); + cx.notify(); + } + /// Recurring refresh: each tick re-spawns itself, so a slow fetch can /// never overlap the next one. Exits once the shell is dropped. fn start_poll(&self, cx: &mut Context) { @@ -279,41 +246,89 @@ impl Shell { /// Snapshot what a background fetch needs. Cheap: one Arc clone + a copy. fn poll_job(&self) -> Option { + if self.page == Page::Connect { + return None; + } self.source.as_ref().map(|src| PollJob { src: src.clone(), page: self.page, + range: self.range, }) } /// Manual refresh action + initial fill. fn refresh_now(&mut self, cx: &mut Context) { let Some(job) = self.poll_job() else { return }; + if self.conn != ConnState::Error { + self.conn = ConnState::Connecting; + } cx.spawn(async move |this, cx| apply_poll(job, &this, cx).await) .detach(); } - /// Land fetched data back on the view (called from the async context). - fn set_overview_data( + fn apply_outcome( &mut self, - data: Result, + outcome: PollOutcome, at: chrono::DateTime, cx: &mut Context, ) { self.last_refresh = Some(at); - self.conn = if data.is_ok() { - ConnState::Connected - } else { - ConnState::Error - }; - if self.overview.is_none() { - self.overview = Some(cx.new(|_| OverviewPage::new())); - } - if let Some(page) = &self.overview { - page.update(cx, |p, cx| p.set_overview(data, cx)); + match outcome { + PollOutcome::Overview { overview, traffic } => { + self.set_conn(overview.is_ok(), overview.as_ref().err().cloned()); + self.overview + .update(cx, |p, cx| p.set_overview(overview, traffic, cx)); + } + PollOutcome::Queries { + running, + slow, + failed, + } => { + let ok = running.is_ok() || slow.is_ok() || failed.is_ok(); + let err = running + .as_ref() + .err() + .or(slow.as_ref().err()) + .or(failed.as_ref().err()) + .cloned(); + self.set_conn(ok, err.filter(|_| !ok)); + self.queries + .update(cx, |p, cx| p.set(running, slow, failed, cx)); + } + PollOutcome::Merges(data) => { + self.set_conn(data.is_ok(), data.as_ref().err().cloned()); + self.merges.update(cx, |p, cx| p.set(data, cx)); + } + PollOutcome::Replicas(data) => { + self.set_conn(data.is_ok(), data.as_ref().err().cloned()); + self.replicas.update(cx, |p, cx| p.set(data, cx)); + } + PollOutcome::Health(data) => { + self.set_conn(data.is_ok(), data.as_ref().err().cloned()); + self.health.update(cx, |p, cx| p.set(data, cx)); + } + PollOutcome::Tables(data) => { + self.set_conn(data.is_ok(), data.as_ref().err().cloned()); + self.tables.update(cx, |p, cx| p.set(data, cx)); + } + PollOutcome::Traffic(data) => { + self.set_conn(data.is_ok(), data.as_ref().err().cloned()); + self.traffic.update(cx, |p, cx| p.set(data, cx)); + } } cx.notify(); } + fn set_conn(&mut self, ok: bool, err: Option) { + if ok { + self.conn = ConnState::Connected; + self.last_error = None; + } else { + self.conn = ConnState::Error; + self.last_error = err; + } + } + // -- rendering ---------------------------------------------------------- fn sidebar(&self, theme: &Theme, compact: bool, cx: &mut Context) -> bezel::gpui::Div { @@ -353,8 +368,7 @@ impl Shell { .text_color(if active { theme.text } else { theme.text_muted }) .on_click( cx.listener(move |this, _: &bezel::gpui::ClickEvent, _, cx| { - this.page = page; - cx.notify(); + this.goto(page, cx); }), ) .child(label) @@ -370,6 +384,36 @@ impl Shell { .children(items) } + fn range_bar(&self, theme: &Theme, cx: &mut Context) -> bezel::gpui::Div { + let mut row = div().flex().flex_row().items_center().gap(px(4.0)); + for range in TimeRange::ALL { + let active = self.range == range; + row = row.child( + div() + .id(SharedString::from(format!("range-{}", range.label()))) + .px(px(8.0)) + .py(px(4.0)) + .rounded(px(6.0)) + .cursor_pointer() + .text_size(px(11.5)) + .when(active, |el| { + el.bg(theme.element_active).text_color(theme.text) + }) + .when(!active, |el| el.text_color(theme.text_muted)) + .hover(|s| s.bg(theme.element_hover)) + .on_click(cx.listener(move |this, _, _, cx| { + if this.range != range { + this.range = range; + this.refresh_now(cx); + cx.notify(); + } + })) + .child(range.label()), + ); + } + row + } + fn status_bar(&self, theme: &Theme) -> bezel::gpui::Div { let label = self .source @@ -380,11 +424,15 @@ impl Shell { Some(at) => format!("updated {}", at.format("%H:%M:%S")), None => "not refreshed yet".to_string(), }; - let note = self - .update_note + let note = match &self.update_note { + None => Some(SharedString::from("update check…")), + Some(UpdateNote(t)) if !t.is_empty() => Some(t.clone()), + Some(_) => None, + }; + let err = self + .last_error .as_ref() - .map(|UpdateNote(t)| t.clone()) - .unwrap_or_else(|| "update check…".into()); + .map(|e| SharedString::from(e.clone())); div() .flex() @@ -401,8 +449,9 @@ impl Shell { .child(status_dot(self.conn.dot(theme))) .child(div().min_w_0().truncate().child(label)) .child(div().child(refreshed)) + .children(err.map(|e| div().min_w_0().truncate().text_color(theme.danger).child(e))) .child(div().flex_1()) - .child(div().text_color(theme.text_faint).child(note)) + .children(note.map(|t| div().text_color(theme.text_faint).child(t))) } fn content(&mut self, _cx: &mut Context) -> bezel::gpui::AnyElement { @@ -419,17 +468,13 @@ impl Shell { .into_any_element(); } match self.page { - Page::Overview => match &self.overview { - Some(page) => page.clone().into_any_element(), - None => placeholder("loading overview…").into_any_element(), - }, - // Placeholder routes until Agents F/G/H replace pages/. - Page::Queries => placeholder("Queries — owned by Agent F").into_any_element(), - Page::Merges => placeholder("Merges — owned by Agent F").into_any_element(), - Page::Replicas => placeholder("Replicas — owned by Agent G").into_any_element(), - Page::Health => placeholder("Health — owned by Agent G").into_any_element(), - Page::Tables => placeholder("Tables — owned by Agent H").into_any_element(), - Page::Traffic => placeholder("Traffic — owned by Agent H").into_any_element(), + Page::Overview => self.overview.clone().into_any_element(), + Page::Queries => self.queries.clone().into_any_element(), + Page::Merges => self.merges.clone().into_any_element(), + Page::Replicas => self.replicas.clone().into_any_element(), + Page::Health => self.health.clone().into_any_element(), + Page::Tables => self.tables.clone().into_any_element(), + Page::Traffic => self.traffic.clone().into_any_element(), Page::Connect => self.connect.clone().into_any_element(), } } @@ -447,6 +492,7 @@ impl Render for Shell { let viewport = window.viewport_size(); let compact = viewport.width < px(COMPACT_BELOW); + let show_range = self.page.uses_range() && self.source.is_some(); div() .id("shell") @@ -471,8 +517,7 @@ impl Render for Shell { .and_then(|n| n.checked_sub(1)) && let Some(&page) = Page::ALL.get(idx) { - this.page = page; - cx.notify(); + this.goto(page, cx); } })) .flex() @@ -506,6 +551,23 @@ impl Render for Shell { .flex_col() .flex_1() .min_w_0() + .child( + div() + .flex() + .flex_row() + .items_center() + .px(px(16.0)) + .pt(px(12.0)) + .pb(px(4.0)) + .gap(px(12.0)) + .child( + div() + .text_size(px(15.0)) + .child(SharedString::from(self.page.title())), + ) + .child(div().flex_1()) + .when(show_range, |row| row.child(self.range_bar(&theme, cx))), + ) .child( div() .id("content-scroll") @@ -532,33 +594,74 @@ impl Render for Shell { struct PollJob { src: Arc>, page: Page, + range: TimeRange, +} + +enum PollOutcome { + Overview { + overview: Result, + traffic: Result, + }, + Queries { + running: Result, String>, + slow: Result, String>, + failed: Result, String>, + }, + Merges(Result, String>), + Replicas(Result, String>), + Health(Result), + Tables(Result, String>), + Traffic(Result), +} + +fn map_err(r: chm_core::Result) -> Result { + r.map_err(|e| e.to_string()) } async fn apply_poll(job: PollJob, this: &WeakEntity, cx: &mut AsyncApp) { let started = Instant::now(); - let result = match job.page { - Page::Overview => fetch_overview(&job.src).await, - _ => return, + let src = job.src; + let range = job.range; + let outcome = match job.page { + Page::Connect => return, + Page::Overview => { + let (overview, traffic) = chm_core::tokio_block_on(async { + tokio::join!(src.overview(range), src.traffic(range)) + }); + PollOutcome::Overview { + overview: map_err(overview), + traffic: map_err(traffic), + } + } + Page::Queries => { + let (running, slow, failed) = chm_core::tokio_block_on(async { + tokio::join!( + src.running_queries(), + src.slow_queries(range), + src.failed_queries(range) + ) + }); + PollOutcome::Queries { + running: map_err(running), + slow: map_err(slow), + failed: map_err(failed), + } + } + Page::Merges => PollOutcome::Merges(map_err(chm_core::tokio_block_on(src.merges()))), + Page::Replicas => PollOutcome::Replicas(map_err(chm_core::tokio_block_on(src.replicas()))), + Page::Health => PollOutcome::Health(map_err(chm_core::tokio_block_on(src.health()))), + Page::Tables => PollOutcome::Tables(map_err(chm_core::tokio_block_on(src.tables()))), + Page::Traffic => { + PollOutcome::Traffic(map_err(chm_core::tokio_block_on(src.traffic(range)))) + } }; // Telemetry hook: fetch latency lands in PerfMetrics whenever the process // global exists; recording itself is opt-in via config.toml at startup. let _ = perf().record_fetch(started.elapsed().as_secs_f64() * 1000.0); let at = chrono::Utc::now(); - let _ = this.update(cx, |shell, cx| shell.set_overview_data(result, at, cx)); -} - -async fn fetch_overview(src: &Arc>) -> Result { - chm_core::tokio_block_on(src.overview(chm_core::TimeRange::TwentyFourHours)) - .map_err(|e| e.to_string()) + let _ = this.update(cx, |shell, cx| shell.apply_outcome(outcome, at, cx)); } -fn placeholder(text: &'static str) -> bezel::gpui::Div { - div() - .flex() - .flex_1() - .items_center() - .justify_center() - .text_color(bezel::theme::ink(0.45)) - .text_size(px(13.0)) - .child(text) -} +// Re-export so existing `crate::shell::ProfileConfig` paths keep compiling +// if any leftover call sites remain. +pub use crate::config::ProfileConfig; diff --git a/app/src/widgets/chart.rs b/app/src/widgets/chart.rs index 7ea1a18..b93e8f0 100644 --- a/app/src/widgets/chart.rs +++ b/app/src/widgets/chart.rs @@ -5,7 +5,7 @@ use super::geometry::{Bounds, format_count, nice_scale, points_to_px}; use bezel::gpui::{ App, Bounds as GBounds, Font, FontFeatures, FontWeight, Hsla, IntoElement, PathBuilder, Pixels, - TextAlign, TextRun, Window, canvas, div, fill, font, point, prelude::*, px, size, + TextAlign, TextRun, Window, canvas, div, font, point, prelude::*, px, }; use bezel::theme::{Theme, current_appearance, hairline}; use chm_core::SeriesPoint; @@ -24,9 +24,9 @@ struct ChartLayout { plot: Bounds, } -const PAD_LEFT: f64 = 46.0; +const PAD_LEFT: f64 = 64.0; const PAD_RIGHT: f64 = 12.0; -const PAD_TOP: f64 = 30.0; +const PAD_TOP: f64 = 22.0; const PAD_BOTTOM: f64 = 22.0; const MIN_PLOT_W: f64 = 40.0; const MIN_PLOT_H: f64 = 40.0; @@ -71,8 +71,7 @@ pub fn line_chart(title: &str, unit: &str, series: Vec) -> impl Int .gap(px(8.0)) .child(title_el) .child(unit_el); - let plot_bg_color = t.surface; - let grid_color = hairline(0.07); + let grid_color = hairline(0.14); let axis_text_color = t.text_muted; let line_color_muted = t.text_muted; let line_color_accent = t.accent; @@ -109,14 +108,6 @@ pub fn line_chart(title: &str, unit: &str, series: Vec) -> impl Int let origin = bounds.origin; let plot = layout.plot; - window.paint_quad(fill( - GBounds:: { - origin: origin + point(px(plot.x as f32), px(plot.y as f32)), - size: size(px(plot.w as f32), px(plot.h as f32)), - }, - plot_bg_color, - )); - let all_values: Vec = series .iter() .flat_map(|s| s.points.iter().map(|p| p.v)) @@ -141,21 +132,30 @@ pub fn line_chart(title: &str, unit: &str, series: Vec) -> impl Int let (y_min, y_max, y_ticks) = nice_scale(data_min, data_max, 4); let y_span = if y_max > y_min { y_max - y_min } else { 1.0 }; + // A 1px fill-quad per tick reads as a ruled-notebook texture on + // Metal (especially with runtime shaders); stroke a handful of + // grid lines instead, and cap labels so they cannot overlap. + let tick_step = y_ticks.len().div_ceil(5).max(1); - for tick in &y_ticks { + for (i, tick) in y_ticks.iter().enumerate() { + if i % tick_step != 0 && i + 1 != y_ticks.len() { + continue; + } let frac = (tick - y_min) / y_span; let y = plot.y + plot.h - frac * plot.h; if y < plot.y - 0.5 || y > plot.y + plot.h + 0.5 { continue; } let y = y.clamp(plot.y, plot.y + plot.h); - window.paint_quad(fill( - GBounds:: { - origin: origin + point(px(plot.x as f32), px(y as f32 - 0.5)), - size: size(px(plot.w as f32), px(1.0)), - }, - grid_color, + let mut grid = PathBuilder::stroke(px(1.0)); + grid.move_to(point(origin.x + px(plot.x as f32), origin.y + px(y as f32))); + grid.line_to(point( + origin.x + px((plot.x + plot.w) as f32), + origin.y + px(y as f32), )); + if let Ok(path) = grid.build() { + window.paint_path(path, grid_color); + } let label = tick_label(*tick); let label_len = label.len(); let shaped = window @@ -179,12 +179,12 @@ pub fn line_chart(title: &str, unit: &str, series: Vec) -> impl Int && let Some(line) = lines.first_mut() { let line_w = line.unwrapped_layout.width; - let lx = origin.x + px(plot.x as f32) - line_w - px(6.0); - let ly = origin.y + px(y as f32) - px(AXIS_FONT_SIZE * 0.62); + let lx = origin.x + px(plot.x as f32) - line_w - px(8.0); + let ly = origin.y + px(y as f32) - px(AXIS_FONT_SIZE * 0.55); let _ = line.paint( point(lx, ly), - px(AXIS_FONT_SIZE * 1.25), - TextAlign::Right, + px(AXIS_FONT_SIZE * 1.4), + TextAlign::Left, None, window, _cx, diff --git a/crates/chm-core/src/lib.rs b/crates/chm-core/src/lib.rs index d410aff..15e3f80 100644 --- a/crates/chm-core/src/lib.rs +++ b/crates/chm-core/src/lib.rs @@ -424,6 +424,8 @@ fn row_query(id: &str, user: &str, elapsed_ms: f64, mem: u64, sql: &str) -> Quer fn rep(name: &str, ro: bool, delay: f64) -> ReplicaRow { ReplicaRow { + database: "events".into(), + table: "clicks".into(), replica_name: name.into(), is_readonly: ro, absolute_delay_sec: delay, From f3cbbc5ab82c8d103e8deed664113073dd7c118d Mon Sep 17 00:00:00 2001 From: duyetbot Date: Mon, 24 Aug 2026 18:12:21 +0700 Subject: [PATCH 03/20] docs: cover macOS build, CLI flags, and smoke-mac.sh Document runtime shaders vs Xcode, the implemented --connect / CHM_PROFILE / CHM_CONFIG flags, and a macOS GUI smoke script that captures the app window. --- README.md | 25 ++++++++++++++ scripts/smoke-mac.sh | 81 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100755 scripts/smoke-mac.sh diff --git a/README.md b/README.md index 8c5c315..a0b074f 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,38 @@ cargo build -p chm-app # debug cargo build --release -p chm-app # release (LTO, stripped) ``` +### macOS + +GPUI paints with Metal. Full Xcode ships the `metal` compiler used to +precompile shaders at build time. This workspace enables +`gpui_platform/runtime_shaders` so a Mac with only Command Line Tools +(`xcode-select -p` → `/Library/Developer/CommandLineTools`) can still +`cargo build -p chm-app`; shaders compile on first launch instead. + +To precompile shaders (faster startup) once Xcode is installed: + +```sh +sudo xcode-select --switch /Applications/Xcode.app/Contents/Developer +xcodebuild -downloadComponent MetalToolchain # Xcode 26+ +``` + +Then drop `runtime_shaders` from the `gpui_platform` features in the +workspace `Cargo.toml`. + ## Run ```sh cargo run -p chm-app -- --connect # open Connect screen +cargo run -p chm-app -- --help CHM_SMOKE=1 cargo run -p chm-app # built-in fixture data, no network CHM_PROFILE=work cargo run -p chm-app # named saved profile +CHM_CONFIG=/tmp/chmonitor.toml cargo run -p chm-app ``` +Named profiles live under `[profiles.]` in `config.toml`; the +default connection is `[profile]`. `r` refreshes the current page; +keys `1`–`8` switch sidebar destinations. + ## Layout | Path | Purpose | @@ -40,6 +64,7 @@ CHM_PROFILE=work cargo run -p chm-app # named saved profile ```sh cargo test --workspace # unit + wiremock + SQL snapshots scripts/smoke.sh # GUI smoke on Linux desktop (display :1) +scripts/smoke-mac.sh # GUI smoke on macOS (CHM_SMOKE=1 + screenshot) ``` CI runs lint (`fmt` + `clippy -D warnings`), the workspace tests, a diff --git a/scripts/smoke-mac.sh b/scripts/smoke-mac.sh new file mode 100755 index 0000000..567092e --- /dev/null +++ b/scripts/smoke-mac.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# GUI smoke test for chmonitor desktop app on macOS. +# Launches with fixture data (CHM_SMOKE=1), verifies the process stays up, +# captures a screenshot of the app window, and fails on an early exit or panic. +# +# Usage: scripts/smoke-mac.sh +set -euo pipefail + +SHOTS_DIR="${SHOTS_DIR:-shots}" +WAIT_SECS="${WAIT_SECS:-6}" + +log() { printf '[smoke-mac] %s\n' "$*"; } +fail() { printf '[smoke-mac] FAIL: %s\n' "$*" >&2; exit 1; } + +command -v cargo >/dev/null || fail "cargo not on PATH" +[[ "$(uname -s)" == Darwin ]] || fail "this script is macOS-only (see scripts/smoke.sh for Linux)" + +mkdir -p "$SHOTS_DIR" + +log "building debug binary…" +cargo build -p chm-app + +BIN="target/debug/chm-app" +[[ -x "$BIN" ]] || fail "binary missing at $BIN" + +LOG="$(mktemp /tmp/chm-smoke.XXXXXX)" +env CHM_SMOKE=1 RUST_LOG=info "$BIN" >"$LOG" 2>&1 & +APP_PID=$! +trap 'kill $APP_PID 2>/dev/null || true' EXIT + +for _ in $(seq "$WAIT_SECS"); do + if grep -q "shell ready" "$LOG" 2>/dev/null; then + break + fi + kill -0 "$APP_PID" 2>/dev/null || { tail -30 "$LOG"; fail "app exited before shell ready"; } + sleep 1 +done +kill -0 "$APP_PID" 2>/dev/null || { tail -30 "$LOG"; fail "app exited early"; } +log "app alive after ${WAIT_SECS}s (pid $APP_PID)" + +# Give the first frame a moment to paint, then capture the app window +# (full-screen -x is a fallback if we cannot resolve the CGWindow id). +sleep 2 +OUT="$SHOTS_DIR/01-macos-overview.png" +if command -v screencapture >/dev/null; then + WID="" + if command -v swift >/dev/null; then + WID="$(swift -e ' +import CoreGraphics +let opts = CGWindowListOption.optionOnScreenOnly.union(.excludeDesktopElements) +guard let info = CGWindowListCopyWindowInfo(opts, kCGNullWindowID) as? [[String: Any]] else { fatalError("no windows") } +for w in info { + let owner = w[kCGWindowOwnerName as String] as? String ?? "" + let num = w[kCGWindowNumber as String] as? Int ?? 0 + if owner == "chm-app" || owner == "chmonitor" { + print(num) + break + } +} +' 2>/dev/null || true)" + fi + if [[ -n "$WID" ]]; then + screencapture -l"$WID" "$OUT" || log "WARN: screencapture -l$WID failed" + else + log "WARN: no chm-app window id — capturing full screen" + screencapture -x "$OUT" || log "WARN: screencapture failed" + fi + [[ -s "$OUT" ]] && log "shot: $OUT ($(wc -c <"$OUT") bytes)" +else + log "WARN: screencapture not on PATH — skipping screenshot" +fi + +kill -0 "$APP_PID" 2>/dev/null || fail "app died during capture" +if grep -qE "panicked at|RUST_BACKTRACE" "$LOG"; then + tail -20 "$LOG"; fail "panic found in app log" +fi +if ! grep -q "shell ready" "$LOG"; then + tail -20 "$LOG"; fail "never printed 'shell ready'" +fi + +log "PASS — log $LOG" From 22996a37387c48d7ef5f01c2025852874eeda2ac Mon Sep 17 00:00:00 2001 From: duyetbot Date: Mon, 24 Aug 2026 18:17:27 +0700 Subject: [PATCH 04/20] fix(ui): stop chart y-axis smear and ruled-notebook grid Horizontal 1px strokes tessellate into a filled stripe texture on the Metal runtime-shader path, and a collapsed canvas stacked every y-label into ~40px. Fill the parent height, drop the grid, and paint three right-aligned y-labels in the left gutter. --- app/src/pages/overview.rs | 25 ++++--- app/src/pages/traffic.rs | 24 ++++--- app/src/widgets/chart.rs | 134 ++++++++++++++++++++++++-------------- 3 files changed, 117 insertions(+), 66 deletions(-) diff --git a/app/src/pages/overview.rs b/app/src/pages/overview.rs index c5f2025..3187246 100644 --- a/app/src/pages/overview.rs +++ b/app/src/pages/overview.rs @@ -133,15 +133,22 @@ impl Render for OverviewPage { if let Some(t) = &self.traffic && !t.queries_per_sec.is_empty() { - grid = grid.child(div().w_full().h(px(220.0)).child(line_chart( - "queries / sec", - "qps", - vec![NamedSeries { - name: "qps".into(), - points: t.queries_per_sec.clone(), - accent: true, - }], - ))); + grid = grid.child( + div() + .flex() + .flex_col() + .w_full() + .h(px(220.0)) + .child(line_chart( + "queries / sec", + "qps", + vec![NamedSeries { + name: "qps".into(), + points: t.queries_per_sec.clone(), + accent: true, + }], + )), + ); } grid } diff --git a/app/src/pages/traffic.rs b/app/src/pages/traffic.rs index 4794621..6dd3459 100644 --- a/app/src/pages/traffic.rs +++ b/app/src/pages/traffic.rs @@ -39,15 +39,21 @@ impl TrafficPage { } fn chart(title: &str, unit: &str, points: &[chm_core::SeriesPoint]) -> bezel::gpui::Div { - div().flex_1().min_w_0().h(px(200.0)).child(line_chart( - title, - unit, - vec![NamedSeries { - name: title.into(), - points: points.to_vec(), - accent: true, - }], - )) + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .h(px(200.0)) + .child(line_chart( + title, + unit, + vec![NamedSeries { + name: title.into(), + points: points.to_vec(), + accent: true, + }], + )) } impl Render for TrafficPage { diff --git a/app/src/widgets/chart.rs b/app/src/widgets/chart.rs index b93e8f0..42737cf 100644 --- a/app/src/widgets/chart.rs +++ b/app/src/widgets/chart.rs @@ -5,7 +5,7 @@ use super::geometry::{Bounds, format_count, nice_scale, points_to_px}; use bezel::gpui::{ App, Bounds as GBounds, Font, FontFeatures, FontWeight, Hsla, IntoElement, PathBuilder, Pixels, - TextAlign, TextRun, Window, canvas, div, font, point, prelude::*, px, + TextAlign, TextRun, Window, canvas, div, font, point, prelude::*, px, size, }; use bezel::theme::{Theme, current_appearance, hairline}; use chm_core::SeriesPoint; @@ -24,14 +24,15 @@ struct ChartLayout { plot: Bounds, } -const PAD_LEFT: f64 = 64.0; +const PAD_LEFT: f64 = 52.0; const PAD_RIGHT: f64 = 12.0; -const PAD_TOP: f64 = 22.0; +const PAD_TOP: f64 = 8.0; const PAD_BOTTOM: f64 = 22.0; const MIN_PLOT_W: f64 = 40.0; const MIN_PLOT_H: f64 = 40.0; const STROKE_WIDTH: f32 = 1.5; -const AXIS_FONT_SIZE: f32 = 10.0; +const AXIS_FONT_SIZE: f32 = 11.0; +const AXIS_LINE_HEIGHT: f32 = 14.0; fn mono_font(t: &Theme) -> Font { let mut f = font(t.font_mono.clone()); @@ -45,6 +46,16 @@ fn tick_label(v: f64) -> String { format_count(v) } +/// Top / middle / bottom labels. `ticks` from [`nice_scale`] is ascending. +fn pick_y_labels(ticks: &[f64]) -> Vec { + match ticks.len() { + 0 => Vec::new(), + 1 => vec![ticks[0]], + 2 => vec![ticks[1], ticks[0]], + n => vec![ticks[n - 1], ticks[n / 2], ticks[0]], + } +} + /// The full chart element: title, plot area with grid lines, tick labels on /// both axes, and one polyline per series. Empty series render the empty /// frame; nothing panics on degenerate data. @@ -71,22 +82,24 @@ pub fn line_chart(title: &str, unit: &str, series: Vec) -> impl Int .gap(px(8.0)) .child(title_el) .child(unit_el); - let grid_color = hairline(0.14); let axis_text_color = t.text_muted; let line_color_muted = t.text_muted; let line_color_accent = t.accent; + let axis_color = hairline(0.18); let mono = mono_font(&t); let font_size = px(AXIS_FONT_SIZE); + // Fill the caller's height. A non-flex parent with only `h()` used to + // collapse the canvas, which stacked every y-label on ~40px of plot. div() - .flex_1() - .min_w_0() - .min_h_0() .flex() .flex_col() .gap(px(6.0)) + .w_full() + .h_full() + .min_h(px(120.0)) .child(header) - .child(div().flex_1().min_h_0().w_full().child(canvas( + .child(div().flex_1().min_h(px(80.0)).w_full().child(canvas( move |bounds: GBounds, _window: &mut Window, _cx: &mut App| { let w = f64::from(bounds.size.width.as_f32()).max(PAD_LEFT + PAD_RIGHT + MIN_PLOT_W); @@ -130,34 +143,39 @@ pub fn line_chart(title: &str, unit: &str, series: Vec) -> impl Int } }; - let (y_min, y_max, y_ticks) = nice_scale(data_min, data_max, 4); + let (y_min, y_max, y_ticks) = nice_scale(data_min, data_max, 3); let y_span = if y_max > y_min { y_max - y_min } else { 1.0 }; - // A 1px fill-quad per tick reads as a ruled-notebook texture on - // Metal (especially with runtime shaders); stroke a handful of - // grid lines instead, and cap labels so they cannot overlap. - let tick_step = y_ticks.len().div_ceil(5).max(1); - for (i, tick) in y_ticks.iter().enumerate() { - if i % tick_step != 0 && i + 1 != y_ticks.len() { - continue; - } + // Horizontal 1px strokes/quads tessellate into a ruled-notebook + // fill on this gpui Metal path. Skip them. A vertical axis is + // safe because Y varies. Three y-labels, right-aligned in the + // left gutter via WrappedLine's bounds (not a guessed wrap). + let mut axis = PathBuilder::stroke(px(1.0)); + axis.move_to(point( + origin.x + px(plot.x as f32), + origin.y + px(plot.y as f32), + )); + axis.line_to(point( + origin.x + px(plot.x as f32), + origin.y + px((plot.y + plot.h) as f32), + )); + if let Ok(path) = axis.build() { + window.paint_path(path, axis_color); + } + + let y_labels = pick_y_labels(&y_ticks); + for tick in y_labels { let frac = (tick - y_min) / y_span; - let y = plot.y + plot.h - frac * plot.h; - if y < plot.y - 0.5 || y > plot.y + plot.h + 0.5 { - continue; - } - let y = y.clamp(plot.y, plot.y + plot.h); - let mut grid = PathBuilder::stroke(px(1.0)); - grid.move_to(point(origin.x + px(plot.x as f32), origin.y + px(y as f32))); - grid.line_to(point( - origin.x + px((plot.x + plot.w) as f32), - origin.y + px(y as f32), - )); - if let Ok(path) = grid.build() { - window.paint_path(path, grid_color); - } - let label = tick_label(*tick); + let y = (plot.y + plot.h - frac * plot.h).clamp(plot.y, plot.y + plot.h); + let label = tick_label(tick); let label_len = label.len(); + let gutter = GBounds:: { + origin: origin + point(px(2.0), px(y as f32) - px(AXIS_LINE_HEIGHT * 0.5)), + size: size( + px((plot.x - 8.0) as f32).max(px(24.0)), + px(AXIS_LINE_HEIGHT), + ), + }; let shaped = window .text_system() .shape_text( @@ -171,21 +189,18 @@ pub fn line_chart(title: &str, unit: &str, series: Vec) -> impl Int underline: None, strikethrough: None, }], - None, - None, + Some(gutter.size.width), + Some(1), ) .ok(); if let Some(mut lines) = shaped && let Some(line) = lines.first_mut() { - let line_w = line.unwrapped_layout.width; - let lx = origin.x + px(plot.x as f32) - line_w - px(8.0); - let ly = origin.y + px(y as f32) - px(AXIS_FONT_SIZE * 0.55); let _ = line.paint( - point(lx, ly), - px(AXIS_FONT_SIZE * 1.4), - TextAlign::Left, - None, + gutter.origin, + px(AXIS_LINE_HEIGHT), + TextAlign::Right, + Some(gutter), window, _cx, ); @@ -223,13 +238,20 @@ pub fn line_chart(title: &str, unit: &str, series: Vec) -> impl Int if let Some(mut lines) = shaped && let Some(line) = lines.first_mut() { - let lx = origin.x + px(x as f32) - line.unwrapped_layout.width / 2.0; - let ly = origin.y + px((plot.y + plot.h) as f32) + px(5.0); + let label_w = px(48.0); + let box_bounds = GBounds:: { + origin: origin + + point( + px(x as f32) - label_w / 2.0, + px((plot.y + plot.h) as f32) + px(4.0), + ), + size: size(label_w, px(AXIS_LINE_HEIGHT)), + }; let _ = line.paint( - point(lx, ly), - px(AXIS_FONT_SIZE * 1.25), - TextAlign::Left, - None, + box_bounds.origin, + px(AXIS_LINE_HEIGHT), + TextAlign::Center, + Some(box_bounds), window, _cx, ); @@ -285,3 +307,19 @@ pub fn chart_axis_for(series: &[NamedSeries]) -> (f64, f64, Vec) { } nice_scale(lo, hi, 4) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pick_y_labels_takes_top_mid_bottom() { + assert_eq!(pick_y_labels(&[]), Vec::::new()); + assert_eq!(pick_y_labels(&[3.0]), vec![3.0]); + assert_eq!(pick_y_labels(&[0.0, 10.0]), vec![10.0, 0.0]); + assert_eq!( + pick_y_labels(&[0.0, 5.0, 10.0, 15.0]), + vec![15.0, 10.0, 0.0] + ); + } +} From 88a71a1e4a11d6b811df3d1927ed2b97af73413e Mon Sep 17 00:00:00 2001 From: duyetbot Date: Mon, 24 Aug 2026 18:20:30 +0700 Subject: [PATCH 05/20] feat(app): add sidebar collapse toggle A Sidebar header control and cmd-b switch between the full nav and the icon strip. Until the user toggles, a window under 900px still collapses on its own. --- README.md | 2 +- app/src/shell.rs | 90 ++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index a0b074f..c89f902 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ CHM_CONFIG=/tmp/chmonitor.toml cargo run -p chm-app Named profiles live under `[profiles.]` in `config.toml`; the default connection is `[profile]`. `r` refreshes the current page; -keys `1`–`8` switch sidebar destinations. +keys `1`–`8` switch sidebar destinations; `cmd-b` toggles the sidebar. ## Layout diff --git a/app/src/shell.rs b/app/src/shell.rs index a5c77c7..b881d12 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -31,7 +31,7 @@ use crate::pages::replicas::ReplicasPage; use crate::pages::tables::TablesPage; use crate::pages::traffic::TrafficPage; -actions!(chm_shell, [Refresh]); +actions!(chm_shell, [Refresh, ToggleSidebar]); /// Seconds between automatic background refreshes. const POLL_SECS: u64 = 30; @@ -92,6 +92,8 @@ pub struct Shell { traffic: Entity, connect: Entity, update_note: Option, + /// `None` follows the viewport; `Some` is a click/`cmd-b` override. + sidebar_collapsed: Option, } impl Focusable for Shell { @@ -189,11 +191,15 @@ impl Shell { traffic: cx.new(|_| TrafficPage::new()), connect: cx.new(|cx| ConnectFlow::new(load_profile(), cx)), update_note: None, + sidebar_collapsed: None, }; // Digits 1-8 switch pages; handled in render's on_key_down so it works // wherever focus sits in this view's subtree. `r` is an action. - cx.bind_keys([KeyBinding::new("r", Refresh, None)]); + cx.bind_keys([ + KeyBinding::new("r", Refresh, None), + KeyBinding::new("cmd-b", ToggleSidebar, None), + ]); // Rebuild the source after the Connect screen writes a new profile. cx.subscribe(&shell.connect, |this, _, event: &ConnectEvent, cx| { @@ -224,6 +230,12 @@ impl Shell { cx.notify(); } + fn toggle_sidebar(&mut self, narrow: bool, cx: &mut Context) { + let compact = sidebar_is_compact(self.sidebar_collapsed, narrow); + self.sidebar_collapsed = Some(!compact); + cx.notify(); + } + /// Recurring refresh: each tick re-spawns itself, so a slow fetch can /// never overlap the next one. Exits once the shell is dropped. fn start_poll(&self, cx: &mut Context) { @@ -331,6 +343,52 @@ impl Shell { // -- rendering ---------------------------------------------------------- + fn sidebar_toggle( + &self, + theme: &Theme, + compact: bool, + cx: &mut Context, + ) -> impl bezel::gpui::IntoElement { + let glyph = if compact { "›" } else { "‹" }; + let label = if compact { + div().child(SharedString::from(glyph)) + } else { + div() + .flex() + .flex_row() + .items_center() + .justify_between() + .w_full() + .child( + div() + .text_size(px(11.0)) + .text_color(theme.text_faint) + .child("Sidebar"), + ) + .child( + div() + .text_color(theme.text_muted) + .child(SharedString::from(glyph)), + ) + }; + div() + .id("sidebar-toggle") + .w_full() + .px(px(if compact { 0.0 } else { 12.0 })) + .py(px(6.0)) + .rounded(px(6.0)) + .cursor_pointer() + .hover(|s| s.bg(theme.element_hover)) + .text_size(px(13.0)) + .when(compact, |el| el.flex().justify_center()) + .on_click( + cx.listener(|this, _: &bezel::gpui::ClickEvent, window, cx| { + this.toggle_sidebar(window.viewport_size().width < px(COMPACT_BELOW), cx); + }), + ) + .child(label) + } + fn sidebar(&self, theme: &Theme, compact: bool, cx: &mut Context) -> bezel::gpui::Div { let items: Vec = Page::ALL .iter() @@ -491,7 +549,8 @@ impl Render for Shell { } let viewport = window.viewport_size(); - let compact = viewport.width < px(COMPACT_BELOW); + let narrow = viewport.width < px(COMPACT_BELOW); + let compact = sidebar_is_compact(self.sidebar_collapsed, narrow); let show_range = self.page.uses_range() && self.source.is_some(); div() @@ -499,6 +558,9 @@ impl Render for Shell { .key_context("Shell") .track_focus(&self.focus) .on_action(cx.listener(|this, _: &Refresh, _, cx| this.refresh_now(cx))) + .on_action(cx.listener(|this, _: &ToggleSidebar, window, cx| { + this.toggle_sidebar(window.viewport_size().width < px(COMPACT_BELOW), cx); + })) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { // Only when the shell itself holds focus — digits typed into // a Connect text field must not switch pages. @@ -543,6 +605,7 @@ impl Render for Shell { .border_r_1() .border_color(theme.border) .bg(theme.surface) + .child(self.sidebar_toggle(&theme, compact, cx)) .child(self.sidebar(&theme, compact, cx)), ) .child( @@ -662,6 +725,27 @@ async fn apply_poll(job: PollJob, this: &WeakEntity, cx: &mut AsyncApp) { let _ = this.update(cx, |shell, cx| shell.apply_outcome(outcome, at, cx)); } +/// Compact (icon strip) when the user collapsed it, otherwise when the +/// window is narrower than [`COMPACT_BELOW`]. +fn sidebar_is_compact(user: Option, narrow: bool) -> bool { + user.unwrap_or(narrow) +} + // Re-export so existing `crate::shell::ProfileConfig` paths keep compiling // if any leftover call sites remain. pub use crate::config::ProfileConfig; + +#[cfg(test)] +mod tests { + use super::sidebar_is_compact; + + #[test] + fn sidebar_follows_viewport_until_toggled() { + assert!(!sidebar_is_compact(None, false)); + assert!(sidebar_is_compact(None, true)); + assert!(sidebar_is_compact(Some(true), false)); + assert!(!sidebar_is_compact(Some(false), true)); + assert!(sidebar_is_compact(Some(true), true)); + assert!(!sidebar_is_compact(Some(false), false)); + } +} From 5582c757ca77a8468aca07e3f33e41a67cf81443 Mon Sep 17 00:00:00 2001 From: duyetbot Date: Mon, 24 Aug 2026 18:26:34 +0700 Subject: [PATCH 06/20] feat(app): add Settings menu and page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chmonitor menu gains Settings… (⌘,) and View gets sidebar/refresh. A Settings page at the sidebar footer persists appearance, update channel, and telemetry in config.toml without taking a 1–8 shortcut. --- README.md | 3 +- app/src/config.rs | 53 +++++++ app/src/connect.rs | 34 +---- app/src/main.rs | 28 +++- app/src/pages/mod.rs | 6 + app/src/pages/settings.rs | 301 ++++++++++++++++++++++++++++++++++++++ app/src/shell.rs | 74 +++++++++- 7 files changed, 458 insertions(+), 41 deletions(-) create mode 100644 app/src/pages/settings.rs diff --git a/README.md b/README.md index c89f902..4e539d4 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ CHM_CONFIG=/tmp/chmonitor.toml cargo run -p chm-app Named profiles live under `[profiles.]` in `config.toml`; the default connection is `[profile]`. `r` refreshes the current page; -keys `1`–`8` switch sidebar destinations; `cmd-b` toggles the sidebar. +keys `1`–`8` switch sidebar destinations; `cmd-b` toggles the sidebar; +`cmd-,` opens Settings. ## Layout diff --git a/app/src/config.rs b/app/src/config.rs index f69ca12..50374ee 100644 --- a/app/src/config.rs +++ b/app/src/config.rs @@ -43,6 +43,13 @@ pub struct TelemetrySection { pub enabled: bool, } +/// `[ui]` table — appearance preference (`system` / `light` / `dark`). +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct UiSection { + #[serde(default)] + pub appearance: Option, +} + /// Whole `config.toml`. #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] pub struct ConfigFile { @@ -53,6 +60,8 @@ pub struct ConfigFile { pub profiles: BTreeMap, #[serde(default)] pub telemetry: TelemetrySection, + #[serde(default)] + pub ui: UiSection, } /// `/chmonitor/config.toml`, or `CHM_CONFIG` when set. @@ -77,6 +86,35 @@ pub fn load_profile() -> Option { load_profile_from(config_path()?.as_path(), profile_name_from_env().as_deref()) } +/// Read `config.toml`, or an empty default when the file is missing/invalid. +pub fn load_config() -> ConfigFile { + config_path() + .as_deref() + .map(load_config_from) + .unwrap_or_default() +} + +pub fn load_config_from(path: &Path) -> ConfigFile { + std::fs::read_to_string(path) + .ok() + .and_then(|text| toml::from_str(&text).ok()) + .unwrap_or_default() +} + +/// Write `config.toml`, creating the parent directory when needed. +pub fn save_config(cfg: &ConfigFile) -> Result<(), String> { + let path = config_path().ok_or_else(|| "no config directory on this platform".to_string())?; + save_config_to(&path, cfg) +} + +pub fn save_config_to(path: &Path, cfg: &ConfigFile) -> Result<(), String> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("mkdir failed: {e}"))?; + } + let out = toml::to_string_pretty(cfg).map_err(|e| format!("serialize failed: {e}"))?; + std::fs::write(path, out).map_err(|e| format!("write failed: {e}")) +} + /// Load `[profile]` or `[profiles.]` from an explicit path. pub fn load_profile_from(path: &Path, named: Option<&str>) -> Option { let text = std::fs::read_to_string(path).ok()?; @@ -296,8 +334,23 @@ user = "alice" }, ); cfg.telemetry.enabled = true; + cfg.ui.appearance = Some("dark".into()); let text = toml::to_string_pretty(&cfg).unwrap(); let back: ConfigFile = toml::from_str(&text).unwrap(); assert_eq!(back, cfg); } + + #[test] + fn save_config_to_roundtrips_ui_and_channel() { + let path = write_cfg(""); + let mut cfg = ConfigFile::default(); + cfg.ui.appearance = Some("light".into()); + cfg.profile.channel = Some("beta".into()); + cfg.telemetry.enabled = true; + save_config_to(&path, &cfg).unwrap(); + let back = load_config_from(&path); + assert_eq!(back.ui.appearance.as_deref(), Some("light")); + assert_eq!(back.profile.channel.as_deref(), Some("beta")); + assert!(back.telemetry.enabled); + } } diff --git a/app/src/connect.rs b/app/src/connect.rs index e6c5abe..46db2cb 100644 --- a/app/src/connect.rs +++ b/app/src/connect.rs @@ -15,7 +15,7 @@ use bezel::theme::Theme; use bezel::ui::input::TextField; use bezel::ui::widgets::{ButtonStyle, Buttons}; -use crate::config::{ProfileConfig, config_path, source_from_profile}; +use crate::config::{ProfileConfig, load_config, save_config, source_from_profile}; /// Fired after Save successfully writes config.toml. #[derive(Debug, Clone)] @@ -151,41 +151,17 @@ impl ConnectFlow { cx.notify(); return; }; - // Preserve anything outside [profile] (e.g. [telemetry]) if a config - // already exists; otherwise start from defaults. - let mut cfg = config_path() - .and_then(|p| std::fs::read_to_string(p).ok()) - .and_then(|text| toml::from_str::(&text).ok()) - .unwrap_or_default(); + // Preserve anything outside [profile] (e.g. [telemetry], [ui]). + let mut cfg = load_config(); cfg.profile = profile.clone(); - let out = match toml::to_string_pretty(&cfg) { - Ok(out) => out, - Err(e) => { - self.test = TestState::Failed(format!("serialize failed: {e}")); - cx.notify(); - return; - } - }; - let Some(path) = config_path() else { - self.test = TestState::Failed("no config directory on this platform".into()); - cx.notify(); - return; - }; - if let Some(parent) = path.parent() - && let Err(e) = std::fs::create_dir_all(parent) - { - self.test = TestState::Failed(format!("mkdir failed: {e}")); - cx.notify(); - return; - } - match std::fs::write(&path, out) { + match save_config(&cfg) { Ok(()) => { self.test = TestState::Ok; cx.emit(ConnectEvent::SavedProfile(profile)); cx.notify(); } Err(e) => { - self.test = TestState::Failed(format!("write failed: {e}")); + self.test = TestState::Failed(e); cx.notify(); } } diff --git a/app/src/main.rs b/app/src/main.rs index 5e826fa..f989051 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -8,13 +8,14 @@ //! * Fonts must be registered before the first window paints. use bezel::gpui::{ - App, AppContext as _, Bounds, Focusable as _, Menu, MenuItem, SharedString, TitlebarOptions, - WindowBounds, WindowOptions, actions, px, size, + App, AppContext as _, Bounds, Focusable as _, KeyBinding, Menu, MenuItem, SharedString, + TitlebarOptions, WindowBounds, WindowOptions, actions, px, size, }; use bezel::theme; use bezel::ui; -use chm_app::config::{Cli, CliError}; -use chm_app::shell::Shell; +use chm_app::config::{Cli, CliError, load_config}; +use chm_app::pages::settings::appearance_from_cfg; +use chm_app::shell::{OpenSettings, Refresh, Shell, ToggleSidebar}; actions!(chm_app, [Quit]); @@ -46,13 +47,28 @@ fn main() { if let Err(err) = ui::register_fonts(cx) { eprintln!("FONT REGISTRATION FAILED: {err:?}"); } - theme::appearance::init(theme::appearance::AppearanceMode::System, cx); + let appearance = appearance_from_cfg(load_config().ui.appearance.as_deref()); + theme::appearance::init(appearance, cx); // TextField keybindings are opt-in and scoped to the field's key context. ui::input::init(cx); + // Bind before set_menus so the menu bar can show the keystrokes. + cx.bind_keys([ + KeyBinding::new("cmd-,", OpenSettings, None), + KeyBinding::new("cmd-b", ToggleSidebar, None), + KeyBinding::new("cmd-r", Refresh, None), + ]); // Without a menu item cmd-q does nothing — no nib ships with a gpui app. cx.on_action(|_: &Quit, cx: &mut App| cx.quit()); cx.set_menus(vec![ - Menu::new("chmonitor").items([MenuItem::action("Quit", Quit)]), + Menu::new("chmonitor").items([ + MenuItem::action("Settings…", OpenSettings), + MenuItem::separator(), + MenuItem::action("Quit", Quit), + ]), + Menu::new("View").items([ + MenuItem::action("Toggle Sidebar", ToggleSidebar), + MenuItem::action("Refresh", Refresh), + ]), ]); let bounds = Bounds::centered(None, size(px(1280.0), px(800.0)), cx); diff --git a/app/src/pages/mod.rs b/app/src/pages/mod.rs index 1117f0a..945ca86 100644 --- a/app/src/pages/mod.rs +++ b/app/src/pages/mod.rs @@ -8,6 +8,7 @@ pub mod merges; pub mod overview; pub mod queries; pub mod replicas; +pub mod settings; pub mod tables; pub mod traffic; @@ -22,6 +23,7 @@ pub enum Page { Tables, Traffic, Connect, + Settings, } impl Page { @@ -51,6 +53,7 @@ impl Page { Page::Tables => "Tables", Page::Traffic => "Traffic", Page::Connect => "Connect", + Page::Settings => "Settings", } } @@ -72,6 +75,7 @@ impl Page { Page::Tables => "▤", Page::Traffic => "↕", Page::Connect => "⌁", + Page::Settings => "⚙", }; div() .w(px(16.0)) @@ -118,5 +122,7 @@ mod tests { assert!(Page::Traffic.uses_range()); assert!(!Page::Merges.uses_range()); assert!(!Page::Connect.uses_range()); + assert_eq!(Page::Settings.title(), "Settings"); + assert!(!Page::ALL.contains(&Page::Settings)); } } diff --git a/app/src/pages/settings.rs b/app/src/pages/settings.rs new file mode 100644 index 0000000..989973f --- /dev/null +++ b/app/src/pages/settings.rs @@ -0,0 +1,301 @@ +//! Settings page — appearance, update channel, telemetry. Opened from the +//! app menu (`cmd-,`) or the sidebar footer. Writes `[ui]` / `[telemetry]` +//! / `profile.channel` in config.toml. + +use bezel::gpui::{Context, Render, SharedString, Window, div, prelude::*, px}; +use bezel::theme::{Theme, appearance::AppearanceMode}; +use chm_update::Channel; + +use crate::config::{config_path, load_config, save_config}; +use crate::pages::heading; + +pub struct SettingsPage { + appearance: AppearanceMode, + channel: Channel, + telemetry: bool, + status: Option, +} + +impl Default for SettingsPage { + fn default() -> Self { + Self::new() + } +} + +impl SettingsPage { + pub fn new() -> Self { + let cfg = load_config(); + Self { + appearance: appearance_from_cfg(cfg.ui.appearance.as_deref()), + channel: channel_from_cfg(cfg.profile.channel.as_deref()), + telemetry: cfg.telemetry.enabled, + status: None, + } + } + + fn persist(&mut self) { + let mut cfg = load_config(); + cfg.ui.appearance = Some(appearance_to_cfg(self.appearance).into()); + cfg.profile.channel = Some(self.channel.as_str().into()); + cfg.telemetry.enabled = self.telemetry; + self.status = save_config(&cfg).err(); + } + + fn set_appearance(&mut self, mode: AppearanceMode, cx: &mut Context) { + self.appearance = mode; + bezel::theme::appearance::set_mode(mode, cx); + self.persist(); + cx.notify(); + } + + fn set_channel(&mut self, channel: Channel, cx: &mut Context) { + self.channel = channel; + self.persist(); + cx.notify(); + } + + fn set_telemetry(&mut self, enabled: bool, cx: &mut Context) { + self.telemetry = enabled; + self.persist(); + cx.notify(); + } + + fn choice_row( + &self, + theme: &Theme, + id: &'static str, + title: (&'static str, &'static str), + selected: bool, + on: impl Fn(&mut Self, &mut Context) + 'static, + cx: &mut Context, + ) -> impl bezel::gpui::IntoElement { + let (label, hint) = title; + div() + .id(SharedString::from(id)) + .flex() + .flex_row() + .items_center() + .gap(px(10.0)) + .px(px(12.0)) + .py(px(8.0)) + .rounded(px(8.0)) + .border_1() + .border_color(if selected { + theme.border_strong + } else { + theme.border + }) + .bg(if selected { + theme.element_active + } else { + theme.input_bg + }) + .cursor_pointer() + .hover(|s| s.bg(theme.element_hover)) + .on_click(cx.listener(move |this, _, _, cx| on(this, cx))) + .child( + div() + .size(px(14.0)) + .rounded_full() + .border_1() + .border_color(theme.border_strong) + .when(selected, |dot| dot.bg(theme.accent)), + ) + .child( + div() + .flex() + .flex_col() + .gap(px(2.0)) + .child(div().text_size(px(13.0)).child(label)) + .child( + div() + .text_size(px(11.0)) + .text_color(theme.text_muted) + .child(hint), + ), + ) + } + + fn section( + title: &str, + children: impl IntoIterator, + ) -> bezel::gpui::Div { + div() + .flex() + .flex_col() + .gap(px(8.0)) + .child(heading(title)) + .children(children) + } +} + +impl Render for SettingsPage { + fn render( + &mut self, + _window: &mut Window, + cx: &mut Context, + ) -> impl bezel::gpui::IntoElement { + let theme = Theme::of(cx).clone(); + let path = config_path() + .map(|p| p.display().to_string()) + .unwrap_or_else(|| "(no config directory)".into()); + let appearance = self.appearance; + let channel = self.channel; + let telemetry = self.telemetry; + + div() + .flex() + .flex_col() + .gap(px(20.0)) + .max_w(px(520.0)) + .child(Self::section( + "Appearance", + AppearanceMode::ALL.iter().map(|&mode| { + self.choice_row( + &theme, + match mode { + AppearanceMode::System => "app-system", + AppearanceMode::Light => "app-light", + AppearanceMode::Dark => "app-dark", + }, + ( + mode.label(), + match mode { + AppearanceMode::System => "follow macOS light/dark", + AppearanceMode::Light => "always light", + AppearanceMode::Dark => "always dark", + }, + ), + appearance == mode, + move |this, cx| this.set_appearance(mode, cx), + cx, + ) + .into_any_element() + }), + )) + .child(Self::section( + "Updates", + [ + self.choice_row( + &theme, + "ch-stable", + ("Stable", "tagged releases"), + channel == Channel::Stable, + |this, cx| this.set_channel(Channel::Stable, cx), + cx, + ) + .into_any_element(), + self.choice_row( + &theme, + "ch-beta", + ("Beta", "pre-release builds"), + channel == Channel::Beta, + |this, cx| this.set_channel(Channel::Beta, cx), + cx, + ) + .into_any_element(), + ], + )) + .child(Self::section( + "Telemetry", + [ + self.choice_row( + &theme, + "tel-off", + ("Off", "nothing is recorded or sent (default)"), + !telemetry, + |this, cx| this.set_telemetry(false, cx), + cx, + ) + .into_any_element(), + self.choice_row( + &theme, + "tel-on", + ("On", "local fetch timings only; no query text"), + telemetry, + |this, cx| this.set_telemetry(true, cx), + cx, + ) + .into_any_element(), + ], + )) + .child(Self::section( + "Shortcuts", + [div() + .flex() + .flex_col() + .gap(px(4.0)) + .text_size(px(12.0)) + .text_color(theme.text_muted) + .child("1–8 switch page") + .child("r refresh") + .child("⌘B toggle sidebar") + .child("⌘, settings") + .child("⌘Q quit") + .into_any_element()], + )) + .child( + div() + .flex() + .flex_col() + .gap(px(4.0)) + .child(heading("Config file")) + .child( + div() + .text_size(px(12.0)) + .text_color(theme.text_muted) + .child(format!("chmonitor {}", env!("CARGO_PKG_VERSION"))), + ) + .child( + div() + .text_size(px(12.0)) + .text_color(theme.text_muted) + .child(path), + ), + ) + .children(self.status.as_ref().map(|e| { + div() + .text_size(px(12.0)) + .text_color(theme.danger) + .child(e.clone()) + })) + } +} + +pub fn appearance_from_cfg(s: Option<&str>) -> AppearanceMode { + match s.map(|s| s.to_ascii_lowercase()).as_deref() { + Some("light") => AppearanceMode::Light, + Some("dark") => AppearanceMode::Dark, + _ => AppearanceMode::System, + } +} + +fn appearance_to_cfg(mode: AppearanceMode) -> &'static str { + match mode { + AppearanceMode::System => "system", + AppearanceMode::Light => "light", + AppearanceMode::Dark => "dark", + } +} + +fn channel_from_cfg(s: Option<&str>) -> Channel { + match s.map(|s| s.to_ascii_lowercase()).as_deref() { + Some("beta") => Channel::Beta, + _ => Channel::Stable, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn appearance_and_channel_parse() { + assert_eq!(appearance_from_cfg(None), AppearanceMode::System); + assert_eq!(appearance_from_cfg(Some("DARK")), AppearanceMode::Dark); + assert_eq!(appearance_from_cfg(Some("light")), AppearanceMode::Light); + assert_eq!(channel_from_cfg(Some("beta")), Channel::Beta); + assert_eq!(channel_from_cfg(None), Channel::Stable); + assert_eq!(appearance_to_cfg(AppearanceMode::Dark), "dark"); + } +} diff --git a/app/src/shell.rs b/app/src/shell.rs index b881d12..c5000c6 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -28,10 +28,11 @@ use crate::pages::merges::MergesPage; use crate::pages::overview::OverviewPage; use crate::pages::queries::QueriesPage; use crate::pages::replicas::ReplicasPage; +use crate::pages::settings::SettingsPage; use crate::pages::tables::TablesPage; use crate::pages::traffic::TrafficPage; -actions!(chm_shell, [Refresh, ToggleSidebar]); +actions!(chm_shell, [Refresh, ToggleSidebar, OpenSettings]); /// Seconds between automatic background refreshes. const POLL_SECS: u64 = 30; @@ -91,6 +92,7 @@ pub struct Shell { tables: Entity, traffic: Entity, connect: Entity, + settings: Entity, update_note: Option, /// `None` follows the viewport; `Some` is a click/`cmd-b` override. sidebar_collapsed: Option, @@ -190,6 +192,7 @@ impl Shell { tables: cx.new(|_| TablesPage::new()), traffic: cx.new(|_| TrafficPage::new()), connect: cx.new(|cx| ConnectFlow::new(load_profile(), cx)), + settings: cx.new(|_| SettingsPage::new()), update_note: None, sidebar_collapsed: None, }; @@ -199,6 +202,7 @@ impl Shell { cx.bind_keys([ KeyBinding::new("r", Refresh, None), KeyBinding::new("cmd-b", ToggleSidebar, None), + KeyBinding::new("cmd-,", OpenSettings, None), ]); // Rebuild the source after the Connect screen writes a new profile. @@ -258,7 +262,7 @@ impl Shell { /// Snapshot what a background fetch needs. Cheap: one Arc clone + a copy. fn poll_job(&self) -> Option { - if self.page == Page::Connect { + if matches!(self.page, Page::Connect | Page::Settings) { return None; } self.source.as_ref().map(|src| PollJob { @@ -442,6 +446,50 @@ impl Shell { .children(items) } + fn settings_nav( + &self, + theme: &Theme, + compact: bool, + cx: &mut Context, + ) -> impl IntoElement { + let active = self.page == Page::Settings; + let label = if compact { + div().child(Page::Settings.icon()) + } else { + div() + .flex() + .flex_row() + .items_center() + .gap(px(8.0)) + .child(Page::Settings.icon()) + .child(div().child(Page::Settings.title())) + .child( + div() + .ml(px(2.0)) + .text_size(px(10.0)) + .text_color(theme.text_faint) + .child("⌘,"), + ) + }; + div() + .id("nav-Settings") + .w_full() + .px(px(if compact { 0.0 } else { 12.0 })) + .py(px(6.0)) + .rounded(px(6.0)) + .cursor_pointer() + .when(active, |el| el.bg(theme.element_active)) + .hover(|s| s.bg(theme.element_hover)) + .text_size(px(13.0)) + .text_color(if active { theme.text } else { theme.text_muted }) + .when(compact, |el| el.flex().justify_center()) + .on_click(cx.listener(|this, _: &bezel::gpui::ClickEvent, _, cx| { + this.page = Page::Settings; + cx.notify(); + })) + .child(label) + } + fn range_bar(&self, theme: &Theme, cx: &mut Context) -> bezel::gpui::Div { let mut row = div().flex().flex_row().items_center().gap(px(4.0)); for range in TimeRange::ALL { @@ -514,7 +562,7 @@ impl Shell { fn content(&mut self, _cx: &mut Context) -> bezel::gpui::AnyElement { // No source yet: Connect owns the pane whatever the route points at. - if self.source.is_none() && self.page != Page::Connect { + if self.source.is_none() && !matches!(self.page, Page::Connect | Page::Settings) { return div() .flex() .flex_1() @@ -534,6 +582,7 @@ impl Shell { Page::Tables => self.tables.clone().into_any_element(), Page::Traffic => self.traffic.clone().into_any_element(), Page::Connect => self.connect.clone().into_any_element(), + Page::Settings => self.settings.clone().into_any_element(), } } } @@ -561,6 +610,10 @@ impl Render for Shell { .on_action(cx.listener(|this, _: &ToggleSidebar, window, cx| { this.toggle_sidebar(window.viewport_size().width < px(COMPACT_BELOW), cx); })) + .on_action(cx.listener(|this, _: &OpenSettings, _, cx| { + this.page = Page::Settings; + cx.notify(); + })) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { // Only when the shell itself holds focus — digits typed into // a Connect text field must not switch pages. @@ -606,7 +659,18 @@ impl Render for Shell { .border_color(theme.border) .bg(theme.surface) .child(self.sidebar_toggle(&theme, compact, cx)) - .child(self.sidebar(&theme, compact, cx)), + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_h_0() + .when(compact, |col| col.items_center()) + .child(self.sidebar(&theme, compact, cx)) + .child(div().flex_1()) + .child(self.settings_nav(&theme, compact, cx)) + .pb(px(28.0)), + ), ) .child( div() @@ -686,7 +750,7 @@ async fn apply_poll(job: PollJob, this: &WeakEntity, cx: &mut AsyncApp) { let src = job.src; let range = job.range; let outcome = match job.page { - Page::Connect => return, + Page::Connect | Page::Settings => return, Page::Overview => { let (overview, traffic) = chm_core::tokio_block_on(async { tokio::join!(src.overview(range), src.traffic(range)) From 1b8d89f6ff3302d44230728c33b48c8c98d6c9f4 Mon Sep 17 00:00:00 2001 From: duyetbot Date: Mon, 24 Aug 2026 18:33:40 +0700 Subject: [PATCH 07/20] feat(app): add host switcher and host status The sidebar lists saved hosts ([profile] plus [profiles.*]) and Connect can name a host on save. The status bar shows the active host, ok/error, version, replica count, and last fetch time. --- README.md | 3 +- app/src/config.rs | 173 +++++++++++++++++++++++++- app/src/connect.rs | 24 +++- app/src/shell.rs | 301 ++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 460 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 4e539d4..8c172d3 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ CHM_CONFIG=/tmp/chmonitor.toml cargo run -p chm-app Named profiles live under `[profiles.]` in `config.toml`; the default connection is `[profile]`. `r` refreshes the current page; keys `1`–`8` switch sidebar destinations; `cmd-b` toggles the sidebar; -`cmd-,` opens Settings. +`cmd-,` opens Settings. The sidebar host switcher lists `[profile]` plus +`[profiles.]`; Connect's optional Name field saves a named host. ## Layout diff --git a/app/src/config.rs b/app/src/config.rs index 50374ee..9dbe571 100644 --- a/app/src/config.rs +++ b/app/src/config.rs @@ -43,11 +43,15 @@ pub struct TelemetrySection { pub enabled: bool, } -/// `[ui]` table — appearance preference (`system` / `light` / `dark`). +/// `[ui]` table — appearance preference (`system` / `light` / `dark`) +/// and the selected host id (`default` or a `[profiles.*]` key). #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] pub struct UiSection { #[serde(default)] pub appearance: Option, + /// Active host: `"default"` for `[profile]`, or a `[profiles.]` key. + #[serde(default)] + pub host: Option, } /// Whole `config.toml`. @@ -128,6 +132,122 @@ pub fn load_profile_from(path: &Path, named: Option<&str>) -> Option String { + match p.mode.as_deref() { + Some("cloud") => host_from_url(p.base_url.as_deref()).unwrap_or_else(|| "cloud".into()), + Some("clickhouse") => { + host_from_url(p.url.as_deref()).unwrap_or_else(|| "clickhouse".into()) + } + _ => "host".into(), + } +} + +pub fn host_label(id: &str, p: &ProfileConfig) -> String { + if id != DEFAULT_HOST_ID { + return id.to_string(); + } + host_display(p) +} + +/// Best-effort host[:port] from an HTTP(S) URL. `None` when empty/unusable. +pub fn host_from_url(raw: Option<&str>) -> Option { + let raw = raw?.trim(); + if raw.is_empty() { + return None; + } + let rest = raw.split_once("://").map(|(_, r)| r).unwrap_or(raw); + let hostport = rest.split('/').next().unwrap_or(rest); + let host = hostport.split('@').next_back()?.split('?').next()?; + let host = host.trim(); + if host.is_empty() { + None + } else { + Some(host.to_string()) + } +} + +/// Turn a Connect "Name" field into a host id. Empty/`default` → `[profile]`. +pub fn host_id_from_name(name: &str) -> String { + let cleaned: String = name + .trim() + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect(); + if cleaned.is_empty() || cleaned == DEFAULT_HOST_ID { + DEFAULT_HOST_ID.into() + } else { + cleaned + } +} + +/// `[profile]` first, then named `[profiles.*]` in key order. +pub fn list_hosts(cfg: &ConfigFile) -> Vec { + let mut out = Vec::new(); + if cfg.profile.mode.is_some() { + out.push(Host { + id: DEFAULT_HOST_ID.into(), + label: host_label(DEFAULT_HOST_ID, &cfg.profile), + profile: cfg.profile.clone(), + }); + } + for (id, p) in &cfg.profiles { + if p.mode.is_none() || id.is_empty() || id == DEFAULT_HOST_ID { + continue; + } + out.push(Host { + id: id.clone(), + label: host_label(id, p), + profile: p.clone(), + }); + } + out +} + +pub fn profile_for_host(cfg: &ConfigFile, id: &str) -> Option { + list_hosts(cfg) + .into_iter() + .find(|h| h.id == id) + .map(|h| h.profile) +} + +/// Env `CHM_PROFILE`, then `[ui].host`, then the first listed host. +pub fn active_host_id(cfg: &ConfigFile) -> Option { + let hosts = list_hosts(cfg); + if hosts.is_empty() { + return None; + } + let listed = |id: &str| hosts.iter().any(|h| h.id == id); + if let Some(name) = profile_name_from_env() + && listed(&name) + { + return Some(name); + } + if let Some(name) = cfg.ui.host.as_deref() + && listed(name) + { + return Some(name.to_string()); + } + Some(hosts[0].id.clone()) +} + /// Build the boxed data source behind [`chm_core::DataSource`] for a saved /// profile. `None` when required fields are missing. pub fn source_from_profile(p: &ProfileConfig) -> Option> { @@ -353,4 +473,55 @@ user = "alice" assert_eq!(back.profile.channel.as_deref(), Some("beta")); assert!(back.telemetry.enabled); } + + #[test] + fn host_from_url_strips_scheme_and_path() { + assert_eq!( + host_from_url(Some("https://acme.dash.chmonitor.dev/api")), + Some("acme.dash.chmonitor.dev".into()) + ); + assert_eq!( + host_from_url(Some("http://localhost:8123")), + Some("localhost:8123".into()) + ); + assert_eq!(host_from_url(Some(" ")), None); + assert_eq!(host_from_url(None), None); + } + + #[test] + fn host_id_from_name_cleans_and_reserves_default() { + assert_eq!(host_id_from_name(""), DEFAULT_HOST_ID); + assert_eq!(host_id_from_name("default"), DEFAULT_HOST_ID); + assert_eq!(host_id_from_name(" prod / eu "), "prod---eu"); + assert_eq!(host_id_from_name("work"), "work"); + } + + #[test] + fn list_hosts_and_active_id() { + let mut cfg = ConfigFile::default(); + cfg.profile.mode = Some("cloud".into()); + cfg.profile.base_url = Some("https://acme.dash.chmonitor.dev".into()); + cfg.profiles.insert( + "work".into(), + ProfileConfig { + mode: Some("clickhouse".into()), + url: Some("http://localhost:8123".into()), + ..Default::default() + }, + ); + let hosts = list_hosts(&cfg); + assert_eq!(hosts.len(), 2); + assert_eq!(hosts[0].id, DEFAULT_HOST_ID); + assert_eq!(hosts[0].label, "acme.dash.chmonitor.dev"); + assert_eq!(hosts[1].id, "work"); + assert_eq!(active_host_id(&cfg).as_deref(), Some(DEFAULT_HOST_ID)); + cfg.ui.host = Some("work".into()); + assert_eq!(active_host_id(&cfg).as_deref(), Some("work")); + cfg.ui.host = Some("missing".into()); + assert_eq!(active_host_id(&cfg).as_deref(), Some(DEFAULT_HOST_ID)); + assert_eq!( + profile_for_host(&cfg, "work").unwrap().url.as_deref(), + Some("http://localhost:8123") + ); + } } diff --git a/app/src/connect.rs b/app/src/connect.rs index 46db2cb..10ca7e4 100644 --- a/app/src/connect.rs +++ b/app/src/connect.rs @@ -15,12 +15,18 @@ use bezel::theme::Theme; use bezel::ui::input::TextField; use bezel::ui::widgets::{ButtonStyle, Buttons}; -use crate::config::{ProfileConfig, load_config, save_config, source_from_profile}; +use crate::config::{ + DEFAULT_HOST_ID, ProfileConfig, host_id_from_name, load_config, save_config, + source_from_profile, +}; /// Fired after Save successfully writes config.toml. #[derive(Debug, Clone)] pub enum ConnectEvent { - SavedProfile(ProfileConfig), + SavedProfile { + profile: ProfileConfig, + host_id: String, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -46,6 +52,7 @@ pub struct ConnectFlow { url: Entity, user: Entity, password: Entity, + name: Entity, test: TestState, } @@ -82,6 +89,7 @@ impl ConnectFlow { url: field(initial.url, "http://localhost:8123", cx), user: field(initial.user.or(Some("default".into())), "user", cx), password: field(initial.password, "password", cx), + name: field(None, "work (optional)", cx), test: TestState::Idle, } } @@ -151,13 +159,18 @@ impl ConnectFlow { cx.notify(); return; }; - // Preserve anything outside [profile] (e.g. [telemetry], [ui]). + let host_id = host_id_from_name(&Self::read(&self.name, cx)); let mut cfg = load_config(); - cfg.profile = profile.clone(); + if host_id == DEFAULT_HOST_ID { + cfg.profile = profile.clone(); + } else { + cfg.profiles.insert(host_id.clone(), profile.clone()); + } + cfg.ui.host = Some(host_id.clone()); match save_config(&cfg) { Ok(()) => { self.test = TestState::Ok; - cx.emit(ConnectEvent::SavedProfile(profile)); + cx.emit(ConnectEvent::SavedProfile { profile, host_id }); cx.notify(); } Err(e) => { @@ -328,6 +341,7 @@ impl Render for ConnectFlow { ) .child(cloud_fields) .child(direct_fields) + .child(Self::field_row("Name", &self.name)) .child(self.status_line(cx)) .child( div() diff --git a/app/src/shell.rs b/app/src/shell.rs index c5000c6..b3399c1 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -20,7 +20,10 @@ use bezel::gpui::{ use bezel::theme::Theme; use bezel::ui::widgets::status_dot; -use crate::config::{ConfigFile, cli, config_path, load_profile, source_from_profile}; +use crate::config::{ + ConfigFile, Host, ProfileConfig, active_host_id, cli, config_path, list_hosts, load_config, + load_profile, profile_for_host, save_config, source_from_profile, +}; use crate::connect::{ConnectEvent, ConnectFlow}; use crate::pages::Page; use crate::pages::health::HealthPage; @@ -75,6 +78,16 @@ impl ConnState { #[derive(Debug, Clone)] struct UpdateNote(SharedString); +/// Glanceable facts about the active host (status bar). +#[derive(Debug, Clone, Default)] +struct HostStatus { + version: Option, + replicas_ok: u64, + replicas_total: u64, + health_ok: Option, + fetch_ms: Option, +} + /// The root view: owns routing, the active data source and the poll task. pub struct Shell { focus: FocusHandle, @@ -96,6 +109,9 @@ pub struct Shell { update_note: Option, /// `None` follows the viewport; `Some` is a click/`cmd-b` override. sidebar_collapsed: Option, + active_host: Option, + host_menu_open: bool, + host_status: HostStatus, } impl Focusable for Shell { @@ -109,26 +125,31 @@ impl Shell { /// * `CHM_SMOKE=1` forces [`MockDataSource`]; /// * else the saved profile builds a cloud/direct client; /// * else no source — Connect becomes the content pane. - fn pick_source() -> (Option>>, ConnState) { + #[allow(clippy::type_complexity)] + fn pick_source() -> (Option>>, ConnState, Option) { if std::env::var("CHM_SMOKE").is_ok() { return ( Some(Arc::new( Box::new(MockDataSource::new("mock (CHM_SMOKE)")) as Box )), ConnState::Connected, + Some("smoke".into()), ); } - match load_profile() { - Some(profile) => match source_from_profile(&profile) { - Some(src) => (Some(Arc::new(src)), ConnState::Connecting), - None => (None, ConnState::Error), - }, - None => (None, ConnState::Error), + let cfg = load_config(); + let id = active_host_id(&cfg); + match id + .as_deref() + .and_then(|id| profile_for_host(&cfg, id)) + .and_then(|p| source_from_profile(&p)) + { + Some(src) => (Some(Arc::new(src)), ConnState::Connecting, id), + None => (None, ConnState::Error, id), } } pub fn new(cx: &mut Context) -> Self { - let (source, conn) = Self::pick_source(); + let (source, conn, active_host) = Self::pick_source(); // Telemetry hook: opt-in only. Recording stays off unless the user // explicitly set `[telemetry] enabled = true`; nothing else enables it. @@ -195,6 +216,9 @@ impl Shell { settings: cx.new(|_| SettingsPage::new()), update_note: None, sidebar_collapsed: None, + active_host, + host_menu_open: false, + host_status: HostStatus::default(), }; // Digits 1-8 switch pages; handled in render's on_key_down so it works @@ -207,13 +231,8 @@ impl Shell { // Rebuild the source after the Connect screen writes a new profile. cx.subscribe(&shell.connect, |this, _, event: &ConnectEvent, cx| { - let ConnectEvent::SavedProfile(profile) = event; - this.source = source_from_profile(profile).map(Arc::new); - this.conn = if this.source.is_some() { - ConnState::Connecting - } else { - ConnState::Error - }; + let ConnectEvent::SavedProfile { profile, host_id } = event; + this.apply_host(host_id.clone(), profile.clone()); this.page = Page::Overview; this.refresh_now(cx); cx.notify(); @@ -240,6 +259,101 @@ impl Shell { cx.notify(); } + fn apply_host(&mut self, host_id: String, profile: ProfileConfig) { + self.active_host = Some(host_id); + self.source = source_from_profile(&profile).map(Arc::new); + self.conn = if self.source.is_some() { + ConnState::Connecting + } else { + ConnState::Error + }; + self.host_status = HostStatus::default(); + self.host_menu_open = false; + self.last_error = None; + } + + fn switch_host(&mut self, host_id: String, cx: &mut Context) { + if self.active_host.as_deref() == Some(host_id.as_str()) { + self.host_menu_open = false; + cx.notify(); + return; + } + if std::env::var("CHM_SMOKE").is_ok() { + self.host_menu_open = false; + cx.notify(); + return; + } + let cfg = load_config(); + let Some(profile) = profile_for_host(&cfg, &host_id) else { + return; + }; + let mut cfg = load_config(); + cfg.ui.host = Some(host_id.clone()); + let _ = save_config(&cfg); + self.apply_host(host_id, profile); + if matches!(self.page, Page::Connect | Page::Settings) { + self.page = Page::Overview; + } + self.refresh_now(cx); + cx.notify(); + } + + fn hosts(&self) -> Vec { + if std::env::var("CHM_SMOKE").is_ok() { + return vec![Host { + id: "smoke".into(), + label: "smoke".into(), + profile: ProfileConfig { + mode: Some("mock".into()), + ..Default::default() + }, + }]; + } + list_hosts(&load_config()) + } + + fn active_host_label(&self) -> String { + let hosts = self.hosts(); + if let Some(id) = &self.active_host + && let Some(h) = hosts.iter().find(|h| &h.id == id) + { + return h.label.clone(); + } + self.source + .as_ref() + .map(|s| s.label()) + .unwrap_or_else(|| "no host".into()) + } + + fn host_status_text(&self) -> String { + match self.conn { + ConnState::Connecting => "connecting".into(), + ConnState::Error => self.last_error.clone().unwrap_or_else(|| "error".into()), + ConnState::Connected => { + let mut parts = Vec::new(); + match self.host_status.health_ok { + Some(false) => parts.push("not ok".into()), + _ => parts.push("ok".into()), + } + if let Some(v) = &self.host_status.version + && !v.is_empty() + { + parts.push(v.clone()); + } + if self.host_status.replicas_total > 0 { + parts.push(format!( + "{}/{} replicas", + self.host_status.replicas_ok, self.host_status.replicas_total + )); + } + if let Some(ms) = self.host_status.fetch_ms { + parts.push(format!("{ms:.0}ms")); + } + parts.join(" · ") + } + } + } + /// Recurring refresh: each tick re-spawns itself, so a slow fetch can /// never overlap the next one. Exits once the shell is dropped. fn start_poll(&self, cx: &mut Context) { @@ -286,11 +400,18 @@ impl Shell { &mut self, outcome: PollOutcome, at: chrono::DateTime, + fetch_ms: f64, cx: &mut Context, ) { self.last_refresh = Some(at); + self.host_status.fetch_ms = Some(fetch_ms); match outcome { PollOutcome::Overview { overview, traffic } => { + if let Ok(o) = &overview { + self.host_status.version = Some(o.clickhouse_version.clone()); + self.host_status.replicas_ok = o.replicas_ok; + self.host_status.replicas_total = o.replicas_total; + } self.set_conn(overview.is_ok(), overview.as_ref().err().cloned()); self.overview .update(cx, |p, cx| p.set_overview(overview, traffic, cx)); @@ -320,6 +441,9 @@ impl Shell { self.replicas.update(cx, |p, cx| p.set(data, cx)); } PollOutcome::Health(data) => { + if let Ok(h) = &data { + self.host_status.health_ok = Some(h.ok); + } self.set_conn(data.is_ok(), data.as_ref().err().cloned()); self.health.update(cx, |p, cx| p.set(data, cx)); } @@ -393,6 +517,111 @@ impl Shell { .child(label) } + fn host_switcher( + &self, + theme: &Theme, + compact: bool, + cx: &mut Context, + ) -> impl IntoElement { + let label = self.active_host_label(); + let chevron = if self.host_menu_open { "▴" } else { "▾" }; + let trigger = div() + .id("host-switcher") + .w_full() + .px(px(if compact { 0.0 } else { 12.0 })) + .py(px(6.0)) + .rounded(px(6.0)) + .cursor_pointer() + .hover(|s| s.bg(theme.element_hover)) + .when(compact, |el| el.flex().justify_center()) + .on_click(cx.listener(|this, _: &bezel::gpui::ClickEvent, _, cx| { + this.host_menu_open = !this.host_menu_open; + cx.notify(); + })) + .child(if compact { + div().child(status_dot(self.conn.dot(theme))) + } else { + div() + .flex() + .flex_row() + .items_center() + .gap(px(8.0)) + .w_full() + .child(status_dot(self.conn.dot(theme))) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_size(px(13.0)) + .child(label), + ) + .child( + div() + .text_size(px(10.0)) + .text_color(theme.text_faint) + .child(chevron), + ) + }); + + let mut col = div().flex().flex_col().gap(px(2.0)).child(trigger); + if self.host_menu_open { + let active = self.active_host.clone(); + for host in self.hosts() { + let id = host.id.clone(); + let selected = active.as_deref() == Some(id.as_str()); + let row_label = host.label.clone(); + col = col.child( + div() + .id(SharedString::from(format!("host-{id}"))) + .w_full() + .px(px(if compact { 0.0 } else { 12.0 })) + .py(px(5.0)) + .rounded(px(6.0)) + .cursor_pointer() + .when(selected, |el| el.bg(theme.element_active)) + .hover(|s| s.bg(theme.element_hover)) + .text_size(px(12.0)) + .when(compact, |el| el.flex().justify_center()) + .on_click(cx.listener(move |this, _, _, cx| { + this.switch_host(id.clone(), cx); + })) + .child(if compact { + div() + .text_size(px(10.0)) + .child(row_label.chars().next().unwrap_or('·').to_string()) + } else { + div().truncate().child(row_label) + }), + ); + } + col = col.child( + div() + .id("host-add") + .w_full() + .px(px(if compact { 0.0 } else { 12.0 })) + .py(px(5.0)) + .rounded(px(6.0)) + .cursor_pointer() + .hover(|s| s.bg(theme.element_hover)) + .text_size(px(12.0)) + .text_color(theme.text_muted) + .when(compact, |el| el.flex().justify_center()) + .on_click(cx.listener(|this, _, _, cx| { + this.host_menu_open = false; + this.page = Page::Connect; + cx.notify(); + })) + .child(if compact { + SharedString::from("+") + } else { + SharedString::from("+ Add host") + }), + ); + } + col + } + fn sidebar(&self, theme: &Theme, compact: bool, cx: &mut Context) -> bezel::gpui::Div { let items: Vec = Page::ALL .iter() @@ -521,11 +750,13 @@ impl Shell { } fn status_bar(&self, theme: &Theme) -> bezel::gpui::Div { - let label = self - .source - .as_ref() - .map(|s| SharedString::from(s.label())) - .unwrap_or_else(|| "no source configured".into()); + let host = SharedString::from(self.active_host_label()); + let status = SharedString::from(self.host_status_text()); + let status_color = match self.conn { + ConnState::Error => theme.danger, + ConnState::Connecting => theme.warning, + ConnState::Connected => theme.text_muted, + }; let refreshed = match self.last_refresh { Some(at) => format!("updated {}", at.format("%H:%M:%S")), None => "not refreshed yet".to_string(), @@ -535,10 +766,6 @@ impl Shell { Some(UpdateNote(t)) if !t.is_empty() => Some(t.clone()), Some(_) => None, }; - let err = self - .last_error - .as_ref() - .map(|e| SharedString::from(e.clone())); div() .flex() @@ -553,10 +780,16 @@ impl Shell { .text_size(px(11.5)) .text_color(theme.text_muted) .child(status_dot(self.conn.dot(theme))) - .child(div().min_w_0().truncate().child(label)) + .child(div().min_w_0().truncate().child(host)) + .child( + div() + .min_w_0() + .flex_1() + .truncate() + .text_color(status_color) + .child(status), + ) .child(div().child(refreshed)) - .children(err.map(|e| div().min_w_0().truncate().text_color(theme.danger).child(e))) - .child(div().flex_1()) .children(note.map(|t| div().text_color(theme.text_faint).child(t))) } @@ -666,6 +899,7 @@ impl Render for Shell { .flex_1() .min_h_0() .when(compact, |col| col.items_center()) + .child(self.host_switcher(&theme, compact, cx)) .child(self.sidebar(&theme, compact, cx)) .child(div().flex_1()) .child(self.settings_nav(&theme, compact, cx)) @@ -784,9 +1018,12 @@ async fn apply_poll(job: PollJob, this: &WeakEntity, cx: &mut AsyncApp) { }; // Telemetry hook: fetch latency lands in PerfMetrics whenever the process // global exists; recording itself is opt-in via config.toml at startup. - let _ = perf().record_fetch(started.elapsed().as_secs_f64() * 1000.0); + let fetch_ms = started.elapsed().as_secs_f64() * 1000.0; + let _ = perf().record_fetch(fetch_ms); let at = chrono::Utc::now(); - let _ = this.update(cx, |shell, cx| shell.apply_outcome(outcome, at, cx)); + let _ = this.update(cx, |shell, cx| { + shell.apply_outcome(outcome, at, fetch_ms, cx) + }); } /// Compact (icon strip) when the user collapsed it, otherwise when the @@ -795,10 +1032,6 @@ fn sidebar_is_compact(user: Option, narrow: bool) -> bool { user.unwrap_or(narrow) } -// Re-export so existing `crate::shell::ProfileConfig` paths keep compiling -// if any leftover call sites remain. -pub use crate::config::ProfileConfig; - #[cfg(test)] mod tests { use super::sidebar_is_compact; From e398dbee01f4809cc002a896f4b58589f7c1ff2a Mon Sep 17 00:00:00 2001 From: duyetbot Date: Mon, 24 Aug 2026 18:41:30 +0700 Subject: [PATCH 08/20] feat(app): add ClickHouse and Postgres host types Connect can add a CH or PG host. Postgres is a read-only tokio-postgres client (pg_stat_activity/statements/tables/replication). Merges and Traffic stay hidden on a PG host so the sidebar matches the engine. --- Cargo.lock | 378 ++++++++++++++++++++++++-- Cargo.toml | 2 + README.md | 3 + app/Cargo.toml | 1 + app/src/config.rs | 30 ++ app/src/connect.rs | 99 ++++--- app/src/pages/mod.rs | 11 + app/src/shell.rs | 42 ++- crates/chm-cloud-api/src/lib.rs | 4 + crates/chm-core/src/lib.rs | 30 ++ crates/chm-postgres/Cargo.toml | 18 ++ crates/chm-postgres/src/lib.rs | 466 ++++++++++++++++++++++++++++++++ 12 files changed, 1017 insertions(+), 67 deletions(-) create mode 100644 crates/chm-postgres/Cargo.toml create mode 100644 crates/chm-postgres/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 2b53b4d..5254a94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -683,6 +683,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -900,6 +909,7 @@ dependencies = [ "chm-clickhouse", "chm-cloud-api", "chm-core", + "chm-postgres", "chm-telemetry", "chm-update", "chrono", @@ -957,6 +967,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "chm-postgres" +version = "0.1.0" +dependencies = [ + "async-trait", + "chm-core", + "chrono", + "native-tls", + "postgres-native-tls", + "tokio", + "tokio-postgres", + "tracing", +] + [[package]] name = "chm-telemetry" version = "0.1.0" @@ -1006,7 +1030,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", "zeroize", ] @@ -1031,6 +1055,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cocoa" version = "0.25.0" @@ -1042,7 +1072,7 @@ dependencies = [ "cocoa-foundation 0.1.2", "core-foundation 0.9.4", "core-graphics 0.23.2", - "foreign-types", + "foreign-types 0.5.0", "libc", "objc", ] @@ -1058,7 +1088,7 @@ dependencies = [ "cocoa-foundation 0.2.0", "core-foundation 0.10.1", "core-graphics 0.24.0", - "foreign-types", + "foreign-types 0.5.0", "libc", "objc", ] @@ -1165,6 +1195,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -1229,7 +1265,7 @@ dependencies = [ "bitflags 1.3.2", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1242,7 +1278,7 @@ dependencies = [ "bitflags 2.13.1", "core-foundation 0.10.1", "core-graphics-types 0.2.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1255,7 +1291,7 @@ dependencies = [ "bitflags 2.13.1", "core-foundation 0.9.4", "core-graphics-types 0.1.3", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1302,7 +1338,7 @@ checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" dependencies = [ "core-foundation 0.10.1", "core-graphics 0.24.0", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -1430,6 +1466,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctor" version = "1.0.13" @@ -1440,6 +1485,15 @@ dependencies = [ "linktime-proc-macro", ] +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "data-url" version = "0.3.2" @@ -1509,11 +1563,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1776,6 +1842,12 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + [[package]] name = "fastrand" version = "2.5.0" @@ -1922,6 +1994,15 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1929,7 +2010,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -1943,6 +2024,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -2118,7 +2205,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -2264,7 +2351,7 @@ dependencies = [ "derive_more", "embed-resource", "etagere", - "foreign-types", + "foreign-types 0.5.0", "futures", "futures-concurrency", "getrandom 0.3.4", @@ -2336,7 +2423,7 @@ dependencies = [ "core-video", "derive_more", "etagere", - "foreign-types", + "foreign-types 0.5.0", "gpui", "image", "log", @@ -2408,7 +2495,7 @@ dependencies = [ "core-text", "ctor", "dispatch2", - "foreign-types", + "foreign-types 0.5.0", "futures", "gpui", "gpui_apple", @@ -2692,7 +2779,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", ] [[package]] @@ -2701,7 +2788,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -2787,6 +2883,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.11.0" @@ -3479,7 +3584,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", ] [[package]] @@ -3492,7 +3607,7 @@ dependencies = [ "core-foundation 0.10.1", "core-video", "ctor", - "foreign-types", + "foreign-types 0.5.0", "metal", "objc", ] @@ -3530,7 +3645,7 @@ dependencies = [ "bitflags 2.13.1", "block", "core-graphics-types 0.2.0", - "foreign-types", + "foreign-types 0.5.0", "log", "objc", "paste", @@ -3575,7 +3690,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -3624,6 +3739,23 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk-sys" version = "0.6.0+11769913" @@ -4023,6 +4155,15 @@ dependencies = [ "objc2-metal 0.3.2", ] +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "objc2-user-notifications" version = "0.3.2" @@ -4092,20 +4233,20 @@ dependencies = [ "blocking", "cbc", "cipher", - "digest", + "digest 0.10.7", "endi", "futures-lite", "futures-util", "getrandom 0.4.3", "hkdf", - "hmac", - "md-5", + "hmac 0.12.1", + "md-5 0.10.6", "num", "num-bigint-dig", "pbkdf2", "serde", "serde_bytes", - "sha2", + "sha2 0.10.9", "subtle", "zbus", "zbus_macros", @@ -4123,12 +4264,49 @@ dependencies = [ "libc", ] +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.1", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -4220,8 +4398,8 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", - "hmac", + "digest 0.10.7", + "hmac 0.12.1", ] [[package]] @@ -4425,6 +4603,48 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "postgres-native-tls" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fef4de47bb81477e0c3deaf153a1b10ae176484713ff1640969f4cb96b653ebc" +dependencies = [ + "native-tls", + "tokio", + "tokio-native-tls", + "tokio-postgres", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator", + "hmac 0.13.0", + "md-5 0.11.0", + "memchr", + "rand 0.10.2", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "chrono", + "fallible-iterator", + "postgres-protocol", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -5489,7 +5709,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -5713,6 +5944,17 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strum" version = "0.27.2" @@ -6180,6 +6422,42 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.2", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -6530,6 +6808,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-properties" version = "0.1.4" @@ -6693,6 +6980,12 @@ dependencies = [ "sval_serde", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -6750,6 +7043,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -6759,6 +7061,15 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -7093,6 +7404,19 @@ dependencies = [ "winsafe", ] +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index 4b7944f..b7d8121 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/chm-core", "crates/chm-cloud-api", "crates/chm-clickhouse", + "crates/chm-postgres", "crates/chm-update", "crates/chm-telemetry", "app", @@ -19,6 +20,7 @@ license = "MIT" chm-core = { path = "crates/chm-core" } chm-cloud-api = { path = "crates/chm-cloud-api" } chm-clickhouse = { path = "crates/chm-clickhouse" } +chm-postgres = { path = "crates/chm-postgres" } chm-update = { path = "crates/chm-update" } chm-telemetry = { path = "crates/chm-telemetry" } diff --git a/README.md b/README.md index 8c172d3..b236aa8 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ monitoring for macOS and Linux, with two connection modes: self-hosted chmonitor worker (`chm-cloud-api`). 2. **Direct ClickHouse** — speaks to your ClickHouse instance over HTTP (`chm-clickhouse`), SQL ported from the web dashboard. +3. **Postgres** — read-only `pg_stat_*` monitoring (`chm-postgres`), same + host switcher as ClickHouse. Merges/Traffic pages hide on a PG host. ## Build @@ -56,6 +58,7 @@ keys `1`–`8` switch sidebar destinations; `cmd-b` toggles the sidebar; | `crates/chm-core` | domain types + `DataSource` trait + mock data | | `crates/chm-cloud-api` | mode 1: dashboard REST client | | `crates/chm-clickhouse` | mode 2: direct ClickHouse HTTP client | +| `crates/chm-postgres` | mode 3: direct Postgres (`pg_stat_*`) | | `crates/chm-update` | channel-aware update checker (stable/beta) | | `crates/chm-telemetry` | opt-in telemetry + perf metrics | | `app/` | GPUI + bezel UI | diff --git a/app/Cargo.toml b/app/Cargo.toml index 3df67e1..5df9c04 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true chm-core.workspace = true chm-cloud-api.workspace = true chm-clickhouse.workspace = true +chm-postgres.workspace = true chm-update.workspace = true chm-telemetry.workspace = true bezel.workspace = true diff --git a/app/src/config.rs b/app/src/config.rs index 9dbe571..c889aef 100644 --- a/app/src/config.rs +++ b/app/src/config.rs @@ -10,6 +10,7 @@ use std::sync::OnceLock; use chm_clickhouse::ClickHouseClient; use chm_cloud_api::CloudClient; use chm_core::DataSource; +use chm_postgres::PostgresClient; /// Saved connection profile (`[profile]` table, or `[profiles.]`). #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] @@ -31,6 +32,12 @@ pub struct ProfileConfig { /// Direct mode: password. #[serde(default)] pub password: Option, + /// Postgres: database name (default `postgres`). + #[serde(default)] + pub database: Option, + /// Postgres: libpq sslmode (`disable` / `prefer` / `require`). + #[serde(default)] + pub sslmode: Option, /// Release channel for the update check: "stable" | "beta". #[serde(default)] pub channel: Option, @@ -150,6 +157,7 @@ pub fn host_display(p: &ProfileConfig) -> String { Some("clickhouse") => { host_from_url(p.url.as_deref()).unwrap_or_else(|| "clickhouse".into()) } + Some("postgres") => host_from_url(p.url.as_deref()).unwrap_or_else(|| "postgres".into()), _ => "host".into(), } } @@ -261,6 +269,15 @@ pub fn source_from_profile(p: &ProfileConfig) -> Option> { p.user.clone().unwrap_or_else(|| "default".into()), p.password.clone(), ))), + "postgres" => PostgresClient::new( + p.url.clone()?, + p.user.clone(), + p.password.clone(), + p.database.clone(), + p.sslmode.clone(), + ) + .ok() + .map(|c| Box::new(c) as Box), _ => None, } } @@ -440,6 +457,19 @@ user = "alice" assert_eq!(src.label(), "clickhouse: http://ch:8123"); } + #[test] + fn postgres_source_from_profile() { + let src = source_from_profile(&ProfileConfig { + mode: Some("postgres".into()), + url: Some("postgres://localhost:5432/app".into()), + user: Some("alice".into()), + ..Default::default() + }) + .unwrap(); + assert!(src.label().starts_with("postgres:")); + assert_eq!(src.engine(), chm_core::SourceEngine::Postgres); + } + #[test] fn save_roundtrip_keeps_named_profiles_and_telemetry() { let mut cfg = ConfigFile::default(); diff --git a/app/src/connect.rs b/app/src/connect.rs index 10ca7e4..50263dc 100644 --- a/app/src/connect.rs +++ b/app/src/connect.rs @@ -32,7 +32,8 @@ pub enum ConnectEvent { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Mode { Cloud, - Direct, + ClickHouse, + Postgres, } /// Outcome of the last Test press. @@ -52,6 +53,7 @@ pub struct ConnectFlow { url: Entity, user: Entity, password: Entity, + database: Entity, name: Entity, test: TestState, } @@ -78,17 +80,31 @@ impl ConnectFlow { }; let initial = initial.unwrap_or_default(); let mode = match initial.mode.as_deref() { - Some("clickhouse") => Mode::Direct, + Some("clickhouse") => Mode::ClickHouse, + Some("postgres") => Mode::Postgres, _ => Mode::Cloud, }; + let default_user = match mode { + Mode::Postgres => "postgres", + _ => "default", + }; Self { focus: cx.focus_handle(), mode, base_url: field(initial.base_url, "https://acme.dash.chmonitor.dev", cx), api_key: field(initial.api_key, "API key", cx), - url: field(initial.url, "http://localhost:8123", cx), - user: field(initial.user.or(Some("default".into())), "user", cx), + url: field( + initial.url, + if matches!(mode, Mode::Postgres) { + "postgres://localhost:5432/postgres" + } else { + "http://localhost:8123" + }, + cx, + ), + user: field(initial.user.or(Some(default_user.into())), "user", cx), password: field(initial.password, "password", cx), + database: field(initial.database.or(Some("postgres".into())), "database", cx), name: field(None, "work (optional)", cx), test: TestState::Idle, } @@ -114,7 +130,7 @@ impl ConnectFlow { ..ProfileConfig::default() }) } - Mode::Direct => { + Mode::ClickHouse => { let url = nonempty(Self::read(&self.url, cx))?; Some(ProfileConfig { mode: Some("clickhouse".into()), @@ -124,6 +140,17 @@ impl ConnectFlow { ..ProfileConfig::default() }) } + Mode::Postgres => { + let url = nonempty(Self::read(&self.url, cx))?; + Some(ProfileConfig { + mode: Some("postgres".into()), + url: Some(url), + user: nonempty(Self::read(&self.user, cx)), + password: nonempty(Self::read(&self.password, cx)), + database: nonempty(Self::read(&self.database, cx)), + ..ProfileConfig::default() + }) + } } } @@ -279,26 +306,28 @@ impl Render for ConnectFlow { cx: &mut Context, ) -> impl bezel::gpui::IntoElement { let theme = Theme::of(cx).clone(); - let (cloud_fields, direct_fields) = match self.mode { - Mode::Cloud => ( - div() - .flex() - .flex_col() - .gap(px(10.0)) - .child(Self::field_row("Base URL", &self.base_url)) - .child(Self::field_row("API key", &self.api_key)), - div(), - ), - Mode::Direct => ( - div(), - div() - .flex() - .flex_col() - .gap(px(10.0)) - .child(Self::field_row("URL", &self.url)) - .child(Self::field_row("User", &self.user)) - .child(Self::field_row("Password", &self.password)), - ), + let fields = match self.mode { + Mode::Cloud => div() + .flex() + .flex_col() + .gap(px(10.0)) + .child(Self::field_row("Base URL", &self.base_url)) + .child(Self::field_row("API key", &self.api_key)), + Mode::ClickHouse => div() + .flex() + .flex_col() + .gap(px(10.0)) + .child(Self::field_row("URL", &self.url)) + .child(Self::field_row("User", &self.user)) + .child(Self::field_row("Password", &self.password)), + Mode::Postgres => div() + .flex() + .flex_col() + .gap(px(10.0)) + .child(Self::field_row("URL", &self.url)) + .child(Self::field_row("User", &self.user)) + .child(Self::field_row("Password", &self.password)) + .child(Self::field_row("Database", &self.database)), }; div() @@ -311,12 +340,12 @@ impl Render for ConnectFlow { .flex() .flex_col() .gap(px(2.0)) - .child(div().text_size(px(16.0)).child("Connect to ClickHouse")) + .child(div().text_size(px(16.0)).child("Add a host")) .child( div() .text_size(px(12.0)) .text_color(theme.text_muted) - .child("Use the chmonitor cloud API or talk to your server directly."), + .child("ClickHouse, Postgres, or the chmonitor cloud API."), ), ) .child( @@ -333,14 +362,20 @@ impl Render for ConnectFlow { )) .child(self.mode_row( &theme, - "Direct", - "ClickHouse HTTP endpoint · url + user + password", - Mode::Direct, + "ClickHouse", + "HTTP endpoint · url + user + password", + Mode::ClickHouse, + cx, + )) + .child(self.mode_row( + &theme, + "Postgres", + "libpq endpoint · url + user + password + database", + Mode::Postgres, cx, )), ) - .child(cloud_fields) - .child(direct_fields) + .child(fields) .child(Self::field_row("Name", &self.name)) .child(self.status_line(cx)) .child( diff --git a/app/src/pages/mod.rs b/app/src/pages/mod.rs index 945ca86..cf9b88c 100644 --- a/app/src/pages/mod.rs +++ b/app/src/pages/mod.rs @@ -62,6 +62,14 @@ impl Page { matches!(self, Page::Overview | Page::Queries | Page::Traffic) } + /// ClickHouse-only pages are hidden on a Postgres host. + pub fn available(self, engine: chm_core::SourceEngine) -> bool { + match engine { + chm_core::SourceEngine::Postgres => !matches!(self, Page::Merges | Page::Traffic), + _ => true, + } + } + /// Sidebar glyph. Text markers until Agent E's icon widget lands; the /// sidebar renders whatever this returns, so swapping in real icons is a /// one-file change. @@ -124,5 +132,8 @@ mod tests { assert!(!Page::Connect.uses_range()); assert_eq!(Page::Settings.title(), "Settings"); assert!(!Page::ALL.contains(&Page::Settings)); + assert!(!Page::Merges.available(chm_core::SourceEngine::Postgres)); + assert!(Page::Queries.available(chm_core::SourceEngine::Postgres)); + assert!(Page::Merges.available(chm_core::SourceEngine::ClickHouse)); } } diff --git a/app/src/shell.rs b/app/src/shell.rs index b3399c1..f6a10be 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -9,8 +9,8 @@ use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; use chm_core::{ - DataSource, Health, MergeRow, MockDataSource, Overview, QueryRow, ReplicaRow, TableStat, - TimeRange, TrafficSeries, + DataSource, Health, MergeRow, MockDataSource, Overview, QueryRow, ReplicaRow, SourceEngine, + TableStat, TimeRange, TrafficSeries, }; use bezel::gpui::{ @@ -291,13 +291,22 @@ impl Shell { cfg.ui.host = Some(host_id.clone()); let _ = save_config(&cfg); self.apply_host(host_id, profile); - if matches!(self.page, Page::Connect | Page::Settings) { + if matches!(self.page, Page::Connect | Page::Settings) + || !self.page.available(self.source_engine()) + { self.page = Page::Overview; } self.refresh_now(cx); cx.notify(); } + fn source_engine(&self) -> SourceEngine { + self.source + .as_ref() + .map(|s| s.engine()) + .unwrap_or(SourceEngine::ClickHouse) + } + fn hosts(&self) -> Vec { if std::env::var("CHM_SMOKE").is_ok() { return vec![Host { @@ -570,7 +579,13 @@ impl Shell { for host in self.hosts() { let id = host.id.clone(); let selected = active.as_deref() == Some(id.as_str()); - let row_label = host.label.clone(); + let tag = match host.profile.mode.as_deref() { + Some("postgres") => " pg", + Some("clickhouse") => " ch", + Some("cloud") => " cloud", + _ => "", + }; + let row_label = format!("{}{tag}", host.label); col = col.child( div() .id(SharedString::from(format!("host-{id}"))) @@ -623,11 +638,15 @@ impl Shell { } fn sidebar(&self, theme: &Theme, compact: bool, cx: &mut Context) -> bezel::gpui::Div { + let engine = self.source_engine(); let items: Vec = Page::ALL .iter() - .map(|&page| { + .copied() + .filter(|page| page.available(engine)) + .enumerate() + .map(|(i, page)| { let active = page == self.page; - let hotkey = format!("{}", page.index() + 1); + let hotkey = format!("{}", i + 1); let label = if compact { div().child(page.icon()) } else { @@ -863,9 +882,16 @@ impl Render for Shell { .parse::() .ok() .and_then(|n| n.checked_sub(1)) - && let Some(&page) = Page::ALL.get(idx) { - this.goto(page, cx); + let engine = this.source_engine(); + let page = Page::ALL + .iter() + .copied() + .filter(|p| p.available(engine)) + .nth(idx); + if let Some(page) = page { + this.goto(page, cx); + } } })) .flex() diff --git a/crates/chm-cloud-api/src/lib.rs b/crates/chm-cloud-api/src/lib.rs index dfc5304..aa4213c 100644 --- a/crates/chm-cloud-api/src/lib.rs +++ b/crates/chm-cloud-api/src/lib.rs @@ -401,6 +401,10 @@ impl DataSource for CloudClient { format!("cloud: {}", self.base_url) } + fn engine(&self) -> chm_core::SourceEngine { + chm_core::SourceEngine::Cloud + } + /// GET /api/healthz (the one known-good route); any 2xx = reachable+authed. async fn ping(&self) -> Result<()> { let resp = self.request("healthz").await?; diff --git a/crates/chm-core/src/lib.rs b/crates/chm-core/src/lib.rs index 15e3f80..c01fd80 100644 --- a/crates/chm-core/src/lib.rs +++ b/crates/chm-core/src/lib.rs @@ -41,6 +41,26 @@ pub enum DataSourceError { pub type Result = std::result::Result; +/// What kind of database a host speaks. Orthogonal to *where* credentials live. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SourceEngine { + ClickHouse, + Cloud, + Postgres, + Mock, +} + +impl SourceEngine { + pub fn tag(self) -> &'static str { + match self { + Self::ClickHouse => "ch", + Self::Cloud => "cloud", + Self::Postgres => "pg", + Self::Mock => "mock", + } + } +} + /// Dashboard time ranges (matches web UI: 1h/6h/24h/7d/30d). #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum TimeRange { @@ -199,6 +219,12 @@ pub trait DataSource: Send + Sync { /// Human-readable label for status bar, e.g. "cloud: acme.dash.chmonitor.dev". fn label(&self) -> String; + /// Engine for this source. Defaults to ClickHouse so existing clients + /// stay fail-closed. + fn engine(&self) -> SourceEngine { + SourceEngine::ClickHouse + } + /// Cheap connectivity/auth probe used by Connect screen and reconnects. async fn ping(&self) -> Result<()>; @@ -233,6 +259,10 @@ impl DataSource for MockDataSource { self.label.clone() } + fn engine(&self) -> SourceEngine { + SourceEngine::Mock + } + async fn ping(&self) -> Result<()> { Ok(()) } diff --git a/crates/chm-postgres/Cargo.toml b/crates/chm-postgres/Cargo.toml new file mode 100644 index 0000000..3202044 --- /dev/null +++ b/crates/chm-postgres/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "chm-postgres" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +async-trait.workspace = true +chm-core.workspace = true +chrono.workspace = true +native-tls = "0.2" +postgres-native-tls = "0.5" +tokio.workspace = true +tokio-postgres = { version = "0.7", features = ["with-chrono-0_4"] } +tracing.workspace = true + +[dev-dependencies] +tokio.workspace = true diff --git a/crates/chm-postgres/src/lib.rs b/crates/chm-postgres/src/lib.rs new file mode 100644 index 0000000..63dfc84 --- /dev/null +++ b/crates/chm-postgres/src/lib.rs @@ -0,0 +1,466 @@ +//! Direct Postgres client for monitored sources (`pg_stat_activity` / +//! `pg_stat_statements` / `pg_stat_user_tables`). Read-only: every session +//! pins `default_transaction_read_only=on`. SQL follows chmonitor's +//! postgres-source collectors. + +use async_trait::async_trait; +use chm_core::{ + DataSource, DataSourceError, Health, MergeRow, Overview, QueryRow, ReplicaRow, Result, + SourceEngine, TableStat, TimeRange, TrafficSeries, +}; +use chrono::{TimeZone, Utc}; +use tokio_postgres::{Client, Config, NoTls, Row}; + +/// Direct Postgres source; implements [`chm_core::DataSource`]. +#[derive(Debug, Clone)] +pub struct PostgresClient { + config: Config, + sslmode: SslMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SslMode { + Disable, + Prefer, + Require, +} + +impl PostgresClient { + pub fn new( + url: impl AsRef, + user: Option, + password: Option, + database: Option, + sslmode: Option, + ) -> Result { + let (mut config, inferred_ssl) = parse_endpoint(url.as_ref())?; + if let Some(u) = user.filter(|u| !u.is_empty()) { + config.user(&u); + } + if let Some(p) = password.filter(|p| !p.is_empty()) { + config.password(p); + } + if let Some(d) = database.filter(|d| !d.is_empty()) { + config.dbname(&d); + } + if config.get_user().is_none() { + config.user("postgres"); + } + if config.get_dbname().is_none() { + config.dbname("postgres"); + } + let sslmode = sslmode + .as_deref() + .map(parse_sslmode) + .or(inferred_ssl) + .unwrap_or(SslMode::Prefer); + Ok(Self { config, sslmode }) + } + + async fn with_client(&self, f: F) -> Result + where + F: FnOnce(Client) -> Fut, + Fut: std::future::Future>, + { + match self.sslmode { + SslMode::Disable | SslMode::Prefer => self.connect_plain(f).await, + SslMode::Require => self.connect_tls(f).await, + } + } + + async fn connect_plain(&self, f: F) -> Result + where + F: FnOnce(Client) -> Fut, + Fut: std::future::Future>, + { + let (client, conn) = self.config.connect(NoTls).await.map_err(map_pg_err)?; + let handle = tokio::spawn(async move { + let _ = conn.await; + }); + pin_read_only(&client).await?; + let out = f(client).await; + handle.abort(); + out + } + + async fn connect_tls(&self, f: F) -> Result + where + F: FnOnce(Client) -> Fut, + Fut: std::future::Future>, + { + let connector = native_tls::TlsConnector::builder().build().map_err(|e| { + DataSourceError::Connection { + message: format!("tls connector: {e}"), + } + })?; + let connector = postgres_native_tls::MakeTlsConnector::new(connector); + let (client, conn) = self.config.connect(connector).await.map_err(map_pg_err)?; + let handle = tokio::spawn(async move { + let _ = conn.await; + }); + pin_read_only(&client).await?; + let out = f(client).await; + handle.abort(); + out + } +} + +async fn pin_read_only(client: &Client) -> Result<()> { + client + .batch_execute("SET default_transaction_read_only = on") + .await + .map_err(map_pg_err) +} + +fn map_pg_err(e: tokio_postgres::Error) -> DataSourceError { + let message = e.to_string(); + let code = e.code().map(|c| c.code()); + match code { + Some("28P01" | "28000") => DataSourceError::Auth { message }, + Some("3D000") => DataSourceError::Query { message }, + _ if e.is_closed() => DataSourceError::Connection { message }, + _ => DataSourceError::Query { message }, + } +} + +fn parse_sslmode(s: &str) -> SslMode { + match s.to_ascii_lowercase().as_str() { + "disable" | "allow" => SslMode::Disable, + "require" | "verify-ca" | "verify-full" => SslMode::Require, + _ => SslMode::Prefer, + } +} + +/// `postgres://…` URLs, or `host:port` / `host`. +fn parse_endpoint(raw: &str) -> Result<(Config, Option)> { + let raw = raw.trim(); + if raw.is_empty() { + return Err(DataSourceError::Connection { + message: "empty postgres url".into(), + }); + } + if raw.contains("://") { + let ssl = raw + .split(['?', '&']) + .find_map(|p| p.strip_prefix("sslmode=")) + .map(parse_sslmode); + let cfg: Config = + raw.parse() + .map_err(|e: tokio_postgres::Error| DataSourceError::Connection { + message: e.to_string(), + })?; + return Ok((cfg, ssl)); + } + let mut cfg = Config::new(); + match raw.rsplit_once(':') { + Some((host, port)) if port.parse::().is_ok() => { + cfg.host(host); + cfg.port(port.parse().unwrap()); + } + _ => { + cfg.host(raw); + cfg.port(5432); + } + } + Ok((cfg, None)) +} + +#[async_trait] +impl DataSource for PostgresClient { + fn label(&self) -> String { + use tokio_postgres::config::Host; + let host = match self.config.get_hosts().first() { + Some(Host::Tcp(h)) => h.clone(), + _ => "postgres".into(), + }; + format!("postgres: {host}") + } + + fn engine(&self) -> SourceEngine { + SourceEngine::Postgres + } + + async fn ping(&self) -> Result<()> { + self.with_client(|client| async move { + client + .query_one("SELECT 1", &[]) + .await + .map_err(map_pg_err)?; + Ok(()) + }) + .await + } + + async fn overview(&self, _range: TimeRange) -> Result { + self.with_client(|client| async move { + let row = client + .query_one( + r#" +SELECT + (SELECT count(*)::bigint FROM pg_stat_activity + WHERE state = 'active' AND pid <> pg_backend_pid()) AS running_queries, + (SELECT count(*)::bigint FROM pg_stat_user_tables) AS tables_total, + (SELECT coalesce(sum(pg_database_size(oid)), 0)::bigint FROM pg_database) AS disk_used_bytes, + extract(epoch FROM (now() - pg_postmaster_start_time()))::bigint AS uptime_seconds, + version() AS server_version, + (SELECT count(*)::bigint FROM pg_stat_replication) AS replicas_total, + (SELECT count(*)::bigint FROM pg_stat_replication WHERE state = 'streaming') AS replicas_ok, + coalesce(( + SELECT sum(xact_commit + xact_rollback) + / greatest(extract(epoch FROM (now() - stats_reset)), 1) + FROM pg_stat_database + ), 0)::float8 AS qps +"#, + &[], + ) + .await + .map_err(map_pg_err)?; + let running = get_i64(&row, "running_queries").max(0) as u64; + let tables = get_i64(&row, "tables_total").max(0) as u64; + let disk = get_i64(&row, "disk_used_bytes").max(0) as u64; + let uptime = get_i64(&row, "uptime_seconds").max(0) as u64; + let version: String = row.try_get("server_version").unwrap_or_default(); + let replicas_total = get_i64(&row, "replicas_total").max(0) as u64; + let replicas_ok = get_i64(&row, "replicas_ok").max(0) as u64; + let qps: f64 = row.try_get("qps").unwrap_or(0.0); + Ok(Overview { + qps, + running_queries: running, + tables_total: tables, + disk_used_bytes: disk, + disk_total_bytes: disk, + uptime_seconds: uptime, + clickhouse_version: version, + replicas_total, + replicas_ok, + ..Overview::default() + }) + }) + .await + } + + async fn traffic(&self, _range: TimeRange) -> Result { + Ok(TrafficSeries::default()) + } + + async fn running_queries(&self) -> Result> { + self.with_client(|client| async move { + let rows = client + .query( + r#" +SELECT pid::text AS id, + coalesce(usename, '') AS user_name, + coalesce(extract(epoch FROM (now() - query_start)), 0) * 1000 AS elapsed_ms, + left(coalesce(query, ''), 240) AS sql, + query_start +FROM pg_stat_activity +WHERE state = 'active' AND pid <> pg_backend_pid() +ORDER BY query_start NULLS LAST +LIMIT 100 +"#, + &[], + ) + .await + .map_err(map_pg_err)?; + Ok(rows.iter().map(activity_row).collect()) + }) + .await + } + + async fn slow_queries(&self, _range: TimeRange) -> Result> { + self.with_client(|client| async move { + let rows = match client + .query( + r#" +SELECT queryid::text AS id, + '' AS user_name, + mean_exec_time AS elapsed_ms, + left(query, 240) AS sql +FROM pg_stat_statements +ORDER BY mean_exec_time DESC +LIMIT 100 +"#, + &[], + ) + .await + { + Ok(rows) => rows, + Err(_) => return Ok(Vec::new()), + }; + Ok(rows + .iter() + .map(|r| QueryRow { + id: r.try_get("id").unwrap_or_default(), + user: r.try_get("user_name").unwrap_or_default(), + elapsed_ms: r.try_get("elapsed_ms").unwrap_or(0.0), + normalized_sql: r.try_get("sql").unwrap_or_default(), + ..QueryRow::default() + }) + .collect()) + }) + .await + } + + async fn failed_queries(&self, _range: TimeRange) -> Result> { + Ok(Vec::new()) + } + + async fn merges(&self) -> Result> { + Ok(Vec::new()) + } + + async fn replicas(&self) -> Result> { + self.with_client(|client| async move { + let rows = client + .query( + r#" +SELECT coalesce(nullif(application_name, ''), client_addr::text, pid::text) AS replica_name, + coalesce(state, '') AS state, + coalesce(extract(epoch FROM replay_lag), 0)::float8 AS delay +FROM pg_stat_replication +"#, + &[], + ) + .await + .map_err(map_pg_err)?; + Ok(rows + .iter() + .map(|r| { + let state: String = r.try_get("state").unwrap_or_default(); + ReplicaRow { + replica_name: r.try_get("replica_name").unwrap_or_default(), + absolute_delay_sec: r.try_get("delay").unwrap_or(0.0), + is_readonly: state != "streaming", + ..ReplicaRow::default() + } + }) + .collect()) + }) + .await + } + + async fn health(&self) -> Result { + self.with_client(|client| async move { + let row = client + .query_one( + r#" +SELECT + (SELECT count(*)::bigint FROM pg_stat_activity) AS conns, + (SELECT setting::bigint FROM pg_settings WHERE name = 'max_connections') AS max_conns, + pg_is_in_recovery() AS in_recovery, + (SELECT count(*)::bigint FROM pg_stat_activity WHERE state = 'idle in transaction') AS idle_txn +"#, + &[], + ) + .await + .map_err(map_pg_err)?; + let conns = get_i64(&row, "conns").max(0) as f32; + let max = get_i64(&row, "max_conns").max(1) as f32; + let in_recovery: bool = row.try_get("in_recovery").unwrap_or(false); + let idle: u64 = get_i64(&row, "idle_txn").max(0) as u64; + let util = (conns / max).clamp(0.0, 1.0); + Ok(Health { + ok: !in_recovery && util < 0.9, + zookeeper_available: true, + delayed_inserts: idle, + background_pool_utilization: util, + ..Health::default() + }) + }) + .await + } + + async fn tables(&self) -> Result> { + self.with_client(|client| async move { + let rows = client + .query( + r#" +SELECT schemaname AS database, + relname AS name, + 'heap' AS engine, + n_live_tup::bigint AS rows, + pg_total_relation_size(relid)::bigint AS bytes_on_disk +FROM pg_stat_user_tables +ORDER BY pg_total_relation_size(relid) DESC +LIMIT 200 +"#, + &[], + ) + .await + .map_err(map_pg_err)?; + Ok(rows + .iter() + .map(|r| TableStat { + database: r.try_get("database").unwrap_or_default(), + name: r.try_get("name").unwrap_or_default(), + engine: r.try_get("engine").unwrap_or_default(), + rows: get_i64(r, "rows").max(0) as u64, + bytes_on_disk: get_i64(r, "bytes_on_disk").max(0) as u64, + ..TableStat::default() + }) + .collect()) + }) + .await + } +} + +fn activity_row(r: &Row) -> QueryRow { + let started_at = r + .try_get::<_, Option>>("query_start") + .ok() + .flatten() + .or_else(|| { + r.try_get::<_, Option>("query_start") + .ok() + .flatten() + .map(|n| Utc.from_utc_datetime(&n)) + }); + QueryRow { + id: r.try_get("id").unwrap_or_default(), + user: r.try_get("user_name").unwrap_or_default(), + elapsed_ms: r.try_get("elapsed_ms").unwrap_or(0.0), + normalized_sql: r.try_get("sql").unwrap_or_default(), + started_at, + ..QueryRow::default() + } +} + +fn get_i64(row: &Row, col: &str) -> i64 { + row.try_get::<_, i64>(col) + .or_else(|_| row.try_get::<_, i32>(col).map(|v| v as i64)) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_host_port_and_url() { + let (cfg, ssl) = parse_endpoint("localhost:5432").unwrap(); + assert!(ssl.is_none()); + assert_eq!(cfg.get_ports(), [5432]); + + let (cfg, ssl) = parse_endpoint("postgres://alice@db.example:5433/app").unwrap(); + assert_eq!(cfg.get_user(), Some("alice")); + assert_eq!(cfg.get_dbname(), Some("app")); + assert_eq!(cfg.get_ports(), [5433]); + assert!(ssl.is_none()); + + let (_, ssl) = parse_endpoint("postgres://h/db?sslmode=disable").unwrap(); + assert_eq!(ssl, Some(SslMode::Disable)); + } + + #[test] + fn parse_empty_fails() { + assert!(parse_endpoint("").is_err()); + assert!(parse_endpoint(" ").is_err()); + } + + #[test] + fn sslmode_mapping() { + assert_eq!(parse_sslmode("disable"), SslMode::Disable); + assert_eq!(parse_sslmode("require"), SslMode::Require); + assert_eq!(parse_sslmode("prefer"), SslMode::Prefer); + } +} From 14641b3cc9a560eda21bf40bc510c1f8384f0b19 Mon Sep 17 00:00:00 2001 From: duyetbot Date: Mon, 24 Aug 2026 19:03:13 +0700 Subject: [PATCH 09/20] feat(ui): switch desktop shell to gpui-component Replace the bezel/crabtalk GPUI stack with Longbridge gpui-component on official Zed GPUI. Sidebar, status bar, charts, tables, inputs, and settings now use the widget kit; Metal still compiles at runtime. --- Cargo.lock | 1536 +++++++++++++++++++++++++++++-------- Cargo.toml | 9 +- README.md | 4 +- app/Cargo.toml | 3 +- app/src/connect.rs | 246 +++--- app/src/lib.rs | 6 +- app/src/main.rs | 114 ++- app/src/pages/health.rs | 27 +- app/src/pages/merges.rs | 14 +- app/src/pages/mod.rs | 48 +- app/src/pages/overview.rs | 56 +- app/src/pages/queries.rs | 39 +- app/src/pages/replicas.rs | 14 +- app/src/pages/settings.rs | 343 ++++----- app/src/pages/tables.rs | 14 +- app/src/pages/traffic.rs | 34 +- app/src/shell.rs | 533 ++++--------- app/src/widgets/cards.rs | 44 +- app/src/widgets/chart.rs | 407 +++------- app/src/widgets/mod.rs | 3 +- app/src/widgets/table.rs | 153 ++-- 21 files changed, 1968 insertions(+), 1679 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5254a94..28ffd1e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ "accesskit", "accesskit_consumer 0.36.0", "atspi-common", - "phf", + "phf 0.13.1", "serde", "zvariant", ] @@ -129,16 +129,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "agent" -version = "0.0.2" -source = "git+https://github.com/crabtalk/bezel?rev=f86dbbfc84569cc69f3cba6ebb3d99d858dcb259#f86dbbfc84569cc69f3cba6ebb3d99d858dcb259" -dependencies = [ - "gpui", - "theme", - "web-time", -] - [[package]] name = "ahash" version = "0.8.12" @@ -147,6 +137,7 @@ checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "const-random", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -194,6 +185,23 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.104" @@ -215,6 +223,15 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -226,6 +243,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayref" version = "0.3.9" @@ -269,10 +292,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fb8421aaa9644a5faf26735f258b669b15f063313ef8f8e2bdb28912a1a6f111" dependencies = [ "enumflags2", + "futures-channel", "futures-util", "getrandom 0.4.3", "serde", "serde_repr", + "wayland-backend", + "wayland-client", + "wayland-protocols", "zbus", ] @@ -550,29 +577,6 @@ dependencies = [ "arrayvec", ] -[[package]] -name = "aws-lc-rs" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" -dependencies = [ - "aws-lc-sys", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - [[package]] name = "backtrace" version = "0.3.76" @@ -588,6 +592,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "base62" +version = "2.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd637ac531c60eb7fbc4684dc061c2d7d90d73d758181aa02eeff0464b9eee4b" + [[package]] name = "base64" version = "0.22.1" @@ -595,15 +605,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "bezel" -version = "0.0.2" -source = "git+https://github.com/crabtalk/bezel?rev=f86dbbfc84569cc69f3cba6ebb3d99d858dcb259#f86dbbfc84569cc69f3cba6ebb3d99d858dcb259" +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" dependencies = [ - "agent", - "gpui", - "motion", - "theme", - "ui", + "serde", ] [[package]] @@ -626,15 +633,30 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", +] + [[package]] name = "bit-set" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" dependencies = [ - "bit-vec", + "bit-vec 0.9.1", ] +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit-vec" version = "0.9.1" @@ -742,6 +764,16 @@ dependencies = [ "cfg_aliases", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + [[package]] name = "built" version = "0.8.1" @@ -814,6 +846,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "calloop-wayland-source" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" +dependencies = [ + "calloop", + "rustix 1.1.4", + "wayland-backend", + "wayland-client", +] + [[package]] name = "cbc" version = "0.1.2" @@ -853,12 +897,6 @@ dependencies = [ "shlex 2.0.1", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cexpr" version = "0.6.0" @@ -905,7 +943,6 @@ name = "chm-app" version = "0.1.1" dependencies = [ "anyhow", - "bezel", "chm-clickhouse", "chm-cloud-api", "chm-core", @@ -915,6 +952,8 @@ dependencies = [ "chrono", "dirs", "gpui", + "gpui-component", + "gpui-component-assets", "gpui_platform", "semver", "serde", @@ -1046,15 +1085,6 @@ dependencies = [ "libloading", ] -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - [[package]] name = "cmov" version = "0.5.4" @@ -1135,7 +1165,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "gpui_util", "indexmap", @@ -1148,16 +1178,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" -[[package]] -name = "combine" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" -dependencies = [ - "bytes", - "memchr", -] - [[package]] name = "compression-codecs" version = "0.4.38" @@ -1416,6 +1436,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -1550,7 +1579,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "proc-macro2", "quote", @@ -1648,6 +1677,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dunce" version = "1.0.5" @@ -1701,12 +1736,41 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + [[package]] name = "endi" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum-iterator" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -1848,6 +1912,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "fancy-regex" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -1883,6 +1958,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -1923,6 +2008,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "flume" version = "0.12.0" @@ -2057,10 +2151,23 @@ dependencies = [ ] [[package]] -name = "fs_extra" -version = "1.3.0" +name = "fsevent-sys" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] [[package]] name = "futures" @@ -2237,6 +2344,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gif" version = "0.14.2" @@ -2270,6 +2387,30 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "glow" version = "0.17.0" @@ -2328,7 +2469,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "accesskit", "anyhow", @@ -2336,28 +2477,20 @@ dependencies = [ "async-task", "bindgen", "bitflags 2.13.1", - "block", - "cbindgen", "chrono", - "cocoa 0.26.0", - "cocoa-foundation 0.2.0", "collections", - "core-foundation 0.10.1", - "core-foundation-sys", - "core-graphics 0.24.0", - "core-text", "core-video", "ctor", "derive_more", "embed-resource", "etagere", - "foreign-types 0.5.0", "futures", "futures-concurrency", "getrandom 0.3.4", "gpui_macros", "gpui_shared_string", "gpui_util", + "hdrhistogram", "heapless", "http_client", "image", @@ -2365,14 +2498,9 @@ dependencies = [ "itertools 0.14.0", "log", "lyon", - "mach2", - "media", - "metal", "num_cpus", - "objc", "parking", "parking_lot", - "pathfinder_geometry", "pin-project", "pollster 0.4.0", "postage", @@ -2381,7 +2509,7 @@ dependencies = [ "raw-window-handle", "refineable", "regex", - "resvg", + "resvg 0.46.0", "scheduler", "schemars", "seahash", @@ -2398,7 +2526,7 @@ dependencies = [ "tracing", "ttf-parser", "url", - "usvg", + "usvg 0.46.0", "util_macros", "uuid", "waker-fn", @@ -2409,10 +2537,111 @@ dependencies = [ "ztracing", ] +[[package]] +name = "gpui-base" +version = "0.5.2" +source = "git+https://github.com/longbridge/gpui-component#c27f5d5c8f70d534978c2f0739ad9e10d4e41eb4" +dependencies = [ + "aho-corasick", + "anyhow", + "async-channel", + "chrono", + "gpui", + "gpui_macros", + "gpui_platform", + "instant", + "lsp-types", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "raw-window-handle", + "regex", + "ropey", + "schemars", + "serde", + "serde_json", + "smallvec", + "smol", + "syntect", + "tracing", + "unicode-segmentation", + "web-time", + "zed-sum-tree", +] + +[[package]] +name = "gpui-component" +version = "0.5.2" +source = "git+https://github.com/longbridge/gpui-component#c27f5d5c8f70d534978c2f0739ad9e10d4e41eb4" +dependencies = [ + "anyhow", + "chrono", + "core-text", + "enum-iterator", + "futures", + "gpui", + "gpui-base", + "gpui-component-assets", + "gpui-component-macros", + "gpui_macros", + "html5ever", + "instant", + "itertools 0.13.0", + "log", + "lsp-types", + "markdown", + "markup5ever_rcdom", + "notify", + "num-traits", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "once_cell", + "paste", + "raw-window-handle", + "resvg 0.45.1", + "ropey", + "rust-i18n", + "schemars", + "serde", + "serde_json", + "serde_repr", + "smallvec", + "smol", + "tracing", + "uuid", + "windows 0.58.0", + "zed-sum-tree", +] + +[[package]] +name = "gpui-component-assets" +version = "0.5.1" +source = "git+https://github.com/longbridge/gpui-component#c27f5d5c8f70d534978c2f0739ad9e10d4e41eb4" +dependencies = [ + "anyhow", + "gpui", + "log", + "rust-embed", + "wasm-bindgen", + "wasm-bindgen-futures", + "zed-reqwest", +] + +[[package]] +name = "gpui-component-macros" +version = "0.5.1" +source = "git+https://github.com/longbridge/gpui-component#c27f5d5c8f70d534978c2f0739ad9e10d4e41eb4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "gpui_apple" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "anyhow", "block", @@ -2435,15 +2664,17 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "accesskit", "accesskit_unix", "anyhow", "as-raw-xcb-connection", "ashpd", + "bitflags 2.13.1", "bytemuck", "calloop", + "calloop-wayland-source", "collections", "filedescriptor", "futures", @@ -2451,24 +2682,24 @@ dependencies = [ "gpui_util", "gpui_wgpu", "http_client", - "image", - "itertools 0.14.0", "libc", "log", "notify-rust", "oo7", "open", "parking_lot", - "pathfinder_geometry", - "pollster 0.4.0", - "profiling", "raw-window-handle", "smallvec", "smol", "strum", - "swash", "url", "uuid", + "wayland-backend", + "wayland-client", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-plasma", + "wayland-protocols-wlr", "x11-clipboard", "x11rb", "xkbcommon", @@ -2479,7 +2710,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "accesskit", "accesskit_macos", @@ -2525,7 +2756,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2536,7 +2767,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "console_error_panic_hook", "gpui", @@ -2544,13 +2775,12 @@ dependencies = [ "gpui_macos", "gpui_web", "gpui_windows", - "reqwest_client", ] [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "schemars", "serde", @@ -2560,7 +2790,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "anyhow", "log", @@ -2570,7 +2800,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "anyhow", "console_error_panic_hook", @@ -2582,6 +2812,7 @@ dependencies = [ "log", "parking_lot", "raw-window-handle", + "scheduler", "uuid", "wasm-bindgen", "wasm-bindgen-futures", @@ -2593,7 +2824,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "anyhow", "bytemuck", @@ -2603,18 +2834,14 @@ dependencies = [ "gpui", "gpui_util", "itertools 0.14.0", - "js-sys", "log", "parking_lot", - "pollster 0.4.0", "profiling", "raw-window-handle", "smallvec", "swash", "unicode-bidi", "unicode-segmentation", - "wasm-bindgen", - "wasm-bindgen-futures", "web-sys", "wgpu", "zed-font-kit", @@ -2623,7 +2850,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "accesskit", "accesskit_windows", @@ -2648,6 +2875,16 @@ dependencies = [ "windows-registry 0.5.3", ] +[[package]] +name = "granit-parser" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d03f81ad4732830d85cfd417a9f62cde6dadda4354d37d078a6084a19560aa2d" +dependencies = [ + "arraydeque", + "smallvec", +] + [[package]] name = "h2" version = "0.4.18" @@ -2733,6 +2970,20 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hdrhistogram" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49d1053f4708f0af3cf9fc5bffc7e68a914a3c45becb231c80068c9c3f78bea" +dependencies = [ + "base64", + "byteorder", + "crossbeam-channel", + "flate2", + "nom 8.0.0", + "num-traits", +] + [[package]] name = "heapless" version = "0.9.3" @@ -2809,6 +3060,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "http" version = "1.5.0" @@ -2845,7 +3110,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "anyhow", "async-compression", @@ -2862,15 +3127,6 @@ dependencies = [ "url", ] -[[package]] -name = "http_client_tls" -version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" -dependencies = [ - "rustls", - "rustls-platform-verifier", -] - [[package]] name = "httparse" version = "1.10.1" @@ -3082,6 +3338,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.10" @@ -3092,7 +3364,7 @@ dependencies = [ "byteorder-lite", "color_quant", "exr", - "gif", + "gif 0.14.2", "image-webp", "moxcms", "num-traits", @@ -3101,8 +3373,8 @@ dependencies = [ "ravif", "rayon", "tiff", - "zune-core", - "zune-jpeg", + "zune-core 0.5.3", + "zune-jpeg 0.5.15", ] [[package]] @@ -3115,6 +3387,12 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "imagesize" version = "0.14.0" @@ -3139,6 +3417,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -3149,6 +3447,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -3206,6 +3516,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -3230,22 +3549,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - [[package]] name = "jni-sys" version = "0.3.1" @@ -3312,6 +3615,37 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.1", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -3474,6 +3808,19 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "lyon" version = "1.0.19" @@ -3526,6 +3873,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + [[package]] name = "mac-notification-sys" version = "0.6.15" @@ -3558,6 +3911,42 @@ dependencies = [ "libc", ] +[[package]] +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "serde", + "unicode-id", +] + +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] + [[package]] name = "matchers" version = "0.2.0" @@ -3600,13 +3989,12 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "anyhow", "bindgen", "core-foundation 0.10.1", "core-video", - "ctor", "foreign-types 0.5.0", "metal", "objc", @@ -3690,19 +4078,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] -[[package]] -name = "motion" -version = "0.0.2" -source = "git+https://github.com/crabtalk/bezel?rev=f86dbbfc84569cc69f3cba6ebb3d99d858dcb259#f86dbbfc84569cc69f3cba6ebb3d99d858dcb259" -dependencies = [ - "gpui", - "web-time", -] - [[package]] name = "moxcms" version = "0.8.1" @@ -3720,7 +4100,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", - "bit-set", + "bit-set 0.9.1", "bitflags 2.13.1", "cfg-if", "cfg_aliases", @@ -3780,6 +4160,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "7.1.3" @@ -3799,12 +4185,40 @@ dependencies = [ "memchr", ] -[[package]] -name = "noop_proc_macro" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" - +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "normpath" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9985ef7269fa99f3b12437bb698381da2428743ab90f20393f399fa14cab21a" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.13.1", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + [[package]] name = "notify-rust" version = "4.18.0" @@ -3819,6 +4233,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -4011,8 +4434,8 @@ dependencies = [ "block2 0.5.1", "libc", "objc2 0.5.2", - "objc2-core-data", - "objc2-core-image", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", "objc2-foundation 0.2.2", "objc2-quartz-core 0.2.2", ] @@ -4024,8 +4447,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.1", + "block2 0.6.2", + "libc", "objc2 0.6.4", + "objc2-cloud-kit", + "objc2-core-data 0.3.2", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4041,6 +4484,17 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -4052,6 +4506,19 @@ dependencies = [ "objc2 0.6.4", ] +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-io-surface", +] + [[package]] name = "objc2-core-image" version = "0.2.2" @@ -4064,6 +4531,16 @@ dependencies = [ "objc2-metal 0.2.2", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-location" version = "0.3.2" @@ -4074,6 +4551,31 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", +] + [[package]] name = "objc2-encode" version = "4.1.0" @@ -4105,6 +4607,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", +] + [[package]] name = "objc2-metal" version = "0.2.2" @@ -4411,13 +4924,22 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "collections", "serde", "serde_json", ] +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + [[package]] name = "phf" version = "0.13.1" @@ -4425,10 +4947,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.7", +] + [[package]] name = "phf_generator" version = "0.13.1" @@ -4436,7 +4978,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", ] [[package]] @@ -4445,13 +4987,22 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "phf_shared" version = "0.13.1" @@ -4669,6 +5220,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "presser" version = "0.3.1" @@ -5127,7 +5684,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "derive_refineable", ] @@ -5206,21 +5763,20 @@ dependencies = [ ] [[package]] -name = "reqwest_client" -version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" dependencies = [ - "anyhow", - "bytes", - "futures", - "gpui_util", - "http_client", - "http_client_tls", + "gif 0.13.3", + "image-webp", "log", - "regex", - "serde", - "tokio", - "zed-reqwest", + "pico-args", + "rgb", + "svgtypes 0.15.3", + "tiny-skia", + "usvg 0.45.1", + "zune-jpeg 0.4.21", ] [[package]] @@ -5229,15 +5785,15 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" dependencies = [ - "gif", + "gif 0.14.2", "image-webp", "log", "pico-args", "rgb", - "svgtypes", + "svgtypes 0.16.1", "tiny-skia", - "usvg", - "zune-jpeg", + "usvg 0.46.0", + "zune-jpeg 0.5.15", ] [[package]] @@ -5263,6 +5819,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ropey" +version = "2.0.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4045a00dc327d084a2bbf126976e14125b54f23bd30511d45b842eba76c52d74" +dependencies = [ + "str_indices", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -5278,6 +5843,90 @@ dependencies = [ "memchr", ] +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "shellexpand", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "globset", + "sha2 0.11.0", + "walkdir", +] + +[[package]] +name = "rust-i18n" +version = "4.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f10cee36dd3b1f7929ea12b759de9eea9eff83bfccbc71f387ef4d41a57c64a4" +dependencies = [ + "globwalk", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "4.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0bb1ed4e04fe26c2a2652cad1c6595efaf7196f4445c0d6e13c67154347fb7e" +dependencies = [ + "glob", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "syn 2.0.119", +] + +[[package]] +name = "rust-i18n-support" +version = "4.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1c083408a2733180ae0acf2897612f8ceec7b0c0dcd065a0a87103bfb3b1d9" +dependencies = [ + "arc-swap", + "base62", + "globwalk", + "itertools 0.11.0", + "normpath", + "serde", + "serde-saphyr", + "serde_json", + "siphasher", + "toml 0.8.23", + "triomphe", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -5337,8 +5986,6 @@ version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ - "aws-lc-rs", - "log", "once_cell", "ring", "rustls-pki-types", @@ -5378,40 +6025,12 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-platform-verifier" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" -dependencies = [ - "core-foundation 0.10.1", - "core-foundation-sys", - "jni", - "log", - "once_cell", - "rustls", - "rustls-native-certs", - "rustls-platform-verifier-android", - "rustls-webpki", - "security-framework", - "security-framework-sys", - "webpki-root-certs 0.26.11", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustls-platform-verifier-android" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" - [[package]] name = "rustls-webpki" version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ - "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -5468,7 +6087,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "async-task", "backtrace", @@ -5477,6 +6096,7 @@ dependencies = [ "futures", "parking_lot", "rand 0.9.5", + "wasm_thread", "web-time", ] @@ -5506,6 +6126,12 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + [[package]] name = "scopeguard" version = "1.2.0" @@ -5590,6 +6216,25 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-saphyr" +version = "0.0.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" +dependencies = [ + "ahash", + "annotate-snippets", + "base64", + "encoding_rs_io", + "getrandom 0.3.4", + "granit-parser", + "nohash-hasher", + "num-traits", + "serde_core", + "smallvec", + "zmij", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -5732,6 +6377,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + [[package]] name = "shlex" version = "1.3.0" @@ -5935,6 +6589,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + [[package]] name = "strict-num" version = "0.1.1" @@ -5944,6 +6604,31 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -5985,7 +6670,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "heapless", "log", @@ -6079,13 +6764,23 @@ version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + [[package]] name = "svgtypes" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ - "kurbo", + "kurbo 0.13.1", "siphasher", ] @@ -6142,6 +6837,24 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "syntect" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925" +dependencies = [ + "bincode", + "fancy-regex", + "flate2", + "fnv", + "once_cell", + "regex-syntax", + "serde", + "serde_derive", + "thiserror 2.0.20", + "walkdir", +] + [[package]] name = "sys-locale" version = "0.3.2" @@ -6235,23 +6948,23 @@ dependencies = [ ] [[package]] -name = "termcolor" -version = "1.4.1" +name = "tendril" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" dependencies = [ - "winapi-util", + "futf", + "mac", + "utf-8", ] [[package]] -name = "theme" -version = "0.0.2" -source = "git+https://github.com/crabtalk/bezel?rev=f86dbbfc84569cc69f3cba6ebb3d99d858dcb259#f86dbbfc84569cc69f3cba6ebb3d99d858dcb259" +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" dependencies = [ - "gpui", - "objc", - "serde", - "tracing", + "winapi-util", ] [[package]] @@ -6314,7 +7027,7 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -6447,7 +7160,7 @@ dependencies = [ "log", "parking_lot", "percent-encoding", - "phf", + "phf 0.13.1", "pin-project-lite", "postgres-protocol", "postgres-types", @@ -6722,6 +7435,17 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6760,18 +7484,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "ui" -version = "0.0.2" -source = "git+https://github.com/crabtalk/bezel?rev=f86dbbfc84569cc69f3cba6ebb3d99d858dcb259#f86dbbfc84569cc69f3cba6ebb3d99d858dcb259" -dependencies = [ - "gpui", - "motion", - "theme", - "unicode-segmentation", - "web-time", -] - [[package]] name = "unicase" version = "2.9.0" @@ -6796,6 +7508,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-id" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ba288e709927c043cbe476718d37be306be53fb1fafecd0dbe36d072be2580" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -6871,6 +7589,33 @@ dependencies = [ "serde", ] +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize 0.13.0", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree 0.20.0", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes 0.15.3", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "usvg" version = "0.46.0" @@ -6881,8 +7626,8 @@ dependencies = [ "data-url", "flate2", "fontdb", - "imagesize", - "kurbo", + "imagesize 0.14.0", + "kurbo 0.13.1", "log", "pico-args", "roxmltree 0.21.1", @@ -6890,7 +7635,7 @@ dependencies = [ "simplecss", "siphasher", "strict-num", - "svgtypes", + "svgtypes 0.16.1", "tiny-skia-path", "unicode-bidi", "unicode-script", @@ -6898,6 +7643,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -6907,7 +7658,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "perf", "quote", @@ -7149,6 +7900,92 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + [[package]] name = "wayland-sys" version = "0.31.11" @@ -7181,24 +8018,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-root-certs" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" -dependencies = [ - "webpki-root-certs 1.0.9", -] - -[[package]] -name = "webpki-root-certs" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webpki-roots" version = "1.0.9" @@ -7251,8 +8070,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", - "bit-set", - "bit-vec", + "bit-set 0.9.1", + "bit-vec 0.9.1", "bitflags 2.13.1", "bytemuck", "cfg_aliases", @@ -7323,7 +8142,7 @@ dependencies = [ "android_system_properties", "arrayvec", "ash", - "bit-set", + "bit-set 0.9.1", "bitflags 2.13.1", "block2 0.6.2", "bytemuck", @@ -7458,6 +8277,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -7526,6 +8355,19 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -7585,6 +8427,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -7607,6 +8460,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -7681,6 +8545,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -7699,6 +8572,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.3.1" @@ -7726,15 +8609,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -7762,21 +8636,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -7837,12 +8696,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -7855,12 +8708,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -7873,12 +8720,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -7903,12 +8744,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -7921,12 +8756,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -7939,12 +8768,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -7957,12 +8780,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -8047,6 +8864,12 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "workspace-hack" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "beffa227304dbaea3ad6a06ac674f9bc83a3dec3b7f63eeb442de37e7cb6bb01" + [[package]] name = "writeable" version = "0.6.4" @@ -8135,6 +8958,7 @@ checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" dependencies = [ "as-raw-xcb-connection", "libc", + "memmap2", "xkeysym", ] @@ -8150,6 +8974,17 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "xml5ever" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" +dependencies = [ + "log", + "mac", + "markup5ever", +] + [[package]] name = "xmlwriter" version = "0.1.0" @@ -8402,6 +9237,18 @@ dependencies = [ "xcb", ] +[[package]] +name = "zed-sum-tree" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d490156d0d7311855564d6e1d6dccab992405a0c0e15e1c8ef18920c02177e35" +dependencies = [ + "arrayvec", + "log", + "rayon", + "workspace-hack", +] + [[package]] name = "zed-xim" version = "0.4.0-zed" @@ -8518,7 +9365,7 @@ dependencies = [ [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "anyhow", "chrono", @@ -8535,7 +9382,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" dependencies = [ "tracing", "tracing-subscriber", @@ -8546,7 +9393,13 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/crabtalk/zed?rev=cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423#cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423" +source = "git+https://github.com/zed-industries/zed#99b0ed6b55017fe110557af7408afff9a6cd9e71" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" [[package]] name = "zune-core" @@ -8563,13 +9416,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core", + "zune-core 0.5.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b7d8121..a1b62d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,13 +24,14 @@ chm-postgres = { path = "crates/chm-postgres" } chm-update = { path = "crates/chm-update" } chm-telemetry = { path = "crates/chm-telemetry" } -# bezel stack — pinned to exact revs; all gpui types flow through bezel::gpui. -bezel = { git = "https://github.com/crabtalk/bezel", rev = "f86dbbfc84569cc69f3cba6ebb3d99d858dcb259" } -gpui = { git = "https://github.com/crabtalk/zed", rev = "cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423", version = "0.2.2" } +# gpui-component stack — official Zed GPUI + Longbridge widgets. # runtime_shaders compiles Metal shaders at launch so `cargo build` works on a # Mac that only has Command Line Tools (no Xcode `metal` compiler). Prefer the # precompiled path when Xcode is installed — drop this feature then. -gpui_platform = { git = "https://github.com/crabtalk/zed", rev = "cf1b90ec8cfda974a50b0c9eaebfdab28d7f0423", features = ["font-kit", "x11", "runtime_shaders"] } +gpui = { git = "https://github.com/zed-industries/zed" } +gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "runtime_shaders"] } +gpui-component = { git = "https://github.com/longbridge/gpui-component" } +gpui-component-assets = { git = "https://github.com/longbridge/gpui-component" } # crates-io anyhow = "1" diff --git a/README.md b/README.md index b236aa8..f89fe2b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # chmonitor Desktop -GPUI + bezel desktop client for [chmonitor](https://chmonitor.dev) — ClickHouse +GPUI + [gpui-component](https://longbridge.github.io/gpui-component/) desktop client for [chmonitor](https://chmonitor.dev) — ClickHouse monitoring for macOS and Linux, with two connection modes: 1. **Cloud / dashboard endpoint** — talks to `dash.chmonitor.dev` or any @@ -61,7 +61,7 @@ keys `1`–`8` switch sidebar destinations; `cmd-b` toggles the sidebar; | `crates/chm-postgres` | mode 3: direct Postgres (`pg_stat_*`) | | `crates/chm-update` | channel-aware update checker (stable/beta) | | `crates/chm-telemetry` | opt-in telemetry + perf metrics | -| `app/` | GPUI + bezel UI | +| `app/` | GPUI + gpui-component UI | | `.github/workflows/` | CI: lint, test, build matrix, releases | ## Testing diff --git a/app/Cargo.toml b/app/Cargo.toml index 5df9c04..23ff524 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -11,9 +11,10 @@ chm-clickhouse.workspace = true chm-postgres.workspace = true chm-update.workspace = true chm-telemetry.workspace = true -bezel.workspace = true gpui.workspace = true gpui_platform.workspace = true +gpui-component.workspace = true +gpui-component-assets.workspace = true anyhow.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/app/src/connect.rs b/app/src/connect.rs index 50263dc..06fe7cd 100644 --- a/app/src/connect.rs +++ b/app/src/connect.rs @@ -1,19 +1,16 @@ -//! Connect screen — two-mode connection form (Cloud API vs direct -//! ClickHouse), Test (ping) and Save (writes config.toml). -//! AGENT D OWNS THIS FILE. -//! -//! Flow: pick mode → fill the relevant fields → Test runs `DataSource::ping` -//! → Save persists `[profile]` to `/chmonitor/config.toml` and -//! emits [`ConnectEvent::SavedProfile`], which shell.rs turns into a live -//! data source. +//! Connect screen — Cloud API vs ClickHouse vs Postgres, Test (ping) and Save. -use bezel::gpui::{ +use gpui::{ AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, - div, prelude::*, px, + Window, div, prelude::*, px, +}; +use gpui_component::{ + ActiveTheme as _, + button::{Button, ButtonVariants as _}, + h_flex, + input::{Input, InputState}, + v_flex, }; -use bezel::theme::Theme; -use bezel::ui::input::TextField; -use bezel::ui::widgets::{ButtonStyle, Buttons}; use crate::config::{ DEFAULT_HOST_ID, ProfileConfig, host_id_from_name, load_config, save_config, @@ -48,32 +45,43 @@ enum TestState { pub struct ConnectFlow { focus: FocusHandle, mode: Mode, - base_url: Entity, - api_key: Entity, - url: Entity, - user: Entity, - password: Entity, - database: Entity, - name: Entity, + base_url: Entity, + api_key: Entity, + url: Entity, + user: Entity, + password: Entity, + database: Entity, + name: Entity, test: TestState, } impl EventEmitter for ConnectFlow {} impl Focusable for ConnectFlow { - fn focus_handle(&self, _: &bezel::gpui::App) -> FocusHandle { + fn focus_handle(&self, _: &gpui::App) -> FocusHandle { self.focus.clone() } } impl ConnectFlow { /// `initial` prefills the form from a saved profile, if any. - pub fn new(initial: Option, cx: &mut Context) -> Self { - let field = |text: Option, placeholder: &'static str, cx: &mut Context| { + pub fn new( + initial: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let field = |text: Option, + placeholder: &'static str, + masked: bool, + window: &mut Window, + cx: &mut Context| { cx.new(|cx| { - let mut f = TextField::new(cx).with_placeholder(placeholder); + let mut f = InputState::new(window, cx).placeholder(placeholder); if let Some(t) = text.filter(|t| !t.is_empty()) { - f.set_content(t, cx); + f = f.default_value(t); + } + if masked { + f = f.masked(true); } f }) @@ -91,8 +99,14 @@ impl ConnectFlow { Self { focus: cx.focus_handle(), mode, - base_url: field(initial.base_url, "https://acme.dash.chmonitor.dev", cx), - api_key: field(initial.api_key, "API key", cx), + base_url: field( + initial.base_url, + "https://acme.dash.chmonitor.dev", + false, + window, + cx, + ), + api_key: field(initial.api_key, "API key", true, window, cx), url: field( initial.url, if matches!(mode, Mode::Postgres) { @@ -100,18 +114,32 @@ impl ConnectFlow { } else { "http://localhost:8123" }, + false, + window, + cx, + ), + user: field( + initial.user.or(Some(default_user.into())), + "user", + false, + window, cx, ), - user: field(initial.user.or(Some(default_user.into())), "user", cx), - password: field(initial.password, "password", cx), - database: field(initial.database.or(Some("postgres".into())), "database", cx), - name: field(None, "work (optional)", cx), + password: field(initial.password, "password", true, window, cx), + database: field( + initial.database.or(Some("postgres".into())), + "database", + false, + window, + cx, + ), + name: field(None, "work (optional)", false, window, cx), test: TestState::Idle, } } - fn read(e: &Entity, cx: &Context) -> String { - e.read(cx).content().trim().to_string() + fn read(e: &Entity, cx: &Context) -> String { + e.read(cx).value().trim().to_string() } /// Collect the form into a profile. Returns None when fields required by @@ -207,39 +235,35 @@ impl ConnectFlow { } } - // -- rendering ---------------------------------------------------------- - fn mode_row( &self, - theme: &Theme, label: &'static str, hint: &'static str, mode: Mode, cx: &mut Context, - ) -> bezel::gpui::Stateful { + ) -> impl IntoElement { let selected = self.mode == mode; div() .id(SharedString::from(format!("mode-{label}"))) .flex() .flex_row() .items_center() - .gap(px(10.0)) - .px(px(12.0)) - .py(px(10.0)) - .rounded(px(8.0)) + .gap(px(10.)) + .px(px(12.)) + .py(px(10.)) + .rounded(cx.theme().radius) .border_1() .border_color(if selected { - theme.border_strong + cx.theme().primary } else { - theme.border + cx.theme().border }) .bg(if selected { - theme.element_active + cx.theme().accent } else { - theme.input_bg + cx.theme().background }) .cursor_pointer() - .hover(|s| s.bg(theme.element_hover)) .on_click(cx.listener(move |this, _, _, cx| { this.mode = mode; this.test = TestState::Idle; @@ -247,128 +271,94 @@ impl ConnectFlow { })) .child( div() - .size(px(14.0)) + .size(px(14.)) .rounded_full() .border_1() - .border_color(theme.border_strong) - .when(selected, |dot| dot.bg(theme.accent)), + .border_color(cx.theme().primary) + .when(selected, |dot| dot.bg(cx.theme().primary)), ) .child( - div() - .flex() - .flex_col() - .gap(px(2.0)) - .child(div().text_size(px(13.0)).child(label)) - .child( - div() - .text_size(px(11.0)) - .text_color(theme.text_muted) - .child(hint), - ), + v_flex().gap_1().child(div().text_sm().child(label)).child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(hint), + ), ) } - fn field_row(label: &'static str, field: &Entity) -> bezel::gpui::Div { - div() - .flex() - .flex_col() - .gap(px(4.0)) - .child( - div() - .text_size(px(11.5)) - .text_color(bezel::theme::ink(0.55)) - .child(label), - ) - .child(field.clone()) + fn field_row(label: &'static str, field: &Entity) -> impl IntoElement { + v_flex() + .gap_1() + .child(div().text_sm().child(label)) + .child(Input::new(field)) } - fn status_line(&self, cx: &Context) -> bezel::gpui::AnyElement { - let theme = Theme::of(cx).clone(); - let (text, color): (&str, bezel::gpui::Hsla) = match &self.test { - TestState::Idle => ("", theme.text_faint), - TestState::Testing => ("testing…", theme.warning), - TestState::Ok => ("connection ok", theme.success), - TestState::Failed(e) => (e.as_str(), theme.danger), + fn status_line(&self, cx: &Context) -> impl IntoElement { + let (text, color) = match &self.test { + TestState::Idle => ("", cx.theme().muted_foreground), + TestState::Testing => ("testing…", cx.theme().warning), + TestState::Ok => ("connection ok", cx.theme().green), + TestState::Failed(e) => (e.as_str(), cx.theme().danger), }; div() - .min_h(px(18.0)) - .text_size(px(12.0)) + .min_h(px(18.)) + .text_sm() .text_color(color) .child(SharedString::from(text.to_string())) - .into_any_element() } } impl Render for ConnectFlow { - fn render( - &mut self, - _window: &mut bezel::gpui::Window, - cx: &mut Context, - ) -> impl bezel::gpui::IntoElement { - let theme = Theme::of(cx).clone(); + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let fields = match self.mode { - Mode::Cloud => div() - .flex() - .flex_col() - .gap(px(10.0)) + Mode::Cloud => v_flex() + .gap_3() .child(Self::field_row("Base URL", &self.base_url)) .child(Self::field_row("API key", &self.api_key)), - Mode::ClickHouse => div() - .flex() - .flex_col() - .gap(px(10.0)) + Mode::ClickHouse => v_flex() + .gap_3() .child(Self::field_row("URL", &self.url)) .child(Self::field_row("User", &self.user)) .child(Self::field_row("Password", &self.password)), - Mode::Postgres => div() - .flex() - .flex_col() - .gap(px(10.0)) + Mode::Postgres => v_flex() + .gap_3() .child(Self::field_row("URL", &self.url)) .child(Self::field_row("User", &self.user)) .child(Self::field_row("Password", &self.password)) .child(Self::field_row("Database", &self.database)), }; - div() - .flex() - .flex_col() - .gap(px(14.0)) - .max_w(px(460.0)) + v_flex() + .gap_4() + .max_w(px(460.)) .child( - div() - .flex() - .flex_col() - .gap(px(2.0)) - .child(div().text_size(px(16.0)).child("Add a host")) + v_flex() + .gap_1() + .child(div().text_lg().child("Add a host")) .child( div() - .text_size(px(12.0)) - .text_color(theme.text_muted) + .text_sm() + .text_color(cx.theme().muted_foreground) .child("ClickHouse, Postgres, or the chmonitor cloud API."), ), ) .child( - div() - .flex() - .flex_col() - .gap(px(8.0)) + v_flex() + .gap_2() .child(self.mode_row( - &theme, "Cloud", "chmonitor-hosted dashboard API · base URL + API key", Mode::Cloud, cx, )) .child(self.mode_row( - &theme, "ClickHouse", "HTTP endpoint · url + user + password", Mode::ClickHouse, cx, )) .child(self.mode_row( - &theme, "Postgres", "libpq endpoint · url + user + password + database", Mode::Postgres, @@ -379,20 +369,18 @@ impl Render for ConnectFlow { .child(Self::field_row("Name", &self.name)) .child(self.status_line(cx)) .child( - div() - .flex() - .flex_row() - .gap(px(8.0)) + h_flex() + .gap_2() .child( - theme - .button("Test", ButtonStyle::Ghost, None) - .id("test") + Button::new("test") + .ghost() + .label("Test") .on_click(cx.listener(|this, _, _, cx| this.run_test(cx))), ) .child( - theme - .button("Save", ButtonStyle::Prominent, None) - .id("save") + Button::new("save") + .primary() + .label("Save") .on_click(cx.listener(|this, _, _, cx| this.save(cx))), ), ) diff --git a/app/src/lib.rs b/app/src/lib.rs index 005e3b9..be4e020 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -1,8 +1,4 @@ -//! chm-app — GPUI + bezel desktop client for chmonitor. -//! -//! AGENT D OWNS shell.rs / main.rs / connect.rs / pages/ wiring. -//! AGENT E OWNS widgets/ (chart, table, metric card). -//! AGENT F/G/H fill pages/. Keep all gpui types via `bezel::gpui`. +//! chm-app — GPUI + gpui-component desktop client for chmonitor. pub mod config; pub mod connect; diff --git a/app/src/main.rs b/app/src/main.rs index f989051..0f8e3bd 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -1,21 +1,13 @@ -//! Entry point — bezel bootstrap (mirrors bezel `apps/hello` exactly). -//! -//! Bootstrap quirks worth remembering: -//! * This gpui fork has no `Application::new()`; the platform facade -//! `gpui_platform::application()` is the only entry. -//! * A gpui app gets no menu bar for free (no nib), so without a Quit -//! menu item `cmd-q` does nothing. -//! * Fonts must be registered before the first window paints. +//! Entry point — gpui-component bootstrap. -use bezel::gpui::{ +use chm_app::config::{Cli, CliError, load_config}; +use chm_app::pages::settings::{appearance_from_cfg, apply_appearance}; +use chm_app::shell::{OpenSettings, Refresh, Shell, ToggleSidebar}; +use gpui::{ App, AppContext as _, Bounds, Focusable as _, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions, WindowBounds, WindowOptions, actions, px, size, }; -use bezel::theme; -use bezel::ui; -use chm_app::config::{Cli, CliError, load_config}; -use chm_app::pages::settings::appearance_from_cfg; -use chm_app::shell::{OpenSettings, Refresh, Shell, ToggleSidebar}; +use gpui_component::Root; actions!(chm_app, [Quit]); @@ -43,56 +35,50 @@ fn main() { println!("shell ready"); } - gpui_platform::application().run(|cx: &mut App| { - if let Err(err) = ui::register_fonts(cx) { - eprintln!("FONT REGISTRATION FAILED: {err:?}"); - } - let appearance = appearance_from_cfg(load_config().ui.appearance.as_deref()); - theme::appearance::init(appearance, cx); - // TextField keybindings are opt-in and scoped to the field's key context. - ui::input::init(cx); - // Bind before set_menus so the menu bar can show the keystrokes. - cx.bind_keys([ - KeyBinding::new("cmd-,", OpenSettings, None), - KeyBinding::new("cmd-b", ToggleSidebar, None), - KeyBinding::new("cmd-r", Refresh, None), - ]); - // Without a menu item cmd-q does nothing — no nib ships with a gpui app. - cx.on_action(|_: &Quit, cx: &mut App| cx.quit()); - cx.set_menus(vec![ - Menu::new("chmonitor").items([ - MenuItem::action("Settings…", OpenSettings), - MenuItem::separator(), - MenuItem::action("Quit", Quit), - ]), - Menu::new("View").items([ - MenuItem::action("Toggle Sidebar", ToggleSidebar), - MenuItem::action("Refresh", Refresh), - ]), - ]); + gpui_platform::application() + .with_assets(gpui_component_assets::Assets) + .run(|cx: &mut App| { + gpui_component::init(cx); + cx.bind_keys([ + KeyBinding::new("cmd-,", OpenSettings, None), + KeyBinding::new("cmd-b", ToggleSidebar, None), + KeyBinding::new("cmd-r", Refresh, None), + KeyBinding::new("cmd-q", Quit, None), + ]); + // Without a menu item cmd-q does nothing — no nib ships with a gpui app. + cx.on_action(|_: &Quit, cx: &mut App| cx.quit()); + cx.set_menus(vec![ + Menu::new("chmonitor").items(vec![ + MenuItem::action("Settings…", OpenSettings), + MenuItem::separator(), + MenuItem::action("Quit", Quit), + ]), + Menu::new("View").items(vec![ + MenuItem::action("Toggle Sidebar", ToggleSidebar), + MenuItem::action("Refresh", Refresh), + ]), + ]); - let bounds = Bounds::centered(None, size(px(1280.0), px(800.0)), cx); - cx.open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - titlebar: Some(TitlebarOptions { - title: Some(SharedString::from("chmonitor")), + let appearance = appearance_from_cfg(load_config().ui.appearance.as_deref()); + let bounds = Bounds::centered(None, size(px(1280.0), px(800.0)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + titlebar: Some(TitlebarOptions { + title: Some(SharedString::from("chmonitor")), + ..Default::default() + }), ..Default::default() - }), - ..Default::default() - }, - |window, cx| { - // Follow system light/dark for this window (bezel README step). - theme::appearance::observe_window(window, cx).detach(); - let shell = cx.new(Shell::new); - // Root takes focus so 1-8 / r keys land in the shell's subtree - // from the first frame (gallery's pattern). - let focus = shell.read(cx).focus_handle(cx); - window.focus(&focus, cx); - shell - }, - ) - .unwrap(); // unwrap allowed here: same bootstrap shape as bezel examples - cx.activate(true); - }); + }, + move |window, cx| { + apply_appearance(appearance, window, cx); + let shell = cx.new(|cx| Shell::new(window, cx)); + let focus = shell.read(cx).focus_handle(cx); + window.focus(&focus, cx); + cx.new(|cx| Root::new(shell, window, cx)) + }, + ) + .unwrap(); + cx.activate(true); + }); } diff --git a/app/src/pages/health.rs b/app/src/pages/health.rs index cb089f3..cf4d481 100644 --- a/app/src/pages/health.rs +++ b/app/src/pages/health.rs @@ -2,7 +2,7 @@ use chm_core::Health; -use bezel::gpui::{Context, Render, div, prelude::*, px}; +use gpui::{Context, Render, Window, div, prelude::*, px}; use crate::pages::status; use crate::widgets::geometry::format_duration_ms; @@ -40,16 +40,12 @@ impl HealthPage { } impl Render for HealthPage { - fn render( - &mut self, - _window: &mut bezel::gpui::Window, - _cx: &mut Context, - ) -> impl bezel::gpui::IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { if let Some(err) = &self.error { - return status(format!("health unavailable: {err}")); + return status(format!("health unavailable: {err}"), cx).into_any_element(); } let Some(h) = &self.data else { - return status("loading health…"); + return status("loading health…", cx).into_any_element(); }; let pool_pct = format!( "{:.0}%", @@ -58,27 +54,30 @@ impl Render for HealthPage { div() .flex() .flex_col() - .gap(px(10.0)) + .gap(px(10.)) .w_full() .child( div() .flex() .flex_row() - .gap(px(10.0)) + .gap(px(10.)) .child(metric_card( "status", if h.ok { "ok" } else { "not ok" }, None, + cx, )) .child(metric_card( "readonly tables", &h.readonly_tables.to_string(), None, + cx, )) .child(metric_card( "replication lag", &format_duration_ms(h.replication_lag_max_sec * 1000.0), None, + cx, )) .child(metric_card( "zookeeper", @@ -88,24 +87,28 @@ impl Render for HealthPage { "unavailable" }, None, + cx, )), ) .child( div() .flex() .flex_row() - .gap(px(10.0)) + .gap(px(10.)) .child(metric_card( "delayed inserts", &h.delayed_inserts.to_string(), None, + cx, )) .child(metric_card( "distributed files", &h.distributed_files_to_insert.to_string(), None, + cx, )) - .child(metric_card("background pool", &pool_pct, None)), + .child(metric_card("background pool", &pool_pct, None, cx)), ) + .into_any_element() } } diff --git a/app/src/pages/merges.rs b/app/src/pages/merges.rs index 2e650fa..9f8d30e 100644 --- a/app/src/pages/merges.rs +++ b/app/src/pages/merges.rs @@ -2,7 +2,7 @@ use chm_core::MergeRow; -use bezel::gpui::{Context, Render, prelude::*}; +use gpui::{Context, Render, Window, prelude::*}; use crate::pages::status; use crate::widgets::{CellVal, Column, data_table}; @@ -39,19 +39,15 @@ impl MergesPage { } impl Render for MergesPage { - fn render( - &mut self, - _window: &mut bezel::gpui::Window, - _cx: &mut Context, - ) -> impl bezel::gpui::IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { if let Some(err) = &self.error { - return status(format!("merges unavailable: {err}")).into_any_element(); + return status(format!("merges unavailable: {err}"), cx).into_any_element(); } let Some(rows) = &self.data else { - return status("loading merges…").into_any_element(); + return status("loading merges…", cx).into_any_element(); }; if rows.is_empty() { - return status("no merges or mutations in flight").into_any_element(); + return status("no merges or mutations in flight", cx).into_any_element(); } let columns = vec![ Column { diff --git a/app/src/pages/mod.rs b/app/src/pages/mod.rs index cf9b88c..6898f92 100644 --- a/app/src/pages/mod.rs +++ b/app/src/pages/mod.rs @@ -1,7 +1,7 @@ -//! Page routing. AGENT D owns mod.rs; Agents F/G/H own the individual page -//! files and will replace the placeholder bodies in shell.rs's `content`. +//! Page routing. -use bezel::gpui::{AnyElement, FontWeight, SharedString, div, prelude::*, px}; +use gpui::{App, FontWeight, SharedString, div, prelude::*}; +use gpui_component::{ActiveTheme as _, IconName}; pub mod health; pub mod merges; @@ -70,43 +70,35 @@ impl Page { } } - /// Sidebar glyph. Text markers until Agent E's icon widget lands; the - /// sidebar renders whatever this returns, so swapping in real icons is a - /// one-file change. - pub fn icon(self) -> AnyElement { - let glyph: &str = match self { - Page::Overview => "◧", - Page::Queries => "⌕", - Page::Merges => "⇄", - Page::Replicas => "⑃", - Page::Health => "♥", - Page::Tables => "▤", - Page::Traffic => "↕", - Page::Connect => "⌁", - Page::Settings => "⚙", - }; - div() - .w(px(16.0)) - .text_size(px(13.0)) - .child(SharedString::from(glyph.to_string())) - .into_any_element() + pub fn icon(self) -> IconName { + match self { + Page::Overview => IconName::LayoutDashboard, + Page::Queries => IconName::Search, + Page::Merges => IconName::Replace, + Page::Replicas => IconName::Copy, + Page::Health => IconName::Heart, + Page::Tables => IconName::File, + Page::Traffic => IconName::ChartPie, + Page::Connect => IconName::Globe, + Page::Settings => IconName::Settings, + } } } -pub(crate) fn status(text: impl Into) -> bezel::gpui::Div { +pub(crate) fn status(text: impl Into, cx: &App) -> gpui::Div { div() .flex() .flex_1() .items_center() .justify_center() - .text_color(bezel::theme::ink(0.45)) - .text_size(px(13.0)) + .text_color(cx.theme().muted_foreground) + .text_sm() .child(text.into()) } -pub(crate) fn heading(title: &str) -> bezel::gpui::Div { +pub(crate) fn heading(title: &str) -> gpui::Div { div() - .text_size(px(13.0)) + .text_sm() .font_weight(FontWeight::SEMIBOLD) .child(SharedString::from(title.to_string())) } diff --git a/app/src/pages/overview.rs b/app/src/pages/overview.rs index 3187246..6788d30 100644 --- a/app/src/pages/overview.rs +++ b/app/src/pages/overview.rs @@ -3,7 +3,7 @@ use chm_core::{Overview, TrafficSeries}; -use bezel::gpui::{Context, Render, div, prelude::*, px}; +use gpui::{Context, Render, Window, div, prelude::*, px}; use crate::pages::status; use crate::widgets::geometry::{format_bytes, format_count}; @@ -51,17 +51,12 @@ impl OverviewPage { } impl Render for OverviewPage { - fn render( - &mut self, - _window: &mut bezel::gpui::Window, - cx: &mut Context, - ) -> impl bezel::gpui::IntoElement { - let _ = cx; + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { if let Some(err) = &self.error { - return status(format!("overview unavailable: {err}")); + return status(format!("overview unavailable: {err}"), cx).into_any_element(); } let Some(o) = &self.data else { - return status("loading overview…"); + return status("loading overview…", cx).into_any_element(); }; let used_pct = 100.0 * o.disk_used_bytes as f64 / o.disk_total_bytes.max(1) as f64; @@ -71,63 +66,85 @@ impl Render for OverviewPage { used_pct ); - let mut grid = div().flex().flex_col().gap(px(10.0)).w_full(); + let mut grid = div().flex().flex_col().gap(px(10.)).w_full(); grid = grid.child( div() .flex() .flex_row() - .gap(px(10.0)) - .child(metric_card("queries / sec", &format!("{:.1}", o.qps), None)) - .child(metric_card("running", &o.running_queries.to_string(), None)) + .gap(px(10.)) + .child(metric_card( + "queries / sec", + &format!("{:.1}", o.qps), + None, + cx, + )) + .child(metric_card( + "running", + &o.running_queries.to_string(), + None, + cx, + )) .child(metric_card( "slow · 24h", &o.slow_queries_24h.to_string(), None, + cx, )) .child(metric_card( "failed · 24h", &o.failed_queries_24h.to_string(), None, + cx, )), ); grid = grid.child( div() .flex() .flex_row() - .gap(px(10.0)) + .gap(px(10.)) .child(metric_card( "active merges", &o.active_merges.to_string(), None, + cx, )) .child(metric_card( "replicas", &format!("{} / {}", o.replicas_ok, o.replicas_total), None, + cx, )) .child(metric_card( "tables", &format_count(o.tables_total as f64), None, + cx, )) .child(metric_card( "parts", &format_count(o.parts_total as f64), None, + cx, )), ); grid = grid.child( div() .flex() .flex_row() - .gap(px(10.0)) + .gap(px(10.)) .child(metric_card( "disk used", &format_bytes(o.disk_used_bytes), Some(&disk_sub), + cx, + )) + .child(metric_card( + "uptime", + &fmt_uptime(o.uptime_seconds), + None, + cx, )) - .child(metric_card("uptime", &fmt_uptime(o.uptime_seconds), None)) - .child(metric_card("version", &o.clickhouse_version, None)), + .child(metric_card("version", &o.clickhouse_version, None, cx)), ); if let Some(t) = &self.traffic @@ -138,7 +155,7 @@ impl Render for OverviewPage { .flex() .flex_col() .w_full() - .h(px(220.0)) + .h(px(220.)) .child(line_chart( "queries / sec", "qps", @@ -147,10 +164,11 @@ impl Render for OverviewPage { points: t.queries_per_sec.clone(), accent: true, }], + cx, )), ); } - grid + grid.into_any_element() } } diff --git a/app/src/pages/queries.rs b/app/src/pages/queries.rs index ce19167..05f2fd8 100644 --- a/app/src/pages/queries.rs +++ b/app/src/pages/queries.rs @@ -2,7 +2,8 @@ use chm_core::QueryRow; -use bezel::gpui::{AnyElement, Context, Render, div, prelude::*, px}; +use gpui::{AnyElement, App, Context, Render, Window, div, prelude::*, px}; +use gpui_component::ActiveTheme as _; use crate::pages::{heading, status}; use crate::widgets::{CellVal, Column, data_table}; @@ -111,10 +112,15 @@ fn query_rows(rows: &[QueryRow], with_exception: bool) -> Vec> { .collect() } -fn section(title: &str, rows: Option<&Vec>, with_exception: bool) -> AnyElement { +fn section( + title: &str, + rows: Option<&Vec>, + with_exception: bool, + cx: &App, +) -> AnyElement { let body: AnyElement = match rows { - None => status("loading…").into_any_element(), - Some(rows) if rows.is_empty() => status("none").into_any_element(), + None => status("loading…", cx).into_any_element(), + Some(rows) if rows.is_empty() => status("none", cx).into_any_element(), Some(rows) => data_table( query_columns(with_exception), query_rows(rows, with_exception), @@ -124,35 +130,32 @@ fn section(title: &str, rows: Option<&Vec>, with_exception: bool) -> A div() .flex() .flex_col() - .gap(px(6.0)) + .gap(px(6.)) .child(heading(title)) .child(body) .into_any_element() } impl Render for QueriesPage { - fn render( - &mut self, - _window: &mut bezel::gpui::Window, - _cx: &mut Context, - ) -> impl bezel::gpui::IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { if self.running.is_none() && self.slow.is_none() && self.failed.is_none() { if let Some(err) = &self.error { - return status(format!("queries unavailable: {err}")); + return status(format!("queries unavailable: {err}"), cx).into_any_element(); } - return status("loading queries…"); + return status("loading queries…", cx).into_any_element(); } - let mut col = div().flex().flex_col().gap(px(16.0)).w_full(); + let mut col = div().flex().flex_col().gap(px(16.)).w_full(); if let Some(err) = &self.error { col = col.child( div() - .text_size(px(12.0)) - .text_color(bezel::theme::ink(0.6)) + .text_xs() + .text_color(cx.theme().muted_foreground) .child(format!("partial: {err}")), ); } - col.child(section("Running", self.running.as_ref(), false)) - .child(section("Slow", self.slow.as_ref(), false)) - .child(section("Failed", self.failed.as_ref(), true)) + col.child(section("Running", self.running.as_ref(), false, cx)) + .child(section("Slow", self.slow.as_ref(), false, cx)) + .child(section("Failed", self.failed.as_ref(), true, cx)) + .into_any_element() } } diff --git a/app/src/pages/replicas.rs b/app/src/pages/replicas.rs index c7a2a31..f3a6818 100644 --- a/app/src/pages/replicas.rs +++ b/app/src/pages/replicas.rs @@ -2,7 +2,7 @@ use chm_core::ReplicaRow; -use bezel::gpui::{Context, Render, prelude::*}; +use gpui::{Context, Render, Window, prelude::*}; use crate::pages::status; use crate::widgets::{CellVal, Column, data_table}; @@ -39,19 +39,15 @@ impl ReplicasPage { } impl Render for ReplicasPage { - fn render( - &mut self, - _window: &mut bezel::gpui::Window, - _cx: &mut Context, - ) -> impl bezel::gpui::IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { if let Some(err) = &self.error { - return status(format!("replicas unavailable: {err}")).into_any_element(); + return status(format!("replicas unavailable: {err}"), cx).into_any_element(); } let Some(rows) = &self.data else { - return status("loading replicas…").into_any_element(); + return status("loading replicas…", cx).into_any_element(); }; if rows.is_empty() { - return status("no replicas").into_any_element(); + return status("no replicas", cx).into_any_element(); } let columns = vec![ Column { diff --git a/app/src/pages/settings.rs b/app/src/pages/settings.rs index 989973f..9907acd 100644 --- a/app/src/pages/settings.rs +++ b/app/src/pages/settings.rs @@ -2,15 +2,54 @@ //! app menu (`cmd-,`) or the sidebar footer. Writes `[ui]` / `[telemetry]` //! / `profile.channel` in config.toml. -use bezel::gpui::{Context, Render, SharedString, Window, div, prelude::*, px}; -use bezel::theme::{Theme, appearance::AppearanceMode}; use chm_update::Channel; +use gpui::{App, Context, Render, Window, div, prelude::*, px}; +use gpui_component::{ + ActiveTheme as _, Theme, ThemeMode, + radio::{Radio, RadioGroup}, + v_flex, +}; use crate::config::{config_path, load_config, save_config}; use crate::pages::heading; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Appearance { + System, + Light, + Dark, +} + +impl Appearance { + const ALL: [Appearance; 3] = [Appearance::System, Appearance::Light, Appearance::Dark]; + + fn index(self) -> usize { + Self::ALL.iter().position(|&m| m == self).unwrap_or(0) + } + + fn from_index(i: usize) -> Self { + Self::ALL.get(i).copied().unwrap_or(Appearance::System) + } + + fn label(self) -> &'static str { + match self { + Self::System => "System", + Self::Light => "Light", + Self::Dark => "Dark", + } + } + + fn hint(self) -> &'static str { + match self { + Self::System => "follow macOS light/dark", + Self::Light => "always light", + Self::Dark => "always dark", + } + } +} + pub struct SettingsPage { - appearance: AppearanceMode, + appearance: Appearance, channel: Channel, telemetry: bool, status: Option, @@ -41,9 +80,9 @@ impl SettingsPage { self.status = save_config(&cfg).err(); } - fn set_appearance(&mut self, mode: AppearanceMode, cx: &mut Context) { + fn set_appearance(&mut self, mode: Appearance, window: &mut Window, cx: &mut Context) { self.appearance = mode; - bezel::theme::appearance::set_mode(mode, cx); + apply_appearance(mode, window, cx); self.persist(); cx.notify(); } @@ -59,83 +98,10 @@ impl SettingsPage { self.persist(); cx.notify(); } - - fn choice_row( - &self, - theme: &Theme, - id: &'static str, - title: (&'static str, &'static str), - selected: bool, - on: impl Fn(&mut Self, &mut Context) + 'static, - cx: &mut Context, - ) -> impl bezel::gpui::IntoElement { - let (label, hint) = title; - div() - .id(SharedString::from(id)) - .flex() - .flex_row() - .items_center() - .gap(px(10.0)) - .px(px(12.0)) - .py(px(8.0)) - .rounded(px(8.0)) - .border_1() - .border_color(if selected { - theme.border_strong - } else { - theme.border - }) - .bg(if selected { - theme.element_active - } else { - theme.input_bg - }) - .cursor_pointer() - .hover(|s| s.bg(theme.element_hover)) - .on_click(cx.listener(move |this, _, _, cx| on(this, cx))) - .child( - div() - .size(px(14.0)) - .rounded_full() - .border_1() - .border_color(theme.border_strong) - .when(selected, |dot| dot.bg(theme.accent)), - ) - .child( - div() - .flex() - .flex_col() - .gap(px(2.0)) - .child(div().text_size(px(13.0)).child(label)) - .child( - div() - .text_size(px(11.0)) - .text_color(theme.text_muted) - .child(hint), - ), - ) - } - - fn section( - title: &str, - children: impl IntoIterator, - ) -> bezel::gpui::Div { - div() - .flex() - .flex_col() - .gap(px(8.0)) - .child(heading(title)) - .children(children) - } } impl Render for SettingsPage { - fn render( - &mut self, - _window: &mut Window, - cx: &mut Context, - ) -> impl bezel::gpui::IntoElement { - let theme = Theme::of(cx).clone(); + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let path = config_path() .map(|p| p.display().to_string()) .unwrap_or_else(|| "(no config directory)".into()); @@ -143,138 +109,131 @@ impl Render for SettingsPage { let channel = self.channel; let telemetry = self.telemetry; - div() - .flex() - .flex_col() - .gap(px(20.0)) - .max_w(px(520.0)) - .child(Self::section( - "Appearance", - AppearanceMode::ALL.iter().map(|&mode| { - self.choice_row( - &theme, - match mode { - AppearanceMode::System => "app-system", - AppearanceMode::Light => "app-light", - AppearanceMode::Dark => "app-dark", - }, - ( - mode.label(), - match mode { - AppearanceMode::System => "follow macOS light/dark", - AppearanceMode::Light => "always light", - AppearanceMode::Dark => "always dark", - }, + v_flex() + .gap_5() + .max_w(px(520.)) + .child(heading("Appearance")) + .child( + RadioGroup::vertical("appearance") + .children(Appearance::ALL.iter().map(|&mode| { + Radio::new(mode.label()).label(mode.label()).child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(mode.hint()), + ) + })) + .selected_index(Some(appearance.index())) + .on_click(cx.listener(|this, index: &usize, window, cx| { + this.set_appearance(Appearance::from_index(*index), window, cx); + })), + ) + .child(heading("Updates")) + .child( + RadioGroup::vertical("channel") + .child( + Radio::new("stable").label("Stable").child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("tagged releases"), ), - appearance == mode, - move |this, cx| this.set_appearance(mode, cx), - cx, ) - .into_any_element() - }), - )) - .child(Self::section( - "Updates", - [ - self.choice_row( - &theme, - "ch-stable", - ("Stable", "tagged releases"), - channel == Channel::Stable, - |this, cx| this.set_channel(Channel::Stable, cx), - cx, - ) - .into_any_element(), - self.choice_row( - &theme, - "ch-beta", - ("Beta", "pre-release builds"), - channel == Channel::Beta, - |this, cx| this.set_channel(Channel::Beta, cx), - cx, + .child( + Radio::new("beta").label("Beta").child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("pre-release builds"), + ), ) - .into_any_element(), - ], - )) - .child(Self::section( - "Telemetry", - [ - self.choice_row( - &theme, - "tel-off", - ("Off", "nothing is recorded or sent (default)"), - !telemetry, - |this, cx| this.set_telemetry(false, cx), - cx, + .selected_index(Some(if channel == Channel::Stable { 0 } else { 1 })) + .on_click(cx.listener(|this, index: &usize, _, cx| { + this.set_channel( + if *index == 0 { + Channel::Stable + } else { + Channel::Beta + }, + cx, + ); + })), + ) + .child(heading("Telemetry")) + .child( + RadioGroup::vertical("telemetry") + .child( + Radio::new("off").label("Off").child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("nothing is recorded or sent (default)"), + ), ) - .into_any_element(), - self.choice_row( - &theme, - "tel-on", - ("On", "local fetch timings only; no query text"), - telemetry, - |this, cx| this.set_telemetry(true, cx), - cx, + .child( + Radio::new("on").label("On").child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("local fetch timings only; no query text"), + ), ) - .into_any_element(), - ], - )) - .child(Self::section( - "Shortcuts", - [div() - .flex() - .flex_col() - .gap(px(4.0)) - .text_size(px(12.0)) - .text_color(theme.text_muted) + .selected_index(Some(if telemetry { 1 } else { 0 })) + .on_click(cx.listener(|this, index: &usize, _, cx| { + this.set_telemetry(*index == 1, cx); + })), + ) + .child(heading("Shortcuts")) + .child( + v_flex() + .gap_1() + .text_sm() + .text_color(cx.theme().muted_foreground) .child("1–8 switch page") .child("r refresh") .child("⌘B toggle sidebar") .child("⌘, settings") - .child("⌘Q quit") - .into_any_element()], - )) + .child("⌘Q quit"), + ) + .child(heading("Config file")) .child( - div() - .flex() - .flex_col() - .gap(px(4.0)) - .child(heading("Config file")) - .child( - div() - .text_size(px(12.0)) - .text_color(theme.text_muted) - .child(format!("chmonitor {}", env!("CARGO_PKG_VERSION"))), - ) - .child( - div() - .text_size(px(12.0)) - .text_color(theme.text_muted) - .child(path), - ), + v_flex() + .gap_1() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(format!("chmonitor {}", env!("CARGO_PKG_VERSION"))) + .child(path), ) .children(self.status.as_ref().map(|e| { div() - .text_size(px(12.0)) - .text_color(theme.danger) + .text_sm() + .text_color(cx.theme().danger) .child(e.clone()) })) } } -pub fn appearance_from_cfg(s: Option<&str>) -> AppearanceMode { +pub fn apply_appearance(mode: Appearance, window: &mut Window, cx: &mut App) { + match mode { + Appearance::Light => Theme::change(ThemeMode::Light, Some(window), cx), + Appearance::Dark => Theme::change(ThemeMode::Dark, Some(window), cx), + Appearance::System => Theme::sync_system_appearance(Some(window), cx), + } +} + +pub fn appearance_from_cfg(s: Option<&str>) -> Appearance { match s.map(|s| s.to_ascii_lowercase()).as_deref() { - Some("light") => AppearanceMode::Light, - Some("dark") => AppearanceMode::Dark, - _ => AppearanceMode::System, + Some("light") => Appearance::Light, + Some("dark") => Appearance::Dark, + _ => Appearance::System, } } -fn appearance_to_cfg(mode: AppearanceMode) -> &'static str { +fn appearance_to_cfg(mode: Appearance) -> &'static str { match mode { - AppearanceMode::System => "system", - AppearanceMode::Light => "light", - AppearanceMode::Dark => "dark", + Appearance::System => "system", + Appearance::Light => "light", + Appearance::Dark => "dark", } } @@ -291,11 +250,11 @@ mod tests { #[test] fn appearance_and_channel_parse() { - assert_eq!(appearance_from_cfg(None), AppearanceMode::System); - assert_eq!(appearance_from_cfg(Some("DARK")), AppearanceMode::Dark); - assert_eq!(appearance_from_cfg(Some("light")), AppearanceMode::Light); + assert_eq!(appearance_from_cfg(None), Appearance::System); + assert_eq!(appearance_from_cfg(Some("DARK")), Appearance::Dark); + assert_eq!(appearance_from_cfg(Some("light")), Appearance::Light); assert_eq!(channel_from_cfg(Some("beta")), Channel::Beta); assert_eq!(channel_from_cfg(None), Channel::Stable); - assert_eq!(appearance_to_cfg(AppearanceMode::Dark), "dark"); + assert_eq!(appearance_to_cfg(Appearance::Dark), "dark"); } } diff --git a/app/src/pages/tables.rs b/app/src/pages/tables.rs index 0ebcae1..eeb6e5d 100644 --- a/app/src/pages/tables.rs +++ b/app/src/pages/tables.rs @@ -2,7 +2,7 @@ use chm_core::TableStat; -use bezel::gpui::{Context, Render, prelude::*}; +use gpui::{Context, Render, Window, prelude::*}; use crate::pages::status; use crate::widgets::{CellVal, Column, data_table}; @@ -39,19 +39,15 @@ impl TablesPage { } impl Render for TablesPage { - fn render( - &mut self, - _window: &mut bezel::gpui::Window, - _cx: &mut Context, - ) -> impl bezel::gpui::IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { if let Some(err) = &self.error { - return status(format!("tables unavailable: {err}")).into_any_element(); + return status(format!("tables unavailable: {err}"), cx).into_any_element(); } let Some(rows) = &self.data else { - return status("loading tables…").into_any_element(); + return status("loading tables…", cx).into_any_element(); }; if rows.is_empty() { - return status("no tables").into_any_element(); + return status("no tables", cx).into_any_element(); } let columns = vec![ Column { diff --git a/app/src/pages/traffic.rs b/app/src/pages/traffic.rs index 6dd3459..0a51817 100644 --- a/app/src/pages/traffic.rs +++ b/app/src/pages/traffic.rs @@ -2,7 +2,7 @@ use chm_core::TrafficSeries; -use bezel::gpui::{Context, Render, div, prelude::*, px}; +use gpui::{App, Context, Render, Window, div, prelude::*, px}; use crate::pages::status; use crate::widgets::{NamedSeries, line_chart}; @@ -38,13 +38,13 @@ impl TrafficPage { } } -fn chart(title: &str, unit: &str, points: &[chm_core::SeriesPoint]) -> bezel::gpui::Div { +fn chart(title: &str, unit: &str, points: &[chm_core::SeriesPoint], cx: &App) -> gpui::Div { div() .flex() .flex_col() .flex_1() .min_w_0() - .h(px(200.0)) + .h(px(200.)) .child(line_chart( title, unit, @@ -53,48 +53,46 @@ fn chart(title: &str, unit: &str, points: &[chm_core::SeriesPoint]) -> bezel::gp points: points.to_vec(), accent: true, }], + cx, )) } impl Render for TrafficPage { - fn render( - &mut self, - _window: &mut bezel::gpui::Window, - _cx: &mut Context, - ) -> impl bezel::gpui::IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { if let Some(err) = &self.error { - return status(format!("traffic unavailable: {err}")); + return status(format!("traffic unavailable: {err}"), cx).into_any_element(); } let Some(t) = &self.data else { - return status("loading traffic…"); + return status("loading traffic…", cx).into_any_element(); }; if t.queries_per_sec.is_empty() && t.rows_read_per_sec.is_empty() && t.network_rx_bps.is_empty() && t.network_tx_bps.is_empty() { - return status("no traffic in this range"); + return status("no traffic in this range", cx).into_any_element(); } div() .flex() .flex_col() - .gap(px(10.0)) + .gap(px(10.)) .w_full() .child( div() .flex() .flex_row() - .gap(px(10.0)) - .child(chart("queries / sec", "qps", &t.queries_per_sec)) - .child(chart("rows read / sec", "rows/s", &t.rows_read_per_sec)), + .gap(px(10.)) + .child(chart("queries / sec", "qps", &t.queries_per_sec, cx)) + .child(chart("rows read / sec", "rows/s", &t.rows_read_per_sec, cx)), ) .child( div() .flex() .flex_row() - .gap(px(10.0)) - .child(chart("network in", "bit/s", &t.network_rx_bps)) - .child(chart("network out", "bit/s", &t.network_tx_bps)), + .gap(px(10.)) + .child(chart("network in", "bit/s", &t.network_rx_bps, cx)) + .child(chart("network out", "bit/s", &t.network_tx_bps, cx)), ) + .into_any_element() } } diff --git a/app/src/shell.rs b/app/src/shell.rs index f6a10be..55b49e8 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -1,9 +1,5 @@ //! App shell — window layout, sidebar nav, page routing, status bar, //! 30-second poll loop and the startup update-check hook. -//! AGENT D OWNS THIS FILE. -//! -//! All gpui types come through `bezel::gpui`; widgets come from the bezel -//! facade (`bezel::theme`, `bezel::ui`). use std::sync::{Arc, OnceLock}; use std::time::{Duration, Instant}; @@ -13,12 +9,21 @@ use chm_core::{ TableStat, TimeRange, TrafficSeries, }; -use bezel::gpui::{ +use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, FocusHandle, Focusable, Hsla, KeyBinding, KeyDownEvent, Render, SharedString, WeakEntity, Window, actions, div, prelude::*, px, }; -use bezel::theme::Theme; -use bezel::ui::widgets::status_dot; +use gpui_component::{ + ActiveTheme as _, IconName, Root, Sizable as _, + button::{Button, ButtonVariants as _}, + h_flex, + sidebar::{ + Sidebar, SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuItem, + SidebarToggleButton, + }, + status_bar::StatusBar, + v_flex, +}; use crate::config::{ ConfigFile, Host, ProfileConfig, active_host_id, cli, config_path, list_hosts, load_config, @@ -43,7 +48,6 @@ const POLL_SECS: u64 = 30; const COMPACT_BELOW: f32 = 900.0; /// Sidebar width expanded / collapsed. const SIDEBAR_W: f32 = 190.0; -const SIDEBAR_W_COMPACT: f32 = 48.0; /// Perf metrics live for the whole process; recording is gated by /// `[telemetry] enabled=true` in config.toml (never on by default). @@ -65,11 +69,11 @@ pub enum ConnState { } impl ConnState { - fn dot(self, theme: &Theme) -> Hsla { + fn color(self, cx: &App) -> Hsla { match self { - Self::Connected => theme.success, - Self::Connecting => theme.warning, - Self::Error => theme.danger, + Self::Connected => cx.theme().green, + Self::Connecting => cx.theme().warning, + Self::Error => cx.theme().danger, } } } @@ -110,7 +114,6 @@ pub struct Shell { /// `None` follows the viewport; `Some` is a click/`cmd-b` override. sidebar_collapsed: Option, active_host: Option, - host_menu_open: bool, host_status: HostStatus, } @@ -148,7 +151,7 @@ impl Shell { } } - pub fn new(cx: &mut Context) -> Self { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { let (source, conn, active_host) = Self::pick_source(); // Telemetry hook: opt-in only. Recording stays off unless the user @@ -212,12 +215,11 @@ impl Shell { health: cx.new(|_| HealthPage::new()), tables: cx.new(|_| TablesPage::new()), traffic: cx.new(|_| TrafficPage::new()), - connect: cx.new(|cx| ConnectFlow::new(load_profile(), cx)), + connect: cx.new(|cx| ConnectFlow::new(load_profile(), window, cx)), settings: cx.new(|_| SettingsPage::new()), update_note: None, sidebar_collapsed: None, active_host, - host_menu_open: false, host_status: HostStatus::default(), }; @@ -268,18 +270,15 @@ impl Shell { ConnState::Error }; self.host_status = HostStatus::default(); - self.host_menu_open = false; self.last_error = None; } fn switch_host(&mut self, host_id: String, cx: &mut Context) { if self.active_host.as_deref() == Some(host_id.as_str()) { - self.host_menu_open = false; cx.notify(); return; } if std::env::var("CHM_SMOKE").is_ok() { - self.host_menu_open = false; cx.notify(); return; } @@ -478,283 +477,111 @@ impl Shell { } } - // -- rendering ---------------------------------------------------------- - - fn sidebar_toggle( - &self, - theme: &Theme, - compact: bool, - cx: &mut Context, - ) -> impl bezel::gpui::IntoElement { - let glyph = if compact { "›" } else { "‹" }; - let label = if compact { - div().child(SharedString::from(glyph)) - } else { - div() - .flex() - .flex_row() - .items_center() - .justify_between() - .w_full() - .child( - div() - .text_size(px(11.0)) - .text_color(theme.text_faint) - .child("Sidebar"), - ) - .child( - div() - .text_color(theme.text_muted) - .child(SharedString::from(glyph)), - ) - }; - div() - .id("sidebar-toggle") - .w_full() - .px(px(if compact { 0.0 } else { 12.0 })) - .py(px(6.0)) - .rounded(px(6.0)) - .cursor_pointer() - .hover(|s| s.bg(theme.element_hover)) - .text_size(px(13.0)) - .when(compact, |el| el.flex().justify_center()) - .on_click( - cx.listener(|this, _: &bezel::gpui::ClickEvent, window, cx| { - this.toggle_sidebar(window.viewport_size().width < px(COMPACT_BELOW), cx); - }), - ) - .child(label) + fn host_icon(mode: Option<&str>) -> IconName { + match mode { + Some("postgres") => IconName::HardDrive, + Some("cloud") => IconName::Globe, + _ => IconName::Cpu, + } } - fn host_switcher( - &self, - theme: &Theme, - compact: bool, - cx: &mut Context, - ) -> impl IntoElement { - let label = self.active_host_label(); - let chevron = if self.host_menu_open { "▴" } else { "▾" }; - let trigger = div() - .id("host-switcher") - .w_full() - .px(px(if compact { 0.0 } else { 12.0 })) - .py(px(6.0)) - .rounded(px(6.0)) - .cursor_pointer() - .hover(|s| s.bg(theme.element_hover)) - .when(compact, |el| el.flex().justify_center()) - .on_click(cx.listener(|this, _: &bezel::gpui::ClickEvent, _, cx| { - this.host_menu_open = !this.host_menu_open; - cx.notify(); - })) - .child(if compact { - div().child(status_dot(self.conn.dot(theme))) - } else { - div() - .flex() - .flex_row() - .items_center() - .gap(px(8.0)) - .w_full() - .child(status_dot(self.conn.dot(theme))) - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .text_size(px(13.0)) - .child(label), - ) - .child( - div() - .text_size(px(10.0)) - .text_color(theme.text_faint) - .child(chevron), - ) - }); + // -- rendering ---------------------------------------------------------- - let mut col = div().flex().flex_col().gap(px(2.0)).child(trigger); - if self.host_menu_open { - let active = self.active_host.clone(); - for host in self.hosts() { - let id = host.id.clone(); - let selected = active.as_deref() == Some(id.as_str()); - let tag = match host.profile.mode.as_deref() { - Some("postgres") => " pg", - Some("clickhouse") => " ch", - Some("cloud") => " cloud", - _ => "", - }; - let row_label = format!("{}{tag}", host.label); - col = col.child( - div() - .id(SharedString::from(format!("host-{id}"))) - .w_full() - .px(px(if compact { 0.0 } else { 12.0 })) - .py(px(5.0)) - .rounded(px(6.0)) - .cursor_pointer() - .when(selected, |el| el.bg(theme.element_active)) - .hover(|s| s.bg(theme.element_hover)) - .text_size(px(12.0)) - .when(compact, |el| el.flex().justify_center()) - .on_click(cx.listener(move |this, _, _, cx| { - this.switch_host(id.clone(), cx); - })) - .child(if compact { - div() - .text_size(px(10.0)) - .child(row_label.chars().next().unwrap_or('·').to_string()) - } else { - div().truncate().child(row_label) - }), - ); - } - col = col.child( - div() - .id("host-add") - .w_full() - .px(px(if compact { 0.0 } else { 12.0 })) - .py(px(5.0)) - .rounded(px(6.0)) - .cursor_pointer() - .hover(|s| s.bg(theme.element_hover)) - .text_size(px(12.0)) - .text_color(theme.text_muted) - .when(compact, |el| el.flex().justify_center()) - .on_click(cx.listener(|this, _, _, cx| { - this.host_menu_open = false; - this.page = Page::Connect; - cx.notify(); - })) - .child(if compact { - SharedString::from("+") - } else { - SharedString::from("+ Add host") - }), + fn render_sidebar(&self, compact: bool, cx: &mut Context) -> impl IntoElement { + let engine = self.source_engine(); + let active_host = self.active_host.clone(); + let hosts = self.hosts(); + + let mut host_menu = SidebarMenu::new(); + for host in hosts { + let id = host.id.clone(); + let selected = active_host.as_deref() == Some(id.as_str()); + let icon = Self::host_icon(host.profile.mode.as_deref()); + host_menu = host_menu.child( + SidebarMenuItem::new(host.label.clone()) + .icon(icon) + .active(selected) + .on_click(cx.listener(move |this, _, _, cx| this.switch_host(id.clone(), cx))), ); } - col - } - - fn sidebar(&self, theme: &Theme, compact: bool, cx: &mut Context) -> bezel::gpui::Div { - let engine = self.source_engine(); - let items: Vec = Page::ALL + host_menu = host_menu.child( + SidebarMenuItem::new("Add host") + .icon(IconName::Plus) + .on_click(cx.listener(|this, _, _, cx| { + this.page = Page::Connect; + cx.notify(); + })), + ); + + let mut nav = SidebarMenu::new(); + for (i, page) in Page::ALL .iter() .copied() .filter(|page| page.available(engine)) .enumerate() - .map(|(i, page)| { - let active = page == self.page; - let hotkey = format!("{}", i + 1); - let label = if compact { - div().child(page.icon()) - } else { - div() - .flex() - .flex_row() - .items_center() - .gap(px(8.0)) - .child(page.icon()) - .child(div().child(page.title())) - .child( - div() - .ml(px(2.0)) - .text_size(px(10.0)) - .text_color(theme.text_faint) - .child(hotkey), - ) - }; - div() - .id(SharedString::from(format!("nav-{}", page.title()))) - .w_full() - .px(px(if compact { 0.0 } else { 12.0 })) - .py(px(6.0)) - .rounded(px(6.0)) - .cursor_pointer() - .when(active, |el| el.bg(theme.element_active)) - .hover(|s| s.bg(theme.element_hover)) - .text_size(px(13.0)) - .text_color(if active { theme.text } else { theme.text_muted }) - .on_click( - cx.listener(move |this, _: &bezel::gpui::ClickEvent, _, cx| { - this.goto(page, cx); - }), - ) - .child(label) - }) - .map(bezel::gpui::IntoElement::into_any_element) - .collect(); - - div() - .flex() - .flex_col() - .gap(px(2.0)) - .when(compact, |col| col.items_center()) - .children(items) - } + { + let active = page == self.page; + let hotkey = format!("{}", i + 1); + nav = nav.child( + SidebarMenuItem::new(page.title()) + .icon(page.icon()) + .active(active) + .suffix({ + let hotkey = hotkey.clone(); + let muted = cx.theme().muted_foreground; + move |_, _| div().text_xs().text_color(muted).child(hotkey.clone()) + }) + .on_click(cx.listener(move |this, _, _, cx| this.goto(page, cx))), + ); + } - fn settings_nav( - &self, - theme: &Theme, - compact: bool, - cx: &mut Context, - ) -> impl IntoElement { - let active = self.page == Page::Settings; - let label = if compact { - div().child(Page::Settings.icon()) - } else { - div() - .flex() - .flex_row() - .items_center() - .gap(px(8.0)) - .child(Page::Settings.icon()) - .child(div().child(Page::Settings.title())) - .child( - div() - .ml(px(2.0)) - .text_size(px(10.0)) - .text_color(theme.text_faint) - .child("⌘,"), - ) - }; - div() - .id("nav-Settings") - .w_full() - .px(px(if compact { 0.0 } else { 12.0 })) - .py(px(6.0)) - .rounded(px(6.0)) - .cursor_pointer() - .when(active, |el| el.bg(theme.element_active)) - .hover(|s| s.bg(theme.element_hover)) - .text_size(px(13.0)) - .text_color(if active { theme.text } else { theme.text_muted }) - .when(compact, |el| el.flex().justify_center()) - .on_click(cx.listener(|this, _: &bezel::gpui::ClickEvent, _, cx| { - this.page = Page::Settings; - cx.notify(); - })) - .child(label) + Sidebar::new("nav") + .collapsed(compact) + .collapsible(true) + .w(px(SIDEBAR_W)) + .header( + SidebarHeader::new().child( + h_flex().w_full().items_center().justify_between().child( + SidebarToggleButton::new() + .collapsed(compact) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_sidebar( + window.viewport_size().width < px(COMPACT_BELOW), + cx, + ); + })), + ), + ), + ) + .child(SidebarGroup::new("Host").child(host_menu)) + .child(SidebarGroup::new("Monitor").child(nav)) + .child( + SidebarGroup::new("App").child( + SidebarMenu::new().child( + SidebarMenuItem::new(Page::Settings.title()) + .icon(Page::Settings.icon()) + .active(self.page == Page::Settings) + .on_click(cx.listener(|this, _, _, cx| { + this.page = Page::Settings; + cx.notify(); + })), + ), + ), + ) + .footer(SidebarFooter::new().child("chmonitor")) } - fn range_bar(&self, theme: &Theme, cx: &mut Context) -> bezel::gpui::Div { - let mut row = div().flex().flex_row().items_center().gap(px(4.0)); - for range in TimeRange::ALL { - let active = self.range == range; - row = row.child( - div() - .id(SharedString::from(format!("range-{}", range.label()))) - .px(px(8.0)) - .py(px(4.0)) - .rounded(px(6.0)) - .cursor_pointer() - .text_size(px(11.5)) - .when(active, |el| { - el.bg(theme.element_active).text_color(theme.text) - }) - .when(!active, |el| el.text_color(theme.text_muted)) - .hover(|s| s.bg(theme.element_hover)) + fn range_bar(&self, cx: &mut Context) -> impl IntoElement { + h_flex() + .items_center() + .gap_1() + .children(TimeRange::ALL.into_iter().map(|range| { + let active = self.range == range; + Button::new(SharedString::from(format!("range-{}", range.label()))) + .xsmall() + .when(active, |b| b.primary()) + .when(!active, |b| b.ghost()) + .label(range.label()) .on_click(cx.listener(move |this, _, _, cx| { if this.range != range { this.range = range; @@ -762,20 +589,12 @@ impl Shell { cx.notify(); } })) - .child(range.label()), - ); - } - row + })) } - fn status_bar(&self, theme: &Theme) -> bezel::gpui::Div { - let host = SharedString::from(self.active_host_label()); - let status = SharedString::from(self.host_status_text()); - let status_color = match self.conn { - ConnState::Error => theme.danger, - ConnState::Connecting => theme.warning, - ConnState::Connected => theme.text_muted, - }; + fn status_bar(&self, cx: &Context) -> impl IntoElement { + let host = self.active_host_label(); + let status = self.host_status_text(); let refreshed = match self.last_refresh { Some(at) => format!("updated {}", at.format("%H:%M:%S")), None => "not refreshed yet".to_string(), @@ -785,43 +604,28 @@ impl Shell { Some(UpdateNote(t)) if !t.is_empty() => Some(t.clone()), Some(_) => None, }; - - div() - .flex() - .flex_row() - .items_center() - .gap(px(12.0)) - .px(px(12.0)) - .py(px(6.0)) - .border_t_1() - .border_color(theme.border) - .bg(theme.surface) - .text_size(px(11.5)) - .text_color(theme.text_muted) - .child(status_dot(self.conn.dot(theme))) - .child(div().min_w_0().truncate().child(host)) - .child( - div() - .min_w_0() - .flex_1() - .truncate() - .text_color(status_color) - .child(status), + StatusBar::new() + .left( + h_flex() + .items_center() + .gap_2() + .child(div().size_2().rounded_full().bg(self.conn.color(cx))) + .child(host), ) - .child(div().child(refreshed)) - .children(note.map(|t| div().text_color(theme.text_faint).child(t))) + .child(div().text_color(self.conn.color(cx)).child(status)) + .right(refreshed) + .children(note.map(|t| div().text_color(cx.theme().muted_foreground).child(t))) } - fn content(&mut self, _cx: &mut Context) -> bezel::gpui::AnyElement { - // No source yet: Connect owns the pane whatever the route points at. + fn content(&mut self, cx: &mut Context) -> gpui::AnyElement { if self.source.is_none() && !matches!(self.page, Page::Connect | Page::Settings) { return div() .flex() .flex_1() .items_center() .justify_center() - .text_color(bezel::theme::ink(0.55)) - .text_size(px(13.0)) + .text_color(cx.theme().muted_foreground) + .text_sm() .child("no connection configured — pick a mode in Connect") .into_any_element(); } @@ -841,20 +645,12 @@ impl Shell { impl Render for Shell { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - // Cloned so helpers can take &mut cx while holding theme tokens - // (Theme::of borrows cx for the whole expression otherwise). - let theme = Theme::of(cx).clone(); - // Hover fades paint once and stick unless frames are requested. - if bezel::motion::hover_fades_active() { - window.request_animation_frame(); - } - let viewport = window.viewport_size(); let narrow = viewport.width < px(COMPACT_BELOW); let compact = sidebar_is_compact(self.sidebar_collapsed, narrow); let show_range = self.page.uses_range() && self.source.is_some(); - div() + h_flex() .id("shell") .key_context("Shell") .track_focus(&self.focus) @@ -867,8 +663,6 @@ impl Render for Shell { cx.notify(); })) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { - // Only when the shell itself holds focus — digits typed into - // a Connect text field must not switch pages. if !this.focus.is_focused(window) { return; } @@ -894,66 +688,24 @@ impl Render for Shell { } } })) - .flex() - .flex_row() - .flex_1() - .bg(theme.bg) - .text_color(theme.text) .size_full() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .child(self.render_sidebar(compact, cx)) .child( - // Sidebar surface; collapses to an icon strip below 900px. - div() - .flex() - .flex_col() - .w(px(if compact { - SIDEBAR_W_COMPACT - } else { - SIDEBAR_W - })) - .h_full() - .flex_none() - .p(px(8.0)) - .gap(px(8.0)) - .border_r_1() - .border_color(theme.border) - .bg(theme.surface) - .child(self.sidebar_toggle(&theme, compact, cx)) - .child( - div() - .flex() - .flex_col() - .flex_1() - .min_h_0() - .when(compact, |col| col.items_center()) - .child(self.host_switcher(&theme, compact, cx)) - .child(self.sidebar(&theme, compact, cx)) - .child(div().flex_1()) - .child(self.settings_nav(&theme, compact, cx)) - .pb(px(28.0)), - ), - ) - .child( - div() - .flex() - .flex_col() + v_flex() .flex_1() .min_w_0() .child( - div() - .flex() - .flex_row() + h_flex() .items_center() - .px(px(16.0)) - .pt(px(12.0)) - .pb(px(4.0)) - .gap(px(12.0)) - .child( - div() - .text_size(px(15.0)) - .child(SharedString::from(self.page.title())), - ) + .px_4() + .pt_3() + .pb_1() + .gap_3() + .child(div().text_lg().child(SharedString::from(self.page.title()))) .child(div().flex_1()) - .when(show_range, |row| row.child(self.range_bar(&theme, cx))), + .when(show_range, |row| row.child(self.range_bar(cx))), ) .child( div() @@ -962,12 +714,13 @@ impl Render for Shell { .flex_col() .flex_1() .min_h_0() - .p(px(16.0)) + .p_4() .overflow_y_scroll() .child(self.content(cx)), ) - .child(self.status_bar(&theme)), + .child(self.status_bar(cx)), ) + .children(Root::render_notification_layer(window, cx)) } } diff --git a/app/src/widgets/cards.rs b/app/src/widgets/cards.rs index 1e47a04..3148d0e 100644 --- a/app/src/widgets/cards.rs +++ b/app/src/widgets/cards.rs @@ -1,18 +1,10 @@ -//! Metric card: a labeled headline number with an optional sub-line, painted -//! from the theme tokens without needing an `App` in scope. +//! Metric card: a labeled headline number with an optional sub-line. -use bezel::gpui::{div, prelude::*, px}; -use bezel::theme::{Theme, current_appearance}; - -/// Resolve the theme without a context: the process-wide appearance mirror -/// plus the palette builder give exactly what `Theme::of(cx)` would return. -pub(crate) fn theme_now() -> Theme { - Theme::for_appearance(current_appearance()) -} +use gpui::{App, FontWeight, div, prelude::*, px}; +use gpui_component::ActiveTheme as _; /// A stat tile: small muted label, large value, optional muted sub-line. -pub fn metric_card(label: &str, value: &str, sub: Option<&str>) -> impl IntoElement { - let t = theme_now(); +pub fn metric_card(label: &str, value: &str, sub: Option<&str>, cx: &App) -> impl IntoElement { let label = label.to_string(); let value = value.to_string(); let sub = sub.map(str::to_string); @@ -20,25 +12,31 @@ pub fn metric_card(label: &str, value: &str, sub: Option<&str>) -> impl IntoElem div() .flex() .flex_col() - .gap(px(4.0)) - .p(px(12.0)) - .min_w(px(140.0)) + .gap(px(4.)) + .p(px(12.)) + .min_w(px(140.)) .flex_1() - .bg(t.surface_card) + .bg(cx.theme().secondary) .border_1() - .border_color(t.border) - .rounded(px(Theme::PANEL_RADIUS)) + .border_color(cx.theme().border) + .rounded(cx.theme().radius) .child( div() - .text_size(px(11.0)) - .text_color(t.text_muted) + .text_sm() + .text_color(cx.theme().muted_foreground) .child(label), ) - .child(div().text_size(px(20.0)).text_color(t.text).child(value)) + .child( + div() + .text_xl() + .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().foreground) + .child(value), + ) .children(sub.map(|sub| { div() - .text_size(px(11.0)) - .text_color(t.text_faint) + .text_xs() + .text_color(cx.theme().muted_foreground) .child(sub) })) } diff --git a/app/src/widgets/chart.rs b/app/src/widgets/chart.rs index 42737cf..4e40eb8 100644 --- a/app/src/widgets/chart.rs +++ b/app/src/widgets/chart.rs @@ -1,17 +1,18 @@ -//! Time-series line chart: axes, tick labels and one polyline per series, -//! painted on a gpui [`canvas`](bezel::gpui::canvas). The math lives in -//! [`geometry`](super::geometry); this file only turns pixels into paint. +//! Time-series chart via gpui-component [`AreaChart`] / [`LineChart`]. -use super::geometry::{Bounds, format_count, nice_scale, points_to_px}; -use bezel::gpui::{ - App, Bounds as GBounds, Font, FontFeatures, FontWeight, Hsla, IntoElement, PathBuilder, Pixels, - TextAlign, TextRun, Window, canvas, div, font, point, prelude::*, px, size, -}; -use bezel::theme::{Theme, current_appearance, hairline}; use chm_core::SeriesPoint; +use gpui::{ + App, FontWeight, SharedString, div, linear_color_stop, linear_gradient, prelude::*, px, +}; +use gpui_component::{ + ActiveTheme as _, + chart::{AreaChart, LineChart}, + h_flex, v_flex, +}; -/// One named line on a chart. `accent` picks the theme's accent color instead -/// of the muted foreground, so a primary series can out-shine its peers. +use super::geometry::format_time_of_day; + +/// One named line on a chart. `accent` picks the primary chart color. #[derive(Debug, Clone, Default, PartialEq)] pub struct NamedSeries { pub name: String, @@ -19,293 +20,102 @@ pub struct NamedSeries { pub accent: bool, } -/// Layout metrics for one chart, resolved against the actual element size. -struct ChartLayout { - plot: Bounds, -} - -const PAD_LEFT: f64 = 52.0; -const PAD_RIGHT: f64 = 12.0; -const PAD_TOP: f64 = 8.0; -const PAD_BOTTOM: f64 = 22.0; -const MIN_PLOT_W: f64 = 40.0; -const MIN_PLOT_H: f64 = 40.0; -const STROKE_WIDTH: f32 = 1.5; -const AXIS_FONT_SIZE: f32 = 11.0; -const AXIS_LINE_HEIGHT: f32 = 14.0; - -fn mono_font(t: &Theme) -> Font { - let mut f = font(t.font_mono.clone()); - f.features = FontFeatures::default(); - f -} - -/// Tick label text: compact counts, except bytes-style units which keep one -/// decimal (`2.5G` vs `1.0 GiB` both read fine on an axis). -fn tick_label(v: f64) -> String { - format_count(v) +#[derive(Clone)] +struct ChartPt { + x: String, + y: f64, } -/// Top / middle / bottom labels. `ticks` from [`nice_scale`] is ascending. -fn pick_y_labels(ticks: &[f64]) -> Vec { - match ticks.len() { - 0 => Vec::new(), - 1 => vec![ticks[0]], - 2 => vec![ticks[1], ticks[0]], - n => vec![ticks[n - 1], ticks[n / 2], ticks[0]], - } +fn to_pts(points: &[SeriesPoint]) -> Vec { + points + .iter() + .map(|p| ChartPt { + x: format_time_of_day(p.t_ms), + y: p.v, + }) + .collect() } -/// The full chart element: title, plot area with grid lines, tick labels on -/// both axes, and one polyline per series. Empty series render the empty -/// frame; nothing panics on degenerate data. -pub fn line_chart(title: &str, unit: &str, series: Vec) -> impl IntoElement { - let t = Theme::for_appearance(current_appearance()); - let title = title.to_string(); - let unit = unit.to_string(); - - let title_el = div() - .text_size(px(12.0)) - .font_weight(FontWeight::SEMIBOLD) - .text_color(t.text) - .child(title); - - let unit_el = div() - .text_size(px(11.0)) - .text_color(t.text_faint) - .child(unit); - - let header = div() - .flex() - .flex_row() - .items_baseline() - .gap(px(8.0)) - .child(title_el) - .child(unit_el); - let axis_text_color = t.text_muted; - let line_color_muted = t.text_muted; - let line_color_accent = t.accent; - let axis_color = hairline(0.18); - let mono = mono_font(&t); - let font_size = px(AXIS_FONT_SIZE); - - // Fill the caller's height. A non-flex parent with only `h()` used to - // collapse the canvas, which stacked every y-label on ~40px of plot. - div() - .flex() - .flex_col() - .gap(px(6.0)) +/// Title + unit + a filled area (accent) or a line (muted). Empty series +/// still render the frame so layout does not jump. +pub fn line_chart(title: &str, unit: &str, series: Vec, cx: &App) -> impl IntoElement { + let title = SharedString::from(title.to_string()); + let unit = SharedString::from(unit.to_string()); + let primary = series.iter().find(|s| s.accent).or(series.first()); + let pts = primary.map(|s| to_pts(&s.points)).unwrap_or_default(); + let accent = primary.map(|s| s.accent).unwrap_or(true); + let color = if accent { + cx.theme().chart_1 + } else { + cx.theme().chart_2 + }; + let tick_margin = (pts.len() / 6).max(1); + let fill = linear_gradient( + 0., + linear_color_stop(color.opacity(0.4), 1.), + linear_color_stop(cx.theme().background.opacity(0.1), 0.), + ); + + v_flex() + .gap_1() .w_full() .h_full() - .min_h(px(120.0)) - .child(header) - .child(div().flex_1().min_h(px(80.0)).w_full().child(canvas( - move |bounds: GBounds, _window: &mut Window, _cx: &mut App| { - let w = - f64::from(bounds.size.width.as_f32()).max(PAD_LEFT + PAD_RIGHT + MIN_PLOT_W); - let h = - f64::from(bounds.size.height.as_f32()).max(PAD_TOP + PAD_BOTTOM + MIN_PLOT_H); - ChartLayout { - plot: Bounds { - x: PAD_LEFT, - y: PAD_TOP, - w: w - PAD_LEFT - PAD_RIGHT, - h: h - PAD_TOP - PAD_BOTTOM, - }, - } - }, - move |bounds: GBounds, - layout: ChartLayout, - window: &mut Window, - _cx: &mut App| { - let origin = bounds.origin; - let plot = layout.plot; - - let all_values: Vec = series - .iter() - .flat_map(|s| s.points.iter().map(|p| p.v)) - .collect(); - let (data_min, data_max) = if all_values.is_empty() { - (0.0, 1.0) - } else { - let mut lo = f64::INFINITY; - let mut hi = f64::NEG_INFINITY; - for v in &all_values { - if v.is_finite() { - lo = lo.min(*v); - hi = hi.max(*v); - } - } - if !lo.is_finite() || !hi.is_finite() { - (0.0, 1.0) - } else { - (lo, hi) - } - }; - - let (y_min, y_max, y_ticks) = nice_scale(data_min, data_max, 3); - let y_span = if y_max > y_min { y_max - y_min } else { 1.0 }; - - // Horizontal 1px strokes/quads tessellate into a ruled-notebook - // fill on this gpui Metal path. Skip them. A vertical axis is - // safe because Y varies. Three y-labels, right-aligned in the - // left gutter via WrappedLine's bounds (not a guessed wrap). - let mut axis = PathBuilder::stroke(px(1.0)); - axis.move_to(point( - origin.x + px(plot.x as f32), - origin.y + px(plot.y as f32), - )); - axis.line_to(point( - origin.x + px(plot.x as f32), - origin.y + px((plot.y + plot.h) as f32), - )); - if let Ok(path) = axis.build() { - window.paint_path(path, axis_color); - } - - let y_labels = pick_y_labels(&y_ticks); - for tick in y_labels { - let frac = (tick - y_min) / y_span; - let y = (plot.y + plot.h - frac * plot.h).clamp(plot.y, plot.y + plot.h); - let label = tick_label(tick); - let label_len = label.len(); - let gutter = GBounds:: { - origin: origin + point(px(2.0), px(y as f32) - px(AXIS_LINE_HEIGHT * 0.5)), - size: size( - px((plot.x - 8.0) as f32).max(px(24.0)), - px(AXIS_LINE_HEIGHT), - ), - }; - let shaped = window - .text_system() - .shape_text( - label.into(), - font_size, - &[TextRun { - len: label_len, - font: mono.clone(), - color: axis_text_color, - background_color: None, - underline: None, - strikethrough: None, - }], - Some(gutter.size.width), - Some(1), - ) - .ok(); - if let Some(mut lines) = shaped - && let Some(line) = lines.first_mut() - { - let _ = line.paint( - gutter.origin, - px(AXIS_LINE_HEIGHT), - TextAlign::Right, - Some(gutter), - window, - _cx, - ); - } - } - - let x_ticks = super::geometry::x_time_ticks( - series.first().map(|s| s.points.as_slice()).unwrap_or(&[]), - plot.w, - 5, - ); - for (t_ms, frac) in x_ticks { - if !(0.0..=1.0).contains(&frac) { - continue; - } - let x = plot.x + frac * plot.w; - let label = super::geometry::format_time_of_day(t_ms); - let shaped = window - .text_system() - .shape_text( - label.clone().into(), - font_size, - &[TextRun { - len: label.len(), - font: mono.clone(), - color: axis_text_color, - background_color: None, - underline: None, - strikethrough: None, - }], - None, - None, - ) - .ok(); - if let Some(mut lines) = shaped - && let Some(line) = lines.first_mut() - { - let label_w = px(48.0); - let box_bounds = GBounds:: { - origin: origin - + point( - px(x as f32) - label_w / 2.0, - px((plot.y + plot.h) as f32) + px(4.0), - ), - size: size(label_w, px(AXIS_LINE_HEIGHT)), - }; - let _ = line.paint( - box_bounds.origin, - px(AXIS_LINE_HEIGHT), - TextAlign::Center, - Some(box_bounds), - window, - _cx, - ); - } - } - - let px_points: Vec> = series - .iter() - .map(|s| points_to_px(&s.points, plot, y_min, y_max)) - .collect(); - for (s, pts) in series.iter().zip(px_points) { - if pts.len() < 2 { - continue; - } - let color: Hsla = if s.accent { - line_color_accent - } else { - line_color_muted - }; - let mut builder = PathBuilder::stroke(px(STROKE_WIDTH)); - let first = pts[0]; - builder.move_to(point( - origin.x + px(first.0 as f32), - origin.y + px(first.1 as f32), - )); - for p in &pts[1..] { - builder - .line_to(point(origin.x + px(p.0 as f32), origin.y + px(p.1 as f32))); - } - if let Ok(stroke_path) = builder.build() { - window.paint_path(stroke_path, color); - } - } - }, - ))) -} - -/// Baseline helper so callers can sanity-check a series against the axis the -/// chart would choose, without painting. -pub fn chart_axis_for(series: &[NamedSeries]) -> (f64, f64, Vec) { - let mut lo = f64::INFINITY; - let mut hi = f64::NEG_INFINITY; - for s in series { - for p in &s.points { - if p.v.is_finite() { - lo = lo.min(p.v); - hi = hi.max(p.v); + .min_h(px(120.)) + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .p_3() + .child( + h_flex() + .items_baseline() + .gap_2() + .child( + div() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().foreground) + .child(title), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(unit), + ), + ) + .child(div().flex_1().min_h(px(80.)).w_full().map(|el| { + if pts.is_empty() { + el.child( + div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("no data"), + ) + } else if accent { + el.child( + AreaChart::new(pts) + .x(|d| d.x.clone()) + .y(|d| d.y) + .stroke(color) + .fill(fill) + .linear() + .tick_margin(tick_margin), + ) + } else { + el.child( + LineChart::new(pts) + .x(|d| d.x.clone()) + .y(|d| d.y) + .stroke(color) + .linear() + .tick_margin(tick_margin), + ) } - } - } - if !lo.is_finite() || !hi.is_finite() { - return nice_scale(0.0, 1.0, 4); - } - nice_scale(lo, hi, 4) + })) } #[cfg(test)] @@ -313,13 +123,12 @@ mod tests { use super::*; #[test] - fn pick_y_labels_takes_top_mid_bottom() { - assert_eq!(pick_y_labels(&[]), Vec::::new()); - assert_eq!(pick_y_labels(&[3.0]), vec![3.0]); - assert_eq!(pick_y_labels(&[0.0, 10.0]), vec![10.0, 0.0]); - assert_eq!( - pick_y_labels(&[0.0, 5.0, 10.0, 15.0]), - vec![15.0, 10.0, 0.0] - ); + fn to_pts_uses_utc_clock_labels() { + let pts = to_pts(&[SeriesPoint { + t_ms: 3_600_000, + v: 1.5, + }]); + assert_eq!(pts[0].x, "01:00"); + assert_eq!(pts[0].y, 1.5); } } diff --git a/app/src/widgets/mod.rs b/app/src/widgets/mod.rs index 86e26fd..bd7823d 100644 --- a/app/src/widgets/mod.rs +++ b/app/src/widgets/mod.rs @@ -1,6 +1,5 @@ //! Reusable dashboard widgets: metric cards, line charts, data tables and -//! their shared geometry math. Owned by Agent E; all gpui types flow through -//! `bezel::gpui`. +//! their shared geometry math. pub mod cards; pub mod chart; diff --git a/app/src/widgets/table.rs b/app/src/widgets/table.rs index 8402259..669e5bf 100644 --- a/app/src/widgets/table.rs +++ b/app/src/widgets/table.rs @@ -1,10 +1,9 @@ -//! Data table: header row, zebra-striped body rows, monospace right-aligned -//! numerics. Cell values render through the [`geometry`](super::geometry) -//! formatters so units stay consistent across the app. +//! Data table: header row, body rows, right-aligned numerics. + +use gpui::{div, prelude::*, px}; +use gpui_component::table::{Table, TableBody, TableCell, TableHead, TableHeader, TableRow}; use super::geometry::{format_bytes, format_count, format_duration_ms}; -use bezel::gpui::{Div, Hsla, div, prelude::*, px}; -use bezel::theme::{Theme, current_appearance, hairline}; /// One cell's value; the variant picks the formatter and the alignment. #[derive(Debug, Clone, PartialEq)] @@ -19,123 +18,59 @@ pub enum CellVal { DurMs(f64), } -/// A column: header label plus an optional fixed width in logical pixels. -/// `None` lets the column flex with the container. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct Column { - pub name: String, - pub width: Option, -} - -/// Render one cell to a styled div. Public within the crate so pages can -/// reuse the exact cell styling inside custom rows. impl CellVal { - pub fn render(self) -> Div { - let t = Theme::for_appearance(current_appearance()); + fn display(&self) -> String { match self { - CellVal::Text(text) => div() - .text_size(px(11.0)) - .text_color(t.text) - .truncate() - .child(text), - CellVal::Num(n) => numeric_cell(format_count(n), t.text), - CellVal::Bytes(b) => numeric_cell(format_bytes(b), t.text), - CellVal::DurMs(ms) => numeric_cell(format_duration_ms(ms), t.text), + CellVal::Text(text) => text.clone(), + CellVal::Num(n) => format_count(*n), + CellVal::Bytes(b) => format_bytes(*b), + CellVal::DurMs(ms) => format_duration_ms(*ms), } } + + fn numeric(&self) -> bool { + !matches!(self, CellVal::Text(_)) + } } -fn numeric_cell(text: String, color: Hsla) -> Div { - let t = Theme::for_appearance(current_appearance()); - div() - .font_family(t.font_mono.clone()) - .text_size(px(11.0)) - .text_color(color) - .flex() - .justify_end() - .child(text) +/// A column: header label plus an optional fixed width in logical pixels. +/// `None` lets the column flex with the container. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct Column { + pub name: String, + pub width: Option, } -/// Header + striped rows. Rows may be ragged: short rows simply leave the +/// Header + rows. Rows may be ragged: short rows simply leave the /// trailing columns empty. pub fn data_table(columns: Vec, rows: Vec>) -> impl IntoElement { - let t = Theme::for_appearance(current_appearance()); - let border = t.border; - let stripe = hairline(0.03); - let muted = t.text_muted; - - let mut header_cells = Vec::with_capacity(columns.len()); - for col in &columns { - let mut cell = div().child( - div() - .text_size(px(10.0)) - .text_color(muted) - .truncate() - .child(col.name.clone()), - ); - match col.width { - Some(w) => cell = cell.w(px(w)).flex_none(), - None => cell = cell.flex_1(), + let header = TableHeader::new().child(TableRow::new().children(columns.iter().map(|col| { + let mut head = TableHead::new().child(col.name.clone()); + if let Some(w) = col.width { + head = head.w(px(w)); } - header_cells.push(cell); - } + head + }))); - let mut row_els = Vec::with_capacity(rows.len()); - for (ix, cells) in rows.iter().enumerate() { - let mut row = div() - .flex() - .flex_row() - .items_center() - .gap(px(12.0)) - .px(px(12.0)) - .py(px(6.0)); - if ix % 2 == 1 { - row = row.bg(stripe); - } - for (col_ix, value) in cells.iter().enumerate() { - let align_right = !matches!(value, CellVal::Text(_)); - let mut cell = value.clone().render(); - if align_right - && let Some(col) = columns.get(col_ix) - && col.width.is_none() - { - cell = cell.flex_1(); - } else if columns.get(col_ix).is_some_and(|c| c.width.is_some()) { - cell = cell.flex_none(); - if let Some(w) = columns.get(col_ix).and_then(|c| c.width) { - cell = cell.w(px(w)); + let body = TableBody::new().children(rows.into_iter().map(|cells| { + let cols = &columns; + TableRow::new().children((0..cols.len()).map(|i| { + let mut cell = match cells.get(i) { + Some(value) => { + let mut c = TableCell::new().child(value.display()); + if value.numeric() { + c = c.text_right(); + } + c } - } else { - cell = cell.flex_1(); + None => TableCell::new().child(div()), + }; + if let Some(w) = cols.get(i).and_then(|c| c.width) { + cell = cell.w(px(w)); } - row = row.child(cell); - } - for _ in cells.len()..columns.len() { - row = row.child(div().flex_1()); - } - row_els.push(row); - } + cell + })) + })); - div() - .w_full() - .overflow_hidden() - .border_1() - .border_color(border) - .rounded(px(Theme::PANEL_RADIUS)) - .flex() - .flex_col() - .child( - div() - .flex() - .flex_row() - .items_center() - .gap(px(12.0)) - .px(px(12.0)) - .py(px(8.0)) - .bg(hairline(0.04)) - .border_b_1() - .border_color(border) - .children(header_cells), - ) - .children(row_els) + Table::new().child(header).child(body) } From 01d89839c2fa6ac2ba0b92fed660258d8e5f93b6 Mon Sep 17 00:00:00 2001 From: duyetbot Date: Mon, 24 Aug 2026 19:08:47 +0700 Subject: [PATCH 10/20] feat(ui): own control presentation on gpui-base Add gpui-base and style its Button, Radio, Toggle, Switch, and Table primitives from theme tokens. Sidebar, charts, and theme stay on gpui-component. --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 4 +- app/Cargo.toml | 1 + app/src/connect.rs | 87 +++++++------------- app/src/lib.rs | 6 +- app/src/main.rs | 4 + app/src/pages/merges.rs | 2 +- app/src/pages/queries.rs | 2 + app/src/pages/replicas.rs | 2 +- app/src/pages/settings.rs | 155 +++++++++++++++++++----------------- app/src/pages/tables.rs | 2 +- app/src/shell.rs | 48 ++++++----- app/src/widgets/controls.rs | 147 ++++++++++++++++++++++++++++++++++ app/src/widgets/mod.rs | 1 + app/src/widgets/table.rs | 101 ++++++++++++++++------- 16 files changed, 375 insertions(+), 189 deletions(-) create mode 100644 app/src/widgets/controls.rs diff --git a/Cargo.lock b/Cargo.lock index 28ffd1e..e3b8497 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -952,6 +952,7 @@ dependencies = [ "chrono", "dirs", "gpui", + "gpui-base", "gpui-component", "gpui-component-assets", "gpui_platform", diff --git a/Cargo.toml b/Cargo.toml index a1b62d8..bf65205 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ chm-telemetry = { path = "crates/chm-telemetry" } # precompiled path when Xcode is installed — drop this feature then. gpui = { git = "https://github.com/zed-industries/zed" } gpui_platform = { git = "https://github.com/zed-industries/zed", features = ["font-kit", "x11", "runtime_shaders"] } +gpui-base = { git = "https://github.com/longbridge/gpui-component" } gpui-component = { git = "https://github.com/longbridge/gpui-component" } gpui-component-assets = { git = "https://github.com/longbridge/gpui-component" } diff --git a/README.md b/README.md index f89fe2b..ae5d9fb 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # chmonitor Desktop -GPUI + [gpui-component](https://longbridge.github.io/gpui-component/) desktop client for [chmonitor](https://chmonitor.dev) — ClickHouse +GPUI desktop client for [chmonitor](https://chmonitor.dev) — ClickHouse monitoring for macOS and Linux, with two connection modes: 1. **Cloud / dashboard endpoint** — talks to `dash.chmonitor.dev` or any @@ -61,7 +61,7 @@ keys `1`–`8` switch sidebar destinations; `cmd-b` toggles the sidebar; | `crates/chm-postgres` | mode 3: direct Postgres (`pg_stat_*`) | | `crates/chm-update` | channel-aware update checker (stable/beta) | | `crates/chm-telemetry` | opt-in telemetry + perf metrics | -| `app/` | GPUI + gpui-component UI | +| `app/` | GPUI UI: [gpui-base](https://longbridge.github.io/gpui-component/base/getting-started.md) primitives (buttons, radios, tables) + [gpui-component](https://longbridge.github.io/gpui-component/) for sidebar, charts, theme | | `.github/workflows/` | CI: lint, test, build matrix, releases | ## Testing diff --git a/app/Cargo.toml b/app/Cargo.toml index 23ff524..f28c171 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -13,6 +13,7 @@ chm-update.workspace = true chm-telemetry.workspace = true gpui.workspace = true gpui_platform.workspace = true +gpui-base.workspace = true gpui-component.workspace = true gpui-component-assets.workspace = true anyhow.workspace = true diff --git a/app/src/connect.rs b/app/src/connect.rs index 06fe7cd..82664fc 100644 --- a/app/src/connect.rs +++ b/app/src/connect.rs @@ -5,13 +5,13 @@ use gpui::{ Window, div, prelude::*, px, }; use gpui_component::{ - ActiveTheme as _, - button::{Button, ButtonVariants as _}, - h_flex, + ActiveTheme as _, h_flex, input::{Input, InputState}, v_flex, }; +use crate::widgets::controls::{choice_radio, ghost_button, primary_button, radio_group}; + use crate::config::{ DEFAULT_HOST_ID, ProfileConfig, host_id_from_name, load_config, save_config, source_from_profile, @@ -243,48 +243,18 @@ impl ConnectFlow { cx: &mut Context, ) -> impl IntoElement { let selected = self.mode == mode; - div() - .id(SharedString::from(format!("mode-{label}"))) - .flex() - .flex_row() - .items_center() - .gap(px(10.)) - .px(px(12.)) - .py(px(10.)) - .rounded(cx.theme().radius) - .border_1() - .border_color(if selected { - cx.theme().primary - } else { - cx.theme().border - }) - .bg(if selected { - cx.theme().accent - } else { - cx.theme().background - }) - .cursor_pointer() - .on_click(cx.listener(move |this, _, _, cx| { - this.mode = mode; - this.test = TestState::Idle; - cx.notify(); - })) - .child( - div() - .size(px(14.)) - .rounded_full() - .border_1() - .border_color(cx.theme().primary) - .when(selected, |dot| dot.bg(cx.theme().primary)), - ) - .child( - v_flex().gap_1().child(div().text_sm().child(label)).child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(hint), - ), - ) + let entity = cx.entity().downgrade(); + choice_radio(format!("mode-{label}"), selected, label, hint, cx).on_change( + move |next, _, _, cx| { + if next { + let _ = entity.update(cx, |this, cx| { + this.mode = mode; + this.test = TestState::Idle; + cx.notify(); + }); + } + }, + ) } fn field_row(label: &'static str, field: &Entity) -> impl IntoElement { @@ -344,8 +314,7 @@ impl Render for ConnectFlow { ), ) .child( - v_flex() - .gap_2() + radio_group("connect-mode") .child(self.mode_row( "Cloud", "chmonitor-hosted dashboard API · base URL + API key", @@ -368,21 +337,21 @@ impl Render for ConnectFlow { .child(fields) .child(Self::field_row("Name", &self.name)) .child(self.status_line(cx)) - .child( + .child({ + let entity = cx.entity().downgrade(); h_flex() .gap_2() + .child(ghost_button("test", "Test", cx).on_click({ + let entity = entity.clone(); + move |_, _, cx| { + let _ = entity.update(cx, |this, cx| this.run_test(cx)); + } + })) .child( - Button::new("test") - .ghost() - .label("Test") - .on_click(cx.listener(|this, _, _, cx| this.run_test(cx))), + primary_button("save", "Save", cx).on_click(move |_, _, cx| { + let _ = entity.update(cx, |this, cx| this.save(cx)); + }), ) - .child( - Button::new("save") - .primary() - .label("Save") - .on_click(cx.listener(|this, _, _, cx| this.save(cx))), - ), - ) + }) } } diff --git a/app/src/lib.rs b/app/src/lib.rs index be4e020..622981e 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -1,4 +1,8 @@ -//! chm-app — GPUI + gpui-component desktop client for chmonitor. +//! chm-app — GPUI desktop client for chmonitor. +//! +//! Presentation for buttons, radios, toggles, and tables is owned here on +//! top of `gpui-base`. Sidebar, charts, inputs, and theme come from +//! `gpui-component`. pub mod config; pub mod connect; diff --git a/app/src/main.rs b/app/src/main.rs index 0f8e3bd..1259c13 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -38,6 +38,10 @@ fn main() { gpui_platform::application() .with_assets(gpui_component_assets::Assets) .run(|cx: &mut App| { + // gpui_component::init includes gpui_base::init (theme tokens, + // scrollbars, focus). Application-owned controls are gpui-base + // primitives styled from the theme; charts/sidebar stay on the + // styled façade. gpui_component::init(cx); cx.bind_keys([ KeyBinding::new("cmd-,", OpenSettings, None), diff --git a/app/src/pages/merges.rs b/app/src/pages/merges.rs index 9f8d30e..a269a1a 100644 --- a/app/src/pages/merges.rs +++ b/app/src/pages/merges.rs @@ -97,6 +97,6 @@ impl Render for MergesPage { ] }) .collect(); - data_table(columns, body).into_any_element() + data_table("merges", columns, body, cx).into_any_element() } } diff --git a/app/src/pages/queries.rs b/app/src/pages/queries.rs index 05f2fd8..f755118 100644 --- a/app/src/pages/queries.rs +++ b/app/src/pages/queries.rs @@ -122,8 +122,10 @@ fn section( None => status("loading…", cx).into_any_element(), Some(rows) if rows.is_empty() => status("none", cx).into_any_element(), Some(rows) => data_table( + format!("queries-{title}"), query_columns(with_exception), query_rows(rows, with_exception), + cx, ) .into_any_element(), }; diff --git a/app/src/pages/replicas.rs b/app/src/pages/replicas.rs index f3a6818..67db00e 100644 --- a/app/src/pages/replicas.rs +++ b/app/src/pages/replicas.rs @@ -109,6 +109,6 @@ impl Render for ReplicasPage { ] }) .collect(); - data_table(columns, body).into_any_element() + data_table("replicas", columns, body, cx).into_any_element() } } diff --git a/app/src/pages/settings.rs b/app/src/pages/settings.rs index 9907acd..9d19748 100644 --- a/app/src/pages/settings.rs +++ b/app/src/pages/settings.rs @@ -4,14 +4,11 @@ use chm_update::Channel; use gpui::{App, Context, Render, Window, div, prelude::*, px}; -use gpui_component::{ - ActiveTheme as _, Theme, ThemeMode, - radio::{Radio, RadioGroup}, - v_flex, -}; +use gpui_component::{ActiveTheme as _, Theme, ThemeMode, h_flex, v_flex}; use crate::config::{config_path, load_config, save_config}; use crate::pages::heading; +use crate::widgets::controls::{choice_radio, radio_group, theme_switch}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Appearance { @@ -23,14 +20,6 @@ pub enum Appearance { impl Appearance { const ALL: [Appearance; 3] = [Appearance::System, Appearance::Light, Appearance::Dark]; - fn index(self) -> usize { - Self::ALL.iter().position(|&m| m == self).unwrap_or(0) - } - - fn from_index(i: usize) -> Self { - Self::ALL.get(i).copied().unwrap_or(Appearance::System) - } - fn label(self) -> &'static str { match self { Self::System => "System", @@ -113,76 +102,94 @@ impl Render for SettingsPage { .gap_5() .max_w(px(520.)) .child(heading("Appearance")) - .child( - RadioGroup::vertical("appearance") - .children(Appearance::ALL.iter().map(|&mode| { - Radio::new(mode.label()).label(mode.label()).child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(mode.hint()), + .child({ + let entity = cx.entity().downgrade(); + let mut group = radio_group("appearance"); + for &mode in &Appearance::ALL { + let entity = entity.clone(); + group = group.child( + choice_radio( + format!("app-{}", appearance_to_cfg(mode)), + appearance == mode, + mode.label(), + mode.hint(), + cx, ) - })) - .selected_index(Some(appearance.index())) - .on_click(cx.listener(|this, index: &usize, window, cx| { - this.set_appearance(Appearance::from_index(*index), window, cx); - })), - ) + .on_change(move |next, _, window, cx| { + if next { + let _ = entity.update(cx, |this, cx| { + this.set_appearance(mode, window, cx); + }); + } + }), + ); + } + group + }) .child(heading("Updates")) - .child( - RadioGroup::vertical("channel") + .child({ + let entity = cx.entity().downgrade(); + radio_group("channel") .child( - Radio::new("stable").label("Stable").child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("tagged releases"), - ), + choice_radio( + "ch-stable", + channel == Channel::Stable, + "Stable", + "tagged releases", + cx, + ) + .on_change({ + let entity = entity.clone(); + move |next, _, _, cx| { + if next { + let _ = entity.update(cx, |this, cx| { + this.set_channel(Channel::Stable, cx); + }); + } + } + }), ) .child( - Radio::new("beta").label("Beta").child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("pre-release builds"), - ), - ) - .selected_index(Some(if channel == Channel::Stable { 0 } else { 1 })) - .on_click(cx.listener(|this, index: &usize, _, cx| { - this.set_channel( - if *index == 0 { - Channel::Stable - } else { - Channel::Beta - }, + choice_radio( + "ch-beta", + channel == Channel::Beta, + "Beta", + "pre-release builds", cx, - ); - })), - ) - .child(heading("Telemetry")) - .child( - RadioGroup::vertical("telemetry") - .child( - Radio::new("off").label("Off").child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("nothing is recorded or sent (default)"), - ), + ) + .on_change(move |next, _, _, cx| { + if next { + let _ = entity.update(cx, |this, cx| { + this.set_channel(Channel::Beta, cx); + }); + } + }), ) + }) + .child(heading("Telemetry")) + .child({ + let entity = cx.entity().downgrade(); + h_flex() + .items_center() + .justify_between() + .gap_3() .child( - Radio::new("on").label("On").child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("local fetch timings only; no query text"), - ), + v_flex() + .gap_1() + .child(div().text_sm().child("Local timings")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("fetch timings only; no query text (off by default)"), + ), ) - .selected_index(Some(if telemetry { 1 } else { 0 })) - .on_click(cx.listener(|this, index: &usize, _, cx| { - this.set_telemetry(*index == 1, cx); - })), - ) + .child(theme_switch("telemetry", telemetry, cx).on_change( + move |next, _, _, cx| { + let _ = entity.update(cx, |this, cx| this.set_telemetry(next, cx)); + }, + )) + }) .child(heading("Shortcuts")) .child( v_flex() diff --git a/app/src/pages/tables.rs b/app/src/pages/tables.rs index eeb6e5d..26b6c7f 100644 --- a/app/src/pages/tables.rs +++ b/app/src/pages/tables.rs @@ -102,6 +102,6 @@ impl Render for TablesPage { ] }) .collect(); - data_table(columns, body).into_any_element() + data_table("tables", columns, body, cx).into_any_element() } } diff --git a/app/src/shell.rs b/app/src/shell.rs index 55b49e8..a652f64 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -14,9 +14,7 @@ use gpui::{ KeyDownEvent, Render, SharedString, WeakEntity, Window, actions, div, prelude::*, px, }; use gpui_component::{ - ActiveTheme as _, IconName, Root, Sizable as _, - button::{Button, ButtonVariants as _}, - h_flex, + ActiveTheme as _, IconName, Root, h_flex, sidebar::{ Sidebar, SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarToggleButton, @@ -572,24 +570,32 @@ impl Shell { } fn range_bar(&self, cx: &mut Context) -> impl IntoElement { - h_flex() - .items_center() - .gap_1() - .children(TimeRange::ALL.into_iter().map(|range| { - let active = self.range == range; - Button::new(SharedString::from(format!("range-{}", range.label()))) - .xsmall() - .when(active, |b| b.primary()) - .when(!active, |b| b.ghost()) - .label(range.label()) - .on_click(cx.listener(move |this, _, _, cx| { - if this.range != range { - this.range = range; - this.refresh_now(cx); - cx.notify(); - } - })) - })) + let entity = cx.entity().downgrade(); + let mut group = crate::widgets::controls::range_group("time-range"); + for range in TimeRange::ALL { + let entity = entity.clone(); + let pressed = self.range == range; + group = group.child( + crate::widgets::controls::range_toggle( + format!("range-{}", range.label()), + pressed, + range.label(), + cx, + ) + .on_change(move |next, _, _, cx| { + if next { + let _ = entity.update(cx, |this, cx| { + if this.range != range { + this.range = range; + this.refresh_now(cx); + cx.notify(); + } + }); + } + }), + ); + } + group } fn status_bar(&self, cx: &Context) -> impl IntoElement { diff --git a/app/src/widgets/controls.rs b/app/src/widgets/controls.rs new file mode 100644 index 0000000..888b839 --- /dev/null +++ b/app/src/widgets/controls.rs @@ -0,0 +1,147 @@ +//! Application-owned presentation on gpui-base primitives. +//! +//! Base owns focus, keyboard, and accessibility. Theme tokens and layout +//! stay here so the product is not locked to gpui-component's default look. + +use gpui::{App, ElementId, SharedString, div, prelude::*, px, relative}; +use gpui_base::{Button, Radio, RadioGroup, Switch, SwitchThumb, SwitchTrack, Toggle, ToggleGroup}; +use gpui_component::ActiveTheme as _; + +pub fn primary_button( + id: impl Into, + label: impl Into, + cx: &App, +) -> Button { + Button::new(id) + .px_3() + .h_8() + .flex() + .items_center() + .rounded(cx.theme().radius) + .bg(cx.theme().primary) + .text_color(cx.theme().primary_foreground) + .hover(|s| s.opacity(0.9)) + .child(label.into()) +} + +pub fn ghost_button(id: impl Into, label: impl Into, cx: &App) -> Button { + Button::new(id) + .px_3() + .h_8() + .flex() + .items_center() + .rounded(cx.theme().radius) + .border_1() + .border_color(cx.theme().border) + .hover(|s| s.bg(cx.theme().accent)) + .child(label.into()) +} + +/// A labeled radio option with an optional hint line. +pub fn choice_radio( + id: impl Into, + checked: bool, + label: impl Into, + hint: impl Into, + cx: &App, +) -> Radio { + let primary = cx.theme().primary; + let muted = cx.theme().muted_foreground; + Radio::new(id) + .checked(checked) + .flex() + .items_start() + .gap_2() + .px_3() + .py_2() + .rounded(cx.theme().radius) + .border_1() + .border_color(if checked { + cx.theme().primary + } else { + cx.theme().border + }) + .bg(if checked { + cx.theme().accent + } else { + cx.theme().background + }) + .child( + div() + .mt(px(2.)) + .flex() + .items_center() + .justify_center() + .size(px(14.)) + .rounded_full() + .border_1() + .border_color(primary) + .when(checked, |dot| { + dot.child(div().size(px(6.)).rounded_full().bg(primary)) + }), + ) + .child( + div() + .flex() + .flex_col() + .gap_1() + .child(div().text_sm().child(label.into())) + .child(div().text_xs().text_color(muted).child(hint.into())), + ) +} + +pub fn radio_group(id: impl Into) -> RadioGroup { + RadioGroup::new(id).flex().flex_col().gap_2() +} + +pub fn range_toggle( + id: impl Into, + pressed: bool, + label: impl Into, + cx: &App, +) -> Toggle { + Toggle::new(id) + .pressed(pressed) + .px_2() + .h_7() + .flex() + .items_center() + .justify_center() + .text_xs() + .line_height(relative(1.)) + .rounded(cx.theme().radius) + .when(pressed, |t| { + t.bg(cx.theme().primary) + .text_color(cx.theme().primary_foreground) + }) + .when(!pressed, |t| t.text_color(cx.theme().muted_foreground)) + .hover(|s| s.bg(cx.theme().accent)) + .child(label.into()) +} + +pub fn range_group(id: impl Into) -> ToggleGroup { + ToggleGroup::new(id).flex().items_center().gap_1() +} + +/// Compact on/off switch styled from theme tokens. +pub fn theme_switch(id: impl Into, checked: bool, cx: &App) -> Switch { + let on = cx.theme().primary; + let off = cx.theme().border; + let thumb = cx.theme().background; + Switch::new(id).checked(checked).child( + SwitchTrack::new("switch-track") + .checked(checked) + .w(px(36.)) + .h(px(20.)) + .p(px(2.)) + .rounded_full() + .bg(if checked { on } else { off }) + .child( + SwitchThumb::new(checked) + .size_4() + .rounded_full() + .bg(thumb) + .ml(if checked { px(16.) } else { px(0.) }), + ), + ) +} diff --git a/app/src/widgets/mod.rs b/app/src/widgets/mod.rs index bd7823d..dbb3be3 100644 --- a/app/src/widgets/mod.rs +++ b/app/src/widgets/mod.rs @@ -3,6 +3,7 @@ pub mod cards; pub mod chart; +pub mod controls; pub mod geometry; pub mod table; diff --git a/app/src/widgets/table.rs b/app/src/widgets/table.rs index 669e5bf..0698c37 100644 --- a/app/src/widgets/table.rs +++ b/app/src/widgets/table.rs @@ -1,7 +1,9 @@ //! Data table: header row, body rows, right-aligned numerics. +//! Semantic structure comes from gpui-base; colors and density from the theme. -use gpui::{div, prelude::*, px}; -use gpui_component::table::{Table, TableBody, TableCell, TableHead, TableHeader, TableRow}; +use gpui::{App, ElementId, div, prelude::*, px}; +use gpui_base::{Table, TableBody, TableCell, TableHead, TableHeader, TableRow}; +use gpui_component::ActiveTheme as _; use super::geometry::{format_bytes, format_count, format_duration_ms}; @@ -43,34 +45,75 @@ pub struct Column { /// Header + rows. Rows may be ragged: short rows simply leave the /// trailing columns empty. -pub fn data_table(columns: Vec, rows: Vec>) -> impl IntoElement { - let header = TableHeader::new().child(TableRow::new().children(columns.iter().map(|col| { - let mut head = TableHead::new().child(col.name.clone()); - if let Some(w) = col.width { - head = head.w(px(w)); - } - head - }))); +pub fn data_table( + id: impl Into, + columns: Vec, + rows: Vec>, + cx: &App, +) -> impl IntoElement { + let id = id.into(); + let border = cx.theme().border; + let muted = cx.theme().muted_foreground; + let header_bg = cx.theme().secondary; + let n_cols = columns.len(); + let n_rows = rows.len(); - let body = TableBody::new().children(rows.into_iter().map(|cells| { - let cols = &columns; - TableRow::new().children((0..cols.len()).map(|i| { - let mut cell = match cells.get(i) { - Some(value) => { - let mut c = TableCell::new().child(value.display()); - if value.numeric() { - c = c.text_right(); - } - c - } - None => TableCell::new().child(div()), - }; - if let Some(w) = cols.get(i).and_then(|c| c.width) { - cell = cell.w(px(w)); + let header = TableHeader::new("header").child(TableRow::new("header-row", 1).flex().children( + columns.iter().enumerate().map(|(i, col)| { + let mut head = TableHead::new(("head", i), i + 1) + .px_3() + .py_2() + .text_xs() + .text_color(muted) + .child(col.name.clone()); + if let Some(w) = col.width { + head = head.w(px(w)).flex_none(); + } else { + head = head.flex_1(); } - cell - })) - })); + head + }), + )); + + let body = + TableBody::new("body").children(rows.into_iter().enumerate().map(|(row_ix, cells)| { + let cols = &columns; + TableRow::new(("row", row_ix), row_ix + 2) + .flex() + .border_t_1() + .border_color(border) + .children((0..cols.len()).map(|i| { + let mut cell = match cells.get(i) { + Some(value) => { + let mut c = TableCell::new(format!("cell-{row_ix}-{i}"), i + 1) + .px_3() + .py_1() + .text_xs() + .child(value.display()); + if value.numeric() { + c = c.text_right(); + } + c + } + None => TableCell::new(format!("empty-{row_ix}-{i}"), i + 1).child(div()), + }; + if let Some(w) = cols.get(i).and_then(|c| c.width) { + cell = cell.w(px(w)).flex_none(); + } else { + cell = cell.flex_1(); + } + cell + })) + })); - Table::new().child(header).child(body) + Table::new(id) + .w_full() + .overflow_hidden() + .border_1() + .border_color(border) + .rounded(cx.theme().radius) + .row_count(n_rows + 1) + .column_count(n_cols) + .child(header.bg(header_bg)) + .child(body) } From b60e2ccace15348ad6b3e7ba8e1b30e55a7f9884 Mon Sep 17 00:00:00 2001 From: duyetbot Date: Mon, 24 Aug 2026 19:17:43 +0700 Subject: [PATCH 11/20] feat(macos): package .app with brand icon and auto-update Add the chmonitor avatar as AppIcon.icns, scripts/build-macos.sh to emit dist/macos/chmonitor.app, and an [update] config (check on launch, optional auto-download). The status bar installs over the running bundle; manifests now hash the downloadable archive. --- .github/workflows/release.yml | 38 ++----- .gitignore | 1 + Cargo.lock | 1 + README.md | 7 +- app/src/config.rs | 49 +++++++++ app/src/lib.rs | 1 + app/src/pages/settings.rs | 66 ++++++++++++ app/src/shell.rs | 188 ++++++++++++++++++++++++++++------ app/src/updater.rs | 124 ++++++++++++++++++++++ assets/icon/icon-1024.png | Bin 0 -> 61008 bytes assets/icon/logo.svg | 5 + crates/chm-update/Cargo.toml | 1 + crates/chm-update/src/lib.rs | 172 ++++++++++++++++++++++++++++++- scripts/build-macos.sh | 135 ++++++++++++++++++++++++ 14 files changed, 721 insertions(+), 67 deletions(-) create mode 100644 app/src/updater.rs create mode 100644 assets/icon/icon-1024.png create mode 100644 assets/icon/logo.svg create mode 100755 scripts/build-macos.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cae8fe..ca31918 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -191,42 +191,26 @@ jobs: VERSION: ${{ needs.channel.outputs.version }} run: | set -euo pipefail - APP_BUNDLE="$APP_NAME.app" - mkdir -p "$APP_BUNDLE/Contents/MacOS" "$APP_BUNDLE/Contents/Resources" - cp "target/$TARGET/$PROFILE/$BIN_NAME" "$APP_BUNDLE/Contents/MacOS/$BIN_NAME" - chmod +x "$APP_BUNDLE/Contents/MacOS/$BIN_NAME" - cat > "$APP_BUNDLE/Contents/Info.plist" < - - - - CFBundleName$APP_NAME - CFBundleDisplayName$APP_NAME - CFBundleIdentifierio.chmonitor.desktop - CFBundleVersion$VERSION - CFBundleShortVersionString$VERSION - CFBundleExecutable$BIN_NAME - CFBundlePackageTypeAPPL - NSHighResolutionCapable - - - PLIST - printf 'chmonitor %s (%s)\n' "$TAG" "$TARGET" > "$APP_BUNDLE/Contents/Resources/README.txt" - zip -qry "chmonitor-$TAG-$TARGET.zip" "$APP_BUNDLE" - rm -rf "$APP_BUNDLE" + chmod +x scripts/build-macos.sh + APP="$(scripts/build-macos.sh --bin "target/$TARGET/$PROFILE/$BIN_NAME" --version "$VERSION" --out "$PWD/dist/macos")" + ditto -c -k --sequesterRsrc --keepParent "$APP" "chmonitor-$TAG-$TARGET.zip" - # SHA256 of the raw packaged binary feeds the update manifests; - # PKG is the archive attached to the release. - - name: Binary sha256 + package name + # SHA256 of the downloadable archive (zip/tar.gz) feeds the update + # manifests so the in-app downloader can verify what it fetched. + - name: Archive sha256 + package name id: meta env: TAG: ${{ needs.channel.outputs.tag }} run: | set -euo pipefail - SHA="$(sha256sum "target/$TARGET/$PROFILE/$BIN_NAME" | cut -d' ' -f1)" if [ "${{ runner.os }}" = "Linux" ]; then EXT=tar.gz; else EXT=zip; fi PKG="chmonitor-$TAG-$TARGET.$EXT" test -f "$PKG" + if command -v sha256sum >/dev/null; then + SHA="$(sha256sum "$PKG" | cut -d' ' -f1)" + else + SHA="$(shasum -a 256 "$PKG" | cut -d' ' -f1)" + fi echo "sha256=$SHA" >> "$GITHUB_OUTPUT" echo "package=$PKG" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index 26e7c26..36c857e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +/dist Cargo.lock.orig shots/ *.log diff --git a/Cargo.lock b/Cargo.lock index e3b8497..4305dc0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1044,6 +1044,7 @@ dependencies = [ "semver", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.20", "tokio", "tracing", diff --git a/README.md b/README.md index ae5d9fb..512c84b 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ monitoring for macOS and Linux, with two connection modes: ```sh cargo build -p chm-app # debug cargo build --release -p chm-app # release (LTO, stripped) +scripts/build-macos.sh # dist/macos/chmonitor.app (icon + Info.plist) ``` ### macOS @@ -84,6 +85,8 @@ are rejected by the release pipeline. - `stable` — tagged releases via release-please. - `beta` — pre-release builds (tag suffix `-beta.N`); the in-app update checker follows the channel baked into the profile. -- Auto-update: in-app check + download prompt (chm-update); update manifests - are emitted per release and attached alongside signed archives. +- Auto-update: `[update]` in `config.toml` (check on launch by default; + `auto_download` fetches the archive). The status bar shows the version and + installs over `chmonitor.app` when you are running from the bundle. + Manifests are `{base}/{channel}.json` (`CHM_UPDATE_URL` overrides the host). diff --git a/app/src/config.rs b/app/src/config.rs index c889aef..a2386a6 100644 --- a/app/src/config.rs +++ b/app/src/config.rs @@ -61,6 +61,33 @@ pub struct UiSection { pub host: Option, } +fn default_true() -> bool { + true +} + +/// `[update]` table — launch check and optional auto-download. +/// +/// Channel still lives on `[profile].channel` (`stable` / `beta`). +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct UpdateSection { + /// Check for a newer build on launch. Default true. + #[serde(default = "default_true")] + pub enabled: bool, + /// Download the archive when a newer build is found. Default false + /// (status bar shows the version; click to fetch/install). + #[serde(default)] + pub auto_download: bool, +} + +impl Default for UpdateSection { + fn default() -> Self { + Self { + enabled: true, + auto_download: false, + } + } +} + /// Whole `config.toml`. #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] pub struct ConfigFile { @@ -73,6 +100,8 @@ pub struct ConfigFile { pub telemetry: TelemetrySection, #[serde(default)] pub ui: UiSection, + #[serde(default)] + pub update: UpdateSection, } /// `/chmonitor/config.toml`, or `CHM_CONFIG` when set. @@ -313,6 +342,11 @@ Environment: CHM_PROFILE= Load [profiles.] from config.toml CHM_CONFIG= Override config.toml path CHM_UPDATE_URL= Override update manifest base + +Config (`config.toml`): + [update] + enabled = true # check on launch (default) + auto_download = false # fetch the archive without a click "; impl Cli { @@ -497,11 +531,26 @@ user = "alice" cfg.ui.appearance = Some("light".into()); cfg.profile.channel = Some("beta".into()); cfg.telemetry.enabled = true; + cfg.update.auto_download = true; save_config_to(&path, &cfg).unwrap(); let back = load_config_from(&path); assert_eq!(back.ui.appearance.as_deref(), Some("light")); assert_eq!(back.profile.channel.as_deref(), Some("beta")); assert!(back.telemetry.enabled); + assert!(back.update.enabled); + assert!(back.update.auto_download); + } + + #[test] + fn update_section_defaults_to_enabled() { + let cfg: ConfigFile = toml::from_str("").unwrap(); + assert!(cfg.update.enabled); + assert!(!cfg.update.auto_download); + let cfg: ConfigFile = toml::from_str("[update]\nauto_download = true\n").unwrap(); + assert!(cfg.update.enabled); + assert!(cfg.update.auto_download); + let cfg: ConfigFile = toml::from_str("[update]\nenabled = false\n").unwrap(); + assert!(!cfg.update.enabled); } #[test] diff --git a/app/src/lib.rs b/app/src/lib.rs index 622981e..b35c81c 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -8,4 +8,5 @@ pub mod config; pub mod connect; pub mod pages; pub mod shell; +pub mod updater; pub mod widgets; diff --git a/app/src/pages/settings.rs b/app/src/pages/settings.rs index 9d19748..7808af9 100644 --- a/app/src/pages/settings.rs +++ b/app/src/pages/settings.rs @@ -41,6 +41,8 @@ pub struct SettingsPage { appearance: Appearance, channel: Channel, telemetry: bool, + update_enabled: bool, + auto_download: bool, status: Option, } @@ -57,6 +59,8 @@ impl SettingsPage { appearance: appearance_from_cfg(cfg.ui.appearance.as_deref()), channel: channel_from_cfg(cfg.profile.channel.as_deref()), telemetry: cfg.telemetry.enabled, + update_enabled: cfg.update.enabled, + auto_download: cfg.update.auto_download, status: None, } } @@ -66,6 +70,8 @@ impl SettingsPage { cfg.ui.appearance = Some(appearance_to_cfg(self.appearance).into()); cfg.profile.channel = Some(self.channel.as_str().into()); cfg.telemetry.enabled = self.telemetry; + cfg.update.enabled = self.update_enabled; + cfg.update.auto_download = self.auto_download; self.status = save_config(&cfg).err(); } @@ -87,6 +93,18 @@ impl SettingsPage { self.persist(); cx.notify(); } + + fn set_update_enabled(&mut self, enabled: bool, cx: &mut Context) { + self.update_enabled = enabled; + self.persist(); + cx.notify(); + } + + fn set_auto_download(&mut self, enabled: bool, cx: &mut Context) { + self.auto_download = enabled; + self.persist(); + cx.notify(); + } } impl Render for SettingsPage { @@ -97,6 +115,8 @@ impl Render for SettingsPage { let appearance = self.appearance; let channel = self.channel; let telemetry = self.telemetry; + let update_enabled = self.update_enabled; + let auto_download = self.auto_download; v_flex() .gap_5() @@ -127,6 +147,52 @@ impl Render for SettingsPage { group }) .child(heading("Updates")) + .child({ + let entity = cx.entity().downgrade(); + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child(div().text_sm().child("Check on launch")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("fetch the channel manifest from updates.chmonitor.dev"), + ), + ) + .child(theme_switch("upd-enabled", update_enabled, cx).on_change( + move |next, _, _, cx| { + let _ = entity.update(cx, |this, cx| this.set_update_enabled(next, cx)); + }, + )) + }) + .child({ + let entity = cx.entity().downgrade(); + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child(div().text_sm().child("Download automatically")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("save the archive when a newer build is found"), + ), + ) + .child(theme_switch("upd-auto", auto_download, cx).on_change( + move |next, _, _, cx| { + let _ = entity.update(cx, |this, cx| this.set_auto_download(next, cx)); + }, + )) + }) .child({ let entity = cx.entity().downgrade(); radio_group("channel") diff --git a/app/src/shell.rs b/app/src/shell.rs index a652f64..2f67879 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -37,6 +37,8 @@ use crate::pages::replicas::ReplicasPage; use crate::pages::settings::SettingsPage; use crate::pages::tables::TablesPage; use crate::pages::traffic::TrafficPage; +use crate::updater; +use crate::widgets::controls::ghost_button; actions!(chm_shell, [Refresh, ToggleSidebar, OpenSettings]); @@ -76,9 +78,21 @@ impl ConnState { } } -/// Result of the one-shot startup update check. +/// Result of the one-shot startup update check / download. #[derive(Debug, Clone)] -struct UpdateNote(SharedString); +enum UpdateUi { + Disabled, + Checking, + Idle, + Silent, + Available(chm_update::ReleaseInfo), + Downloading(String), + Ready { + version: String, + archive: std::path::PathBuf, + }, + Failed(String), +} /// Glanceable facts about the active host (status bar). #[derive(Debug, Clone, Default)] @@ -108,7 +122,7 @@ pub struct Shell { traffic: Entity, connect: Entity, settings: Entity, - update_note: Option, + update: UpdateUi, /// `None` follows the viewport; `Some` is a click/`cmd-b` override. sidebar_collapsed: Option, active_host: Option, @@ -165,31 +179,10 @@ impl Shell { let _cfg = chm_telemetry::TelemetryConfig::default().set_enabled(true); } - // One-shot update check, background thread. Failures are silent unless - // CHM_UPDATE_URL overrides the manifest base (chm_update semantics). - let channel = match load_profile().and_then(|p| p.channel).as_deref() { - Some("beta") => chm_update::Channel::Beta, - _ => chm_update::Channel::Stable, - }; - cx.spawn(async move |this, cx| { - let checker = chm_update::UpdateChecker::production(); - let current = semver::Version::parse(env!("CARGO_PKG_VERSION")) - .unwrap_or_else(|_| semver::Version::new(0, 1, 1)); - let note = match chm_core::tokio_block_on(checker.check(channel, ¤t)) { - Ok(Some(release)) => { - UpdateNote(format!("update available: v{}", release.version()).into()) - } - Ok(None) => UpdateNote("up to date".into()), - // Keep the status bar from sitting on "update check…" forever - // when the manifest host is unreachable. - Err(_) => UpdateNote(SharedString::default()), - }; - let _ = this.update(cx, |shell, cx| { - shell.update_note = Some(note); - cx.notify(); - }); - }) - .detach(); + let update_cfg = load_config().update; + if update_cfg.enabled { + Self::spawn_update_check(update_cfg.auto_download, cx); + } let force_connect = cli().connect; let page = if force_connect || source.is_none() { @@ -215,7 +208,11 @@ impl Shell { traffic: cx.new(|_| TrafficPage::new()), connect: cx.new(|cx| ConnectFlow::new(load_profile(), window, cx)), settings: cx.new(|_| SettingsPage::new()), - update_note: None, + update: if load_config().update.enabled { + UpdateUi::Checking + } else { + UpdateUi::Disabled + }, sidebar_collapsed: None, active_host, host_status: HostStatus::default(), @@ -475,6 +472,90 @@ impl Shell { } } + fn spawn_update_check(auto_download: bool, cx: &mut Context) { + let channel = match load_profile().and_then(|p| p.channel).as_deref() { + Some("beta") => chm_update::Channel::Beta, + _ => chm_update::Channel::Stable, + }; + cx.spawn(async move |this, cx| { + let checker = chm_update::UpdateChecker::production(); + let current = semver::Version::parse(env!("CARGO_PKG_VERSION")) + .unwrap_or_else(|_| semver::Version::new(0, 1, 1)); + let found = chm_core::tokio_block_on(checker.check(channel, ¤t)); + let _ = this.update(cx, |shell, cx| { + match found { + Ok(Some(release)) => { + shell.update = UpdateUi::Available(release.clone()); + if auto_download { + shell.start_download(release, cx); + } + } + Ok(None) => shell.update = UpdateUi::Idle, + Err(_) => shell.update = UpdateUi::Silent, + } + cx.notify(); + }); + }) + .detach(); + } + + fn start_download(&mut self, release: chm_update::ReleaseInfo, cx: &mut Context) { + let Some(dest) = updater::archive_path(&release) else { + self.update = UpdateUi::Failed("no cache directory".into()); + cx.notify(); + return; + }; + self.update = UpdateUi::Downloading(release.version().to_string()); + cx.notify(); + cx.spawn(async move |this, cx| { + let checker = chm_update::UpdateChecker::production(); + let result = chm_core::tokio_block_on(checker.download(&release, &dest)); + let _ = this.update(cx, |shell, cx| { + shell.update = match result { + Ok(()) => UpdateUi::Ready { + version: release.version().to_string(), + archive: dest, + }, + Err(e) => UpdateUi::Failed(e.to_string()), + }; + cx.notify(); + }); + }) + .detach(); + } + + fn apply_downloaded(&mut self, cx: &mut Context) { + let UpdateUi::Ready { archive, .. } = &self.update else { + return; + }; + let archive = archive.clone(); + #[cfg(target_os = "macos")] + { + match updater::install_macos_zip(&archive) { + Ok(app) => { + updater::relaunch(&app); + cx.quit(); + } + Err(_) => { + let _ = std::process::Command::new("/usr/bin/open") + .args(["-R"]) + .arg(&archive) + .spawn(); + self.update = UpdateUi::Failed( + "saved the archive — drop it over /Applications/chmonitor.app".into(), + ); + cx.notify(); + } + } + } + #[cfg(not(target_os = "macos"))] + { + let _ = archive; + self.update = UpdateUi::Failed("install the archive from the release page".into()); + cx.notify(); + } + } + fn host_icon(mode: Option<&str>) -> IconName { match mode { Some("postgres") => IconName::HardDrive, @@ -605,10 +686,49 @@ impl Shell { Some(at) => format!("updated {}", at.format("%H:%M:%S")), None => "not refreshed yet".to_string(), }; - let note = match &self.update_note { - None => Some(SharedString::from("update check…")), - Some(UpdateNote(t)) if !t.is_empty() => Some(t.clone()), - Some(_) => None, + let muted = cx.theme().muted_foreground; + let update_el = match &self.update { + UpdateUi::Disabled | UpdateUi::Silent => None, + UpdateUi::Checking => Some( + div() + .text_color(muted) + .child("update check…") + .into_any_element(), + ), + UpdateUi::Idle => Some( + div() + .text_color(muted) + .child("up to date") + .into_any_element(), + ), + UpdateUi::Available(release) => { + let release = release.clone(); + let label = format!("update v{}", release.version()); + Some( + ghost_button("apply-update", label, cx) + .on_click(cx.listener(move |this, _, _, cx| { + this.start_download(release.clone(), cx); + })) + .into_any_element(), + ) + } + UpdateUi::Downloading(v) => Some( + div() + .text_color(muted) + .child(format!("downloading v{v}…")) + .into_any_element(), + ), + UpdateUi::Ready { version, .. } => Some( + ghost_button("install-update", format!("install v{version}"), cx) + .on_click(cx.listener(|this, _, _, cx| this.apply_downloaded(cx))) + .into_any_element(), + ), + UpdateUi::Failed(e) => Some( + div() + .text_color(cx.theme().danger) + .child(e.clone()) + .into_any_element(), + ), }; StatusBar::new() .left( @@ -620,7 +740,7 @@ impl Shell { ) .child(div().text_color(self.conn.color(cx)).child(status)) .right(refreshed) - .children(note.map(|t| div().text_color(cx.theme().muted_foreground).child(t))) + .children(update_el) } fn content(&mut self, cx: &mut Context) -> gpui::AnyElement { diff --git a/app/src/updater.rs b/app/src/updater.rs new file mode 100644 index 0000000..15f87c2 --- /dev/null +++ b/app/src/updater.rs @@ -0,0 +1,124 @@ +//! Download cache path and macOS `.app` install from a release zip. + +use std::path::{Path, PathBuf}; + +use chm_update::ReleaseInfo; + +/// `~/Library/Caches/chmonitor/updates` (macOS) or the platform cache dir. +pub fn cache_dir() -> Option { + dirs::cache_dir().map(|d| d.join("chmonitor").join("updates")) +} + +pub fn archive_path(release: &ReleaseInfo) -> Option { + let name = release + .url() + .rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .unwrap_or("chmonitor-update.bin"); + cache_dir().map(|d| { + d.join(format!("v{name}", name = release.version())) + .join(name) + }) +} + +/// If this process is running from `chmonitor.app/Contents/MacOS/…`, +/// return the `.app` bundle path. +pub fn macos_bundle_path() -> Option { + let exe = std::env::current_exe().ok()?; + let macos = exe.parent()?; + if macos.file_name()?.to_str()? != "MacOS" { + return None; + } + let contents = macos.parent()?; + if contents.file_name()?.to_str()? != "Contents" { + return None; + } + let bundle = contents.parent()?; + if bundle.extension()?.to_str()? != "app" { + return None; + } + Some(bundle.to_path_buf()) +} + +/// Unpack a macOS zip (`.app` inside) and swap it over the running bundle. +/// Returns the installed `.app` path. Caller should relaunch and quit. +#[cfg(target_os = "macos")] +pub fn install_macos_zip(zip: &Path) -> Result { + let bundle = macos_bundle_path().ok_or_else(|| { + "not running from chmonitor.app — open the zip from Downloads".to_string() + })?; + let parent = bundle + .parent() + .ok_or_else(|| "app bundle has no parent directory".to_string())?; + let stage = parent.join(format!(".chmonitor-update-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&stage); + std::fs::create_dir_all(&stage).map_err(|e| format!("stage mkdir: {e}"))?; + let status = std::process::Command::new("/usr/bin/ditto") + .args(["-xk", "--"]) + .arg(zip) + .arg(&stage) + .status() + .map_err(|e| format!("ditto: {e}"))?; + if !status.success() { + let _ = std::fs::remove_dir_all(&stage); + return Err(format!("ditto exited {status}")); + } + let fresh = find_app(&stage).ok_or_else(|| { + let _ = std::fs::remove_dir_all(&stage); + "zip did not contain a .app bundle".to_string() + })?; + let backup = parent.join(format!("chmonitor.app.bak-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&backup); + std::fs::rename(&bundle, &backup).map_err(|e| { + let _ = std::fs::remove_dir_all(&stage); + format!("move running app aside: {e}") + })?; + let dest = parent.join("chmonitor.app"); + if let Err(e) = std::fs::rename(&fresh, &dest) { + let _ = std::fs::rename(&backup, &bundle); + let _ = std::fs::remove_dir_all(&stage); + return Err(format!("install new app: {e}")); + } + let _ = std::fs::remove_dir_all(&backup); + let _ = std::fs::remove_dir_all(&stage); + Ok(dest) +} + +#[cfg(target_os = "macos")] +fn find_app(root: &Path) -> Option { + let mut found = None; + let walker = std::fs::read_dir(root).ok()?; + for entry in walker.flatten() { + let p = entry.path(); + if p.extension().and_then(|e| e.to_str()) == Some("app") { + return Some(p); + } + if p.is_dir() + && let Some(inner) = find_app(&p) + { + found = Some(inner); + } + } + found +} + +#[cfg(target_os = "macos")] +pub fn relaunch(app: &Path) { + let _ = std::process::Command::new("/usr/bin/open").arg(app).spawn(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn archive_path_nests_under_version() { + let release = serde_json::from_str::( + r#"{"version":"1.2.3","url":"https://x/chmonitor-v1.2.3.zip","notes":""}"#, + ) + .unwrap(); + let path = archive_path(&release).expect("cache dir"); + assert!(path.ends_with("v1.2.3/chmonitor-v1.2.3.zip")); + } +} diff --git a/assets/icon/icon-1024.png b/assets/icon/icon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..5683d491943aa5a401dc74b180b6658c63b68846 GIT binary patch literal 61008 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 a-t`@KfgH-Z?xE+bYhN}nzlgoy7V|$Hs(~s1 literal 0 HcmV?d00001 diff --git a/assets/icon/logo.svg b/assets/icon/logo.svg new file mode 100644 index 0000000..a8b3cd8 --- /dev/null +++ b/assets/icon/logo.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/crates/chm-update/Cargo.toml b/crates/chm-update/Cargo.toml index 420a057..14e296e 100644 --- a/crates/chm-update/Cargo.toml +++ b/crates/chm-update/Cargo.toml @@ -11,6 +11,7 @@ thiserror.workspace = true semver.workspace = true reqwest.workspace = true tracing.workspace = true +sha2 = "0.10" [dev-dependencies] tokio.workspace = true diff --git a/crates/chm-update/src/lib.rs b/crates/chm-update/src/lib.rs index ce9b2eb..563ea65 100644 --- a/crates/chm-update/src/lib.rs +++ b/crates/chm-update/src/lib.rs @@ -48,14 +48,15 @@ pub enum Error { BadManifest(String), #[error("invalid version in update manifest: {0}")] VersionParse(#[from] semver::Error), + #[error("update download checksum mismatch: expected {expected}, got {got}")] + Checksum { expected: String, got: String }, + #[error("update download io failed: {0}")] + Io(#[from] std::io::Error), } pub type Result = std::result::Result; /// A release advertised on a channel manifest. -/// -/// `sha256` is meant to be verified by the download/install step; this crate -/// never fetches the artifact itself. #[derive(Debug, Clone, PartialEq, Deserialize)] pub struct ReleaseInfo { version: semver::Version, @@ -63,6 +64,7 @@ pub struct ReleaseInfo { notes: String, sha256: Option, date: Option, + target: Option, } impl ReleaseInfo { @@ -85,6 +87,22 @@ impl ReleaseInfo { pub fn date(&self) -> Option<&str> { self.date.as_deref() } + + pub fn target(&self) -> Option<&str> { + self.target.as_deref() + } +} + +/// Rustc target triple for this binary, used to pick a row from a +/// multi-target channel manifest. +pub fn current_target() -> &'static str { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("macos", "aarch64") => "aarch64-apple-darwin", + ("macos", "x86_64") => "x86_64-apple-darwin", + ("linux", "x86_64") => "x86_64-unknown-linux-gnu", + ("linux", "aarch64") => "aarch64-unknown-linux-gnu", + _ => "unknown", + } } #[derive(Debug, Deserialize)] @@ -96,6 +114,15 @@ struct RawManifest { sha256: Option, #[serde(default)] date: Option, + #[serde(default)] + target: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum ManifestBody { + One(RawManifest), + Many(Vec), } #[derive(Debug, Clone)] @@ -150,8 +177,10 @@ impl UpdateChecker { .text() .await?; - let raw: RawManifest = + let body: ManifestBody = serde_json::from_str(&body).map_err(|e| Error::BadManifest(e.to_string()))?; + let raw = pick_manifest(body, current_target()) + .ok_or_else(|| Error::BadManifest("empty update manifest".into()))?; let version: semver::Version = raw.version.parse()?; if version <= *current { @@ -164,8 +193,59 @@ impl UpdateChecker { notes: raw.notes, sha256: raw.sha256, date: raw.date, + target: raw.target, })) } + + /// Downloads `release.url` to `dest` and verifies `sha256` when present. + pub async fn download(&self, release: &ReleaseInfo, dest: &std::path::Path) -> Result<()> { + let bytes = self + .client + .get(release.url()) + .send() + .await? + .error_for_status()? + .bytes() + .await?; + if let Some(expected) = release.sha256() { + let got = sha256_hex(&bytes); + if !got.eq_ignore_ascii_case(expected) { + return Err(Error::Checksum { + expected: expected.to_string(), + got, + }); + } + } + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(dest, bytes)?; + Ok(()) + } +} + +fn pick_manifest(body: ManifestBody, target: &str) -> Option { + match body { + ManifestBody::One(one) => Some(one), + ManifestBody::Many(rows) if rows.is_empty() => None, + ManifestBody::Many(mut rows) => { + if let Some(ix) = rows + .iter() + .position(|r| r.target.as_deref() == Some(target)) + { + Some(rows.swap_remove(ix)) + } else { + Some(rows.swap_remove(0)) + } + } + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("{:x}", hasher.finalize()) } fn normalize_base(base: String) -> String { @@ -314,6 +394,90 @@ mod tests { assert!(matches!(err, Error::Http(_)), "{err:?}"); } + #[tokio::test] + async fn multi_target_manifest_picks_matching_row() { + let body = serde_json::json!([ + { + "version": "1.2.3", + "url": "https://dl.example/linux.tar.gz", + "notes": "linux", + "target": "x86_64-unknown-linux-gnu" + }, + { + "version": "1.2.3", + "url": "https://dl.example/mac.zip", + "notes": "mac", + "sha256": "abc", + "target": current_target() + } + ]) + .to_string(); + let server = serve("/stable.json", 200, body).await; + let checker = UpdateChecker::new(server.uri()); + let release = checker + .check(Channel::Stable, &semver::Version::new(1, 0, 0)) + .await + .unwrap() + .expect("newer"); + assert_eq!(release.url(), "https://dl.example/mac.zip"); + assert_eq!(release.target(), Some(current_target())); + assert_eq!(release.sha256(), Some("abc")); + } + + #[tokio::test] + async fn download_writes_file_and_checks_sha256() { + let payload = b"chmonitor-update-bytes"; + let digest = sha256_hex(payload); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/pkg.zip")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(payload.as_slice())) + .mount(&server) + .await; + let checker = UpdateChecker::new(server.uri()); + let release = ReleaseInfo { + version: semver::Version::new(1, 2, 3), + url: format!("{}/pkg.zip", server.uri()), + notes: String::new(), + sha256: Some(digest), + date: None, + target: None, + }; + let dest = std::env::temp_dir().join(format!( + "chm-update-dl-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + checker.download(&release, &dest).await.unwrap(); + assert_eq!(std::fs::read(&dest).unwrap(), payload); + let _ = std::fs::remove_file(&dest); + } + + #[tokio::test] + async fn download_rejects_bad_checksum() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/pkg.zip")) + .respond_with(ResponseTemplate::new(200).set_body_bytes(b"nope")) + .mount(&server) + .await; + let checker = UpdateChecker::new(server.uri()); + let release = ReleaseInfo { + version: semver::Version::new(1, 2, 3), + url: format!("{}/pkg.zip", server.uri()), + notes: String::new(), + sha256: Some("deadbeef".into()), + date: None, + target: None, + }; + let dest = std::env::temp_dir().join("chm-update-bad-sha"); + let err = checker.download(&release, &dest).await.unwrap_err(); + assert!(matches!(err, Error::Checksum { .. }), "{err:?}"); + } + #[test] fn manifest_base_normalizes_trailing_slashes() { let checker = UpdateChecker::new("https://updates.example.com/"); diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh new file mode 100755 index 0000000..09d6e0f --- /dev/null +++ b/scripts/build-macos.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# Build chmonitor.app for macOS: icon, Info.plist, signed-ready bundle. +# +# Usage: +# scripts/build-macos.sh # release profile → dist/macos/chmonitor.app +# scripts/build-macos.sh --debug +# scripts/build-macos.sh --beta +# scripts/build-macos.sh --bin PATH --version 0.1.1 --out DIR +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +PROFILE="release" +BIN="" +VERSION="${CHM_VERSION:-}" +OUT="$ROOT/dist/macos" +SKIP_BUILD=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --debug) PROFILE=dev; shift ;; + --release) PROFILE=release; shift ;; + --beta) PROFILE=beta; shift ;; + --bin) BIN="$2"; SKIP_BUILD=1; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --out) OUT="$2"; shift 2 ;; + -h|--help) + sed -n '2,12p' "$0" + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + exit 2 + ;; + esac +done + +APP_NAME="chmonitor" +BIN_NAME="chm-app" +BUNDLE_ID="io.chmonitor.desktop" +if [[ -z "$VERSION" ]]; then + VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' app/Cargo.toml | head -1)" +fi + +log() { printf '[build-macos] %s\n' "$*" >&2; } + +# --- icon --- +ICON_SRC="$ROOT/assets/icon/icon-1024.png" +[[ -f "$ICON_SRC" ]] || { echo "missing $ICON_SRC" >&2; exit 1; } + +ICONSET="$(mktemp -d /tmp/chm-iconset.XXXXXX)" +trap 'rm -rf "$ICONSET"' EXIT +mkdir -p "$ICONSET/AppIcon.iconset" + +# iconutil wants both 1x and @2x names. +copy_size() { + local px="$1" name="$2" + sips -z "$px" "$px" "$ICON_SRC" --out "$ICONSET/AppIcon.iconset/$name" >/dev/null +} +copy_size 16 icon_16x16.png +copy_size 32 icon_16x16@2x.png +copy_size 32 icon_32x32.png +copy_size 64 icon_32x32@2x.png +copy_size 128 icon_128x128.png +copy_size 256 icon_128x128@2x.png +copy_size 256 icon_256x256.png +copy_size 512 icon_256x256@2x.png +copy_size 512 icon_512x512.png +copy_size 1024 icon_512x512@2x.png + +ICNS="$ICONSET/AppIcon.icns" +iconutil -c icns "$ICONSET/AppIcon.iconset" -o "$ICNS" +log "icon $ICNS ($(wc -c <"$ICNS") bytes)" + +# --- binary --- +if [[ "$SKIP_BUILD" -eq 0 ]]; then + log "cargo build --profile $PROFILE -p chm-app" + cargo build --profile "$PROFILE" -p chm-app + if [[ "$PROFILE" = "dev" ]]; then + BIN="$ROOT/target/debug/$BIN_NAME" + else + BIN="$ROOT/target/$PROFILE/$BIN_NAME" + fi +fi +[[ -x "$BIN" ]] || { echo "binary not executable: $BIN" >&2; exit 1; } + +# --- bundle --- +mkdir -p "$OUT" +APP="$OUT/$APP_NAME.app" +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" +cp "$BIN" "$APP/Contents/MacOS/$BIN_NAME" +chmod +x "$APP/Contents/MacOS/$BIN_NAME" +cp "$ICNS" "$APP/Contents/Resources/AppIcon.icns" +printf 'APPL????' > "$APP/Contents/PkgInfo" + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleName + $APP_NAME + CFBundleDisplayName + $APP_NAME + CFBundleIdentifier + $BUNDLE_ID + CFBundleVersion + $VERSION + CFBundleShortVersionString + $VERSION + CFBundleExecutable + $BIN_NAME + CFBundlePackageType + APPL + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon + LSApplicationCategoryType + public.app-category.developer-tools + LSMinimumSystemVersion + 13.0 + NSHighResolutionCapable + + NSSupportsAutomaticTermination + + + +PLIST + +log "app $APP" +log "version $VERSION ($(file -b "$APP/Contents/MacOS/$BIN_NAME"))" +echo "$APP" From 1d8f0d5ed0012846fc0e1b20a0f5f8b4a6a47c8f Mon Sep 17 00:00:00 2001 From: duyetbot Date: Mon, 24 Aug 2026 19:26:21 +0700 Subject: [PATCH 12/20] feat(ui): match dashboard look, cache, skeletons, telemetry Apply the chmonitor.dev Rhea palette (indigo primary, amber charts) and larger metric type. Pages hydrate from a 20s disk cache, show skeleton placeholders on first load, and toggle light/dark from the title bar. Status bar tracks fetch time and RSS; opt-in telemetry pings telemetry.chmonitor.dev for installs and page views. --- Cargo.lock | 2 + README.md | 6 +- app/src/cache.rs | 103 ++++++++++++++ app/src/lib.rs | 2 + app/src/pages/health.rs | 2 +- app/src/pages/merges.rs | 2 +- app/src/pages/overview.rs | 2 +- app/src/pages/queries.rs | 2 +- app/src/pages/replicas.rs | 2 +- app/src/pages/settings.rs | 9 +- app/src/pages/tables.rs | 2 +- app/src/pages/traffic.rs | 2 +- app/src/shell.rs | 232 ++++++++++++++++++++++++++++++-- app/src/theme.rs | 114 ++++++++++++++++ app/src/widgets/cards.rs | 13 +- app/src/widgets/mod.rs | 1 + app/src/widgets/skeleton.rs | 73 ++++++++++ crates/chm-telemetry/Cargo.toml | 2 + crates/chm-telemetry/src/lib.rs | 163 +++++++++++++++++++++- 19 files changed, 704 insertions(+), 30 deletions(-) create mode 100644 app/src/cache.rs create mode 100644 app/src/theme.rs create mode 100644 app/src/widgets/skeleton.rs diff --git a/Cargo.lock b/Cargo.lock index 4305dc0..b1aea3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1027,9 +1027,11 @@ version = "0.1.0" dependencies = [ "chm-update", "chrono", + "dirs", "reqwest", "serde", "serde_json", + "sha2 0.10.9", "thiserror 2.0.20", "tokio", "tracing", diff --git a/README.md b/README.md index 512c84b..be5296a 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,9 @@ CHM_CONFIG=/tmp/chmonitor.toml cargo run -p chm-app Named profiles live under `[profiles.]` in `config.toml`; the default connection is `[profile]`. `r` refreshes the current page; keys `1`–`8` switch sidebar destinations; `cmd-b` toggles the sidebar; -`cmd-,` opens Settings. The sidebar host switcher lists `[profile]` plus +`cmd-,` opens Settings; the sun/moon control in the title bar toggles +light/dark. Pages restore from a local cache so they paint immediately, +then refresh; skeletons show when nothing is cached yet. The sidebar host switcher lists `[profile]` plus `[profiles.]`; Connect's optional Name field saves a named host. ## Layout @@ -61,7 +63,7 @@ keys `1`–`8` switch sidebar destinations; `cmd-b` toggles the sidebar; | `crates/chm-clickhouse` | mode 2: direct ClickHouse HTTP client | | `crates/chm-postgres` | mode 3: direct Postgres (`pg_stat_*`) | | `crates/chm-update` | channel-aware update checker (stable/beta) | -| `crates/chm-telemetry` | opt-in telemetry + perf metrics | +| `crates/chm-telemetry` | opt-in install ping + page events, local fetch/RSS metrics | | `app/` | GPUI UI: [gpui-base](https://longbridge.github.io/gpui-component/base/getting-started.md) primitives (buttons, radios, tables) + [gpui-component](https://longbridge.github.io/gpui-component/) for sidebar, charts, theme | | `.github/workflows/` | CI: lint, test, build matrix, releases | diff --git a/app/src/cache.rs b/app/src/cache.rs new file mode 100644 index 0000000..f070aff --- /dev/null +++ b/app/src/cache.rs @@ -0,0 +1,103 @@ +//! On-disk page cache so a host switch or relaunch paints last-known data +//! immediately while a background refresh runs. + +use std::path::PathBuf; +use std::time::{Duration, SystemTime}; + +use chm_core::{ + Health, MergeRow, Overview, QueryRow, ReplicaRow, TableStat, TimeRange, TrafficSeries, +}; +use serde::{Deserialize, Serialize}; + +use crate::pages::Page; + +/// Skip a network round-trip when the file is newer than this. +pub const FRESH_SECS: u64 = 20; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum CachedPage { + Overview { + overview: Overview, + traffic: TrafficSeries, + }, + Queries { + running: Vec, + slow: Vec, + failed: Vec, + }, + Merges(Vec), + Replicas(Vec), + Health(Health), + Tables(Vec), + Traffic(TrafficSeries), +} + +fn dir() -> Option { + dirs::cache_dir().map(|d| d.join("chmonitor").join("pages")) +} + +fn file(host: &str, page: Page, range: TimeRange) -> Option { + let safe_host: String = host + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect(); + dir().map(|d| { + d.join(format!( + "{safe_host}_{}_{}.json", + page.title().to_ascii_lowercase(), + range.label() + )) + }) +} + +pub fn load(host: &str, page: Page, range: TimeRange) -> Option<(CachedPage, SystemTime)> { + let path = file(host, page, range)?; + let meta = std::fs::metadata(&path).ok()?; + let mtime = meta.modified().ok()?; + let text = std::fs::read_to_string(&path).ok()?; + let page: CachedPage = serde_json::from_str(&text).ok()?; + Some((page, mtime)) +} + +pub fn save(host: &str, page: Page, range: TimeRange, data: &CachedPage) { + let Some(path) = file(host, page, range) else { + return; + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(text) = serde_json::to_string(data) { + let _ = std::fs::write(path, text); + } +} + +pub fn is_fresh(mtime: SystemTime) -> bool { + mtime + .elapsed() + .map(|d| d < Duration::from_secs(FRESH_SECS)) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_health_cache() { + let host = format!("test-{}", std::process::id()); + let data = CachedPage::Health(Health { + ok: true, + readonly_tables: 2, + ..Health::default() + }); + save(&host, Page::Health, TimeRange::TwentyFourHours, &data); + let (loaded, _) = load(&host, Page::Health, TimeRange::TwentyFourHours).expect("cached"); + match loaded { + CachedPage::Health(h) => { + assert!(h.ok); + assert_eq!(h.readonly_tables, 2); + } + _ => panic!("wrong page"), + } + } +} diff --git a/app/src/lib.rs b/app/src/lib.rs index b35c81c..111a1ae 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -4,9 +4,11 @@ //! top of `gpui-base`. Sidebar, charts, inputs, and theme come from //! `gpui-component`. +pub mod cache; pub mod config; pub mod connect; pub mod pages; pub mod shell; +pub mod theme; pub mod updater; pub mod widgets; diff --git a/app/src/pages/health.rs b/app/src/pages/health.rs index cf4d481..27b8f4c 100644 --- a/app/src/pages/health.rs +++ b/app/src/pages/health.rs @@ -45,7 +45,7 @@ impl Render for HealthPage { return status(format!("health unavailable: {err}"), cx).into_any_element(); } let Some(h) = &self.data else { - return status("loading health…", cx).into_any_element(); + return crate::widgets::skeleton::metric_grid(cx).into_any_element(); }; let pool_pct = format!( "{:.0}%", diff --git a/app/src/pages/merges.rs b/app/src/pages/merges.rs index a269a1a..db709a6 100644 --- a/app/src/pages/merges.rs +++ b/app/src/pages/merges.rs @@ -44,7 +44,7 @@ impl Render for MergesPage { return status(format!("merges unavailable: {err}"), cx).into_any_element(); } let Some(rows) = &self.data else { - return status("loading merges…", cx).into_any_element(); + return crate::widgets::skeleton::table_block(cx).into_any_element(); }; if rows.is_empty() { return status("no merges or mutations in flight", cx).into_any_element(); diff --git a/app/src/pages/overview.rs b/app/src/pages/overview.rs index 6788d30..c0bb618 100644 --- a/app/src/pages/overview.rs +++ b/app/src/pages/overview.rs @@ -56,7 +56,7 @@ impl Render for OverviewPage { return status(format!("overview unavailable: {err}"), cx).into_any_element(); } let Some(o) = &self.data else { - return status("loading overview…", cx).into_any_element(); + return crate::widgets::skeleton::metric_grid(cx).into_any_element(); }; let used_pct = 100.0 * o.disk_used_bytes as f64 / o.disk_total_bytes.max(1) as f64; diff --git a/app/src/pages/queries.rs b/app/src/pages/queries.rs index f755118..7c31873 100644 --- a/app/src/pages/queries.rs +++ b/app/src/pages/queries.rs @@ -144,7 +144,7 @@ impl Render for QueriesPage { if let Some(err) = &self.error { return status(format!("queries unavailable: {err}"), cx).into_any_element(); } - return status("loading queries…", cx).into_any_element(); + return crate::widgets::skeleton::table_block(cx).into_any_element(); } let mut col = div().flex().flex_col().gap(px(16.)).w_full(); if let Some(err) = &self.error { diff --git a/app/src/pages/replicas.rs b/app/src/pages/replicas.rs index 67db00e..de86c2f 100644 --- a/app/src/pages/replicas.rs +++ b/app/src/pages/replicas.rs @@ -44,7 +44,7 @@ impl Render for ReplicasPage { return status(format!("replicas unavailable: {err}"), cx).into_any_element(); } let Some(rows) = &self.data else { - return status("loading replicas…", cx).into_any_element(); + return crate::widgets::skeleton::table_block(cx).into_any_element(); }; if rows.is_empty() { return status("no replicas", cx).into_any_element(); diff --git a/app/src/pages/settings.rs b/app/src/pages/settings.rs index 7808af9..e80f8f3 100644 --- a/app/src/pages/settings.rs +++ b/app/src/pages/settings.rs @@ -242,12 +242,14 @@ impl Render for SettingsPage { .child( v_flex() .gap_1() - .child(div().text_sm().child("Local timings")) + .child(div().text_sm().child("Anonymous usage")) .child( div() .text_xs() .text_color(cx.theme().muted_foreground) - .child("fetch timings only; no query text (off by default)"), + .child( + "install ping + page views to telemetry.chmonitor.dev; no SQL or hostnames", + ), ), ) .child(theme_switch("telemetry", telemetry, cx).on_change( @@ -292,6 +294,7 @@ pub fn apply_appearance(mode: Appearance, window: &mut Window, cx: &mut App) { Appearance::Dark => Theme::change(ThemeMode::Dark, Some(window), cx), Appearance::System => Theme::sync_system_appearance(Some(window), cx), } + crate::theme::apply_brand(cx); } pub fn appearance_from_cfg(s: Option<&str>) -> Appearance { @@ -302,7 +305,7 @@ pub fn appearance_from_cfg(s: Option<&str>) -> Appearance { } } -fn appearance_to_cfg(mode: Appearance) -> &'static str { +pub(crate) fn appearance_to_cfg(mode: Appearance) -> &'static str { match mode { Appearance::System => "system", Appearance::Light => "light", diff --git a/app/src/pages/tables.rs b/app/src/pages/tables.rs index 26b6c7f..f21d164 100644 --- a/app/src/pages/tables.rs +++ b/app/src/pages/tables.rs @@ -44,7 +44,7 @@ impl Render for TablesPage { return status(format!("tables unavailable: {err}"), cx).into_any_element(); } let Some(rows) = &self.data else { - return status("loading tables…", cx).into_any_element(); + return crate::widgets::skeleton::table_block(cx).into_any_element(); }; if rows.is_empty() { return status("no tables", cx).into_any_element(); diff --git a/app/src/pages/traffic.rs b/app/src/pages/traffic.rs index 0a51817..87a2fbb 100644 --- a/app/src/pages/traffic.rs +++ b/app/src/pages/traffic.rs @@ -63,7 +63,7 @@ impl Render for TrafficPage { return status(format!("traffic unavailable: {err}"), cx).into_any_element(); } let Some(t) = &self.data else { - return status("loading traffic…", cx).into_any_element(); + return crate::widgets::skeleton::chart_block(cx).into_any_element(); }; if t.queries_per_sec.is_empty() && t.rows_read_per_sec.is_empty() diff --git a/app/src/shell.rs b/app/src/shell.rs index 2f67879..c0215ce 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -14,7 +14,9 @@ use gpui::{ KeyDownEvent, Render, SharedString, WeakEntity, Window, actions, div, prelude::*, px, }; use gpui_component::{ - ActiveTheme as _, IconName, Root, h_flex, + ActiveTheme as _, Icon, IconName, Root, Sizable as _, + button::{Button, ButtonVariants as _}, + h_flex, sidebar::{ Sidebar, SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarToggleButton, @@ -23,6 +25,7 @@ use gpui_component::{ v_flex, }; +use crate::cache; use crate::config::{ ConfigFile, Host, ProfileConfig, active_host_id, cli, config_path, list_hosts, load_config, load_profile, profile_for_host, save_config, source_from_profile, @@ -102,6 +105,7 @@ struct HostStatus { replicas_total: u64, health_ok: Option, fetch_ms: Option, + rss_bytes: Option, } /// The root view: owns routing, the active data source and the poll task. @@ -127,6 +131,7 @@ pub struct Shell { sidebar_collapsed: Option, active_host: Option, host_status: HostStatus, + fetching: bool, } impl Focusable for Shell { @@ -174,9 +179,21 @@ impl Shell { .map(|cfg| cfg.telemetry.enabled) .unwrap_or(false); if telemetry_enabled { - // Config built but nothing transmitted: PerfMetrics only records - // local latency numbers, and recording itself stays gated here. - let _cfg = chm_telemetry::TelemetryConfig::default().set_enabled(true); + let channel = match load_profile().and_then(|p| p.channel).as_deref() { + Some("beta") => chm_update::Channel::Beta, + _ => chm_update::Channel::Stable, + }; + let cfg = chm_telemetry::TelemetryConfig::opt_in( + chm_telemetry::TELEMETRY_PING_URL, + env!("CARGO_PKG_VERSION"), + channel, + ); + cx.spawn(async move |_, _| { + let http = chm_telemetry::http_client(); + let _ = chm_core::tokio_block_on(chm_telemetry::ping(http, &cfg)); + let _ = chm_core::tokio_block_on(chm_telemetry::track(http, &cfg, "app_loaded")); + }) + .detach(); } let update_cfg = load_config().update; @@ -216,6 +233,7 @@ impl Shell { sidebar_collapsed: None, active_host, host_status: HostStatus::default(), + fetching: false, }; // Digits 1-8 switch pages; handled in render's on_key_down so it works @@ -237,7 +255,7 @@ impl Shell { .detach(); shell.start_poll(cx); - shell.refresh_now(cx); + shell.refresh(false, cx); shell } @@ -246,10 +264,32 @@ impl Shell { return; } self.page = page; - self.refresh_now(cx); + self.emit_page(page); + self.refresh(false, cx); cx.notify(); } + fn emit_page(&self, page: Page) { + if !load_config().telemetry.enabled { + return; + } + let event = match page { + Page::Health => "health_viewed", + Page::Queries => "queries_viewed", + Page::Overview => "app_loaded", + _ => return, + }; + let cfg = chm_telemetry::TelemetryConfig::opt_in( + chm_telemetry::TELEMETRY_PING_URL, + env!("CARGO_PKG_VERSION"), + chm_update::Channel::Stable, + ); + std::thread::spawn(move || { + let http = chm_telemetry::http_client(); + let _ = chm_core::tokio_block_on(chm_telemetry::track(http, &cfg, event)); + }); + } + fn toggle_sidebar(&mut self, narrow: bool, cx: &mut Context) { let compact = sidebar_is_compact(self.sidebar_collapsed, narrow); self.sidebar_collapsed = Some(!compact); @@ -391,14 +431,110 @@ impl Shell { /// Manual refresh action + initial fill. fn refresh_now(&mut self, cx: &mut Context) { + self.refresh(true, cx); + } + + fn refresh(&mut self, force: bool, cx: &mut Context) { + self.hydrate_cache(cx); + if !force && self.cache_is_fresh() { + self.fetching = false; + cx.notify(); + return; + } let Some(job) = self.poll_job() else { return }; - if self.conn != ConnState::Error { + if self.conn != ConnState::Error && !self.has_cached_page() { self.conn = ConnState::Connecting; } + self.fetching = true; + cx.notify(); cx.spawn(async move |this, cx| apply_poll(job, &this, cx).await) .detach(); } + fn cache_host(&self) -> Option<&str> { + self.active_host.as_deref() + } + + fn cache_is_fresh(&self) -> bool { + let Some(host) = self.cache_host() else { + return false; + }; + cache::load(host, self.page, self.range) + .map(|(_, t)| cache::is_fresh(t)) + .unwrap_or(false) + } + + fn has_cached_page(&self) -> bool { + let Some(host) = self.cache_host() else { + return false; + }; + cache::load(host, self.page, self.range).is_some() + } + + fn hydrate_cache(&mut self, cx: &mut Context) { + let Some(host) = self.active_host.clone() else { + return; + }; + let Some((data, _)) = cache::load(&host, self.page, self.range) else { + return; + }; + self.apply_cached(data, cx); + if self.conn != ConnState::Error { + self.conn = ConnState::Connected; + } + } + + fn apply_cached(&mut self, data: crate::cache::CachedPage, cx: &mut Context) { + use crate::cache::CachedPage; + match data { + CachedPage::Overview { overview, traffic } => { + self.host_status.version = Some(overview.clickhouse_version.clone()); + self.host_status.replicas_ok = overview.replicas_ok; + self.host_status.replicas_total = overview.replicas_total; + self.overview + .update(cx, |p, cx| p.set_overview(Ok(overview), Ok(traffic), cx)); + } + CachedPage::Queries { + running, + slow, + failed, + } => { + self.queries + .update(cx, |p, cx| p.set(Ok(running), Ok(slow), Ok(failed), cx)); + } + CachedPage::Merges(rows) => { + self.merges.update(cx, |p, cx| p.set(Ok(rows), cx)); + } + CachedPage::Replicas(rows) => { + self.replicas.update(cx, |p, cx| p.set(Ok(rows), cx)); + } + CachedPage::Health(h) => { + self.host_status.health_ok = Some(h.ok); + self.health.update(cx, |p, cx| p.set(Ok(h), cx)); + } + CachedPage::Tables(rows) => { + self.tables.update(cx, |p, cx| p.set(Ok(rows), cx)); + } + CachedPage::Traffic(t) => { + self.traffic.update(cx, |p, cx| p.set(Ok(t), cx)); + } + } + } + + fn toggle_dark(&mut self, window: &mut Window, cx: &mut Context) { + use crate::pages::settings::{Appearance, appearance_to_cfg, apply_appearance}; + let next = if crate::theme::current_mode(cx) == gpui_component::ThemeMode::Dark { + Appearance::Light + } else { + Appearance::Dark + }; + apply_appearance(next, window, cx); + let mut cfg = load_config(); + cfg.ui.appearance = Some(appearance_to_cfg(next).into()); + let _ = save_config(&cfg); + cx.notify(); + } + fn apply_outcome( &mut self, outcome: PollOutcome, @@ -407,7 +543,16 @@ impl Shell { cx: &mut Context, ) { self.last_refresh = Some(at); + self.fetching = false; self.host_status.fetch_ms = Some(fetch_ms); + if let Some(rss) = chm_telemetry::rss_bytes() { + self.host_status.rss_bytes = Some(rss); + perf().record_rss(rss); + } + let _ = perf().record_fetch(fetch_ms); + if let Some(host) = self.active_host.clone() { + self.persist_cache(&host, &outcome); + } match outcome { PollOutcome::Overview { overview, traffic } => { if let Ok(o) = &overview { @@ -462,6 +607,42 @@ impl Shell { cx.notify(); } + fn persist_cache(&self, host: &str, outcome: &PollOutcome) { + use crate::cache::CachedPage; + let page = match outcome { + PollOutcome::Overview { overview, traffic } => { + let (Ok(o), Ok(t)) = (overview, traffic) else { + return; + }; + CachedPage::Overview { + overview: o.clone(), + traffic: t.clone(), + } + } + PollOutcome::Queries { + running, + slow, + failed, + } => { + let (Ok(r), Ok(s), Ok(f)) = (running, slow, failed) else { + return; + }; + CachedPage::Queries { + running: r.clone(), + slow: s.clone(), + failed: f.clone(), + } + } + PollOutcome::Merges(Ok(rows)) => CachedPage::Merges(rows.clone()), + PollOutcome::Replicas(Ok(rows)) => CachedPage::Replicas(rows.clone()), + PollOutcome::Health(Ok(h)) => CachedPage::Health(h.clone()), + PollOutcome::Tables(Ok(rows)) => CachedPage::Tables(rows.clone()), + PollOutcome::Traffic(Ok(t)) => CachedPage::Traffic(t.clone()), + _ => return, + }; + crate::cache::save(host, self.page, self.range, &page); + } + fn set_conn(&mut self, ok: bool, err: Option) { if ok { self.conn = ConnState::Connected; @@ -739,7 +920,16 @@ impl Shell { .child(host), ) .child(div().text_color(self.conn.color(cx)).child(status)) - .right(refreshed) + .right({ + let mut bits = vec![refreshed]; + if let Some(ms) = self.host_status.fetch_ms { + bits.push(format!("{ms:.0}ms")); + } + if let Some(rss) = self.host_status.rss_bytes { + bits.push(crate::widgets::geometry::format_bytes(rss)); + } + bits.join(" · ") + }) .children(update_el) } @@ -830,8 +1020,32 @@ impl Render for Shell { .pb_1() .gap_3() .child(div().text_lg().child(SharedString::from(self.page.title()))) + .when(self.fetching, |row| { + row.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("refreshing"), + ) + }) .child(div().flex_1()) - .when(show_range, |row| row.child(self.range_bar(cx))), + .when(show_range, |row| row.child(self.range_bar(cx))) + .child({ + let dark = crate::theme::current_mode(cx) + == gpui_component::ThemeMode::Dark; + Button::new("theme-toggle") + .ghost() + .xsmall() + .icon(if dark { + Icon::new(IconName::Sun) + } else { + Icon::new(IconName::Moon) + }) + .tooltip(if dark { "Light mode" } else { "Dark mode" }) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_dark(window, cx); + })) + }), ) .child( div() diff --git a/app/src/theme.rs b/app/src/theme.rs new file mode 100644 index 0000000..fb87f29 --- /dev/null +++ b/app/src/theme.rs @@ -0,0 +1,114 @@ +//! Brand theme matching the chmonitor.dev dashboard (Rhea: indigo primary, +//! amber charts, 10px radius, SF/system UI + Menlo). + +use gpui::{App, Hsla, rgb}; +use gpui_component::{Theme, ThemeMode}; + +fn hx(v: u32) -> Hsla { + rgb(v).into() +} + +/// Paint dashboard colors onto the active gpui-component theme. +pub fn apply_brand(cx: &mut App) { + let dark = Theme::global(cx).is_dark(); + let theme = Theme::global_mut(cx); + theme.font_size = gpui::px(14.); + theme.mono_font_size = gpui::px(12.); + theme.radius = gpui::px(10.); + theme.radius_lg = gpui::px(12.); + theme.mono_font_family = "Menlo".into(); + if dark { + paint_dark(theme); + } else { + paint_light(theme); + } + Theme::sync_base(cx); +} + +fn paint_light(theme: &mut Theme) { + theme.background = hx(0xffffff); + theme.foreground = hx(0x252525); + theme.secondary = hx(0xf4f4f7); + theme.secondary_foreground = hx(0x252525); + theme.muted = hx(0xf4f4f5); + theme.muted_foreground = hx(0x737373); + theme.accent = hx(0xf4f4f5); + theme.accent_foreground = hx(0x252525); + theme.primary = hx(0x4f46e5); + theme.primary_foreground = hx(0xf5f7ff); + theme.primary_hover = hx(0x4338ca); + theme.primary_active = hx(0x3730a3); + theme.button_primary = theme.primary; + theme.button_primary_foreground = theme.primary_foreground; + theme.button_primary_hover = theme.primary_hover; + theme.border = hx(0xe5e5e5); + theme.input = hx(0xe5e5e5); + theme.ring = hx(0xa5b4fc); + theme.danger = hx(0xe11d48); + theme.danger_foreground = hx(0xffffff); + theme.warning = hx(0xd97706); + theme.green = hx(0x16a34a); + theme.sidebar = hx(0xfafafa); + theme.sidebar_foreground = hx(0x252525); + theme.sidebar_accent = hx(0xf4f4f5); + theme.sidebar_accent_foreground = hx(0x252525); + theme.sidebar_border = hx(0xe5e5e5); + theme.sidebar_primary = hx(0x4f46e5); + theme.sidebar_primary_foreground = hx(0xf5f7ff); + theme.chart_1 = hx(0xeab308); + theme.chart_2 = hx(0xf59e0b); + theme.chart_3 = hx(0xf97316); + theme.chart_4 = hx(0xea580c); + theme.chart_5 = hx(0xc2410c); + theme.skeleton = hx(0xe5e5e5); + theme.popover = hx(0xffffff); + theme.popover_foreground = hx(0x252525); +} + +fn paint_dark(theme: &mut Theme) { + theme.background = hx(0x171717); + theme.foreground = hx(0xfafafa); + theme.secondary = hx(0x2a2a2e); + theme.secondary_foreground = hx(0xfafafa); + theme.muted = hx(0x262626); + theme.muted_foreground = hx(0xa1a1aa); + theme.accent = hx(0x262626); + theme.accent_foreground = hx(0xfafafa); + theme.primary = hx(0x818cf8); + theme.primary_foreground = hx(0x1e1b4b); + theme.primary_hover = hx(0xa5b4fc); + theme.primary_active = hx(0x6366f1); + theme.button_primary = theme.primary; + theme.button_primary_foreground = theme.primary_foreground; + theme.button_primary_hover = theme.primary_hover; + theme.border = hx(0x3f3f46); + theme.input = hx(0x3f3f46); + theme.ring = hx(0x818cf8); + theme.danger = hx(0xfb7185); + theme.danger_foreground = hx(0x1c1917); + theme.warning = hx(0xfbbf24); + theme.green = hx(0x4ade80); + theme.sidebar = hx(0x2a2a2a); + theme.sidebar_foreground = hx(0xfafafa); + theme.sidebar_accent = hx(0x3f3f46); + theme.sidebar_accent_foreground = hx(0xfafafa); + theme.sidebar_border = hx(0x3f3f46); + theme.sidebar_primary = hx(0x818cf8); + theme.sidebar_primary_foreground = hx(0x1e1b4b); + theme.chart_1 = hx(0xeab308); + theme.chart_2 = hx(0xf59e0b); + theme.chart_3 = hx(0xf97316); + theme.chart_4 = hx(0xea580c); + theme.chart_5 = hx(0xc2410c); + theme.skeleton = hx(0x3f3f46); + theme.popover = hx(0x2a2a2a); + theme.popover_foreground = hx(0xfafafa); +} + +pub fn current_mode(cx: &App) -> ThemeMode { + if Theme::global(cx).is_dark() { + ThemeMode::Dark + } else { + ThemeMode::Light + } +} diff --git a/app/src/widgets/cards.rs b/app/src/widgets/cards.rs index 3148d0e..5c5ac99 100644 --- a/app/src/widgets/cards.rs +++ b/app/src/widgets/cards.rs @@ -12,24 +12,25 @@ pub fn metric_card(label: &str, value: &str, sub: Option<&str>, cx: &App) -> imp div() .flex() .flex_col() - .gap(px(4.)) - .p(px(12.)) + .gap(px(6.)) + .p(px(14.)) .min_w(px(140.)) .flex_1() - .bg(cx.theme().secondary) + .bg(cx.theme().background) .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius) .child( div() - .text_sm() + .text_xs() + .font_weight(FontWeight::MEDIUM) .text_color(cx.theme().muted_foreground) .child(label), ) .child( div() - .text_xl() - .font_weight(FontWeight::SEMIBOLD) + .text_size(px(26.)) + .font_weight(FontWeight::BOLD) .text_color(cx.theme().foreground) .child(value), ) diff --git a/app/src/widgets/mod.rs b/app/src/widgets/mod.rs index dbb3be3..e2bccac 100644 --- a/app/src/widgets/mod.rs +++ b/app/src/widgets/mod.rs @@ -5,6 +5,7 @@ pub mod cards; pub mod chart; pub mod controls; pub mod geometry; +pub mod skeleton; pub mod table; pub use cards::metric_card; diff --git a/app/src/widgets/skeleton.rs b/app/src/widgets/skeleton.rs new file mode 100644 index 0000000..982bf39 --- /dev/null +++ b/app/src/widgets/skeleton.rs @@ -0,0 +1,73 @@ +//! Dashboard-style skeleton placeholders (gpui-component Skeleton). + +use gpui::{App, div, prelude::*, px}; +use gpui_component::{ActiveTheme as _, skeleton::Skeleton}; + +fn bone(w: f32, h: f32) -> Skeleton { + Skeleton::new().w(px(w)).h(px(h)).rounded_md() +} + +pub fn metric_grid(cx: &App) -> impl IntoElement { + let border = cx.theme().border; + let radius = cx.theme().radius; + div() + .flex() + .flex_col() + .gap_3() + .w_full() + .children((0..3).map(|_| { + div().flex().flex_row().gap_3().children((0..4).map(|_| { + div() + .flex() + .flex_col() + .gap_2() + .p_3() + .flex_1() + .min_w(px(140.)) + .border_1() + .border_color(border) + .rounded(radius) + .child(bone(72., 10.)) + .child(bone(96., 22.)) + })) + })) +} + +pub fn chart_block(cx: &App) -> impl IntoElement { + div() + .flex() + .flex_col() + .gap_2() + .w_full() + .h(px(200.)) + .p_3() + .border_1() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .child(bone(120., 12.)) + .child(div().flex_1().w_full().child(bone(400., 140.))) +} + +pub fn table_block(cx: &App) -> impl IntoElement { + let border = cx.theme().border; + div() + .flex() + .flex_col() + .w_full() + .border_1() + .border_color(border) + .rounded(cx.theme().radius) + .children((0..8).map(|i| { + div() + .flex() + .flex_row() + .gap_3() + .px_3() + .py_2() + .when(i > 0, |r| r.border_t_1().border_color(border)) + .child(bone(80., 10.)) + .child(bone(140., 10.)) + .child(bone(60., 10.)) + .child(div().flex_1().child(bone(180., 10.))) + })) +} diff --git a/crates/chm-telemetry/Cargo.toml b/crates/chm-telemetry/Cargo.toml index 05a2c29..88f9158 100644 --- a/crates/chm-telemetry/Cargo.toml +++ b/crates/chm-telemetry/Cargo.toml @@ -12,6 +12,8 @@ chrono.workspace = true reqwest.workspace = true tracing.workspace = true chm-update.workspace = true +sha2 = "0.10" +dirs.workspace = true [dev-dependencies] tokio.workspace = true diff --git a/crates/chm-telemetry/src/lib.rs b/crates/chm-telemetry/src/lib.rs index b93f20b..ff33f6e 100644 --- a/crates/chm-telemetry/src/lib.rs +++ b/crates/chm-telemetry/src/lib.rs @@ -10,7 +10,7 @@ //! [`TelemetryConfig::endpoint`]. use std::collections::VecDeque; -use std::sync::Mutex; +use std::sync::{Mutex, OnceLock}; use chm_update::Channel; use serde::{Deserialize, Serialize}; @@ -126,6 +126,7 @@ impl RingBuffer { pub struct PerfMetrics { frame_ms: Mutex, fetch_ms: Mutex, + rss_bytes: Mutex>, } impl Default for PerfMetrics { @@ -139,6 +140,7 @@ impl PerfMetrics { Self { frame_ms: Mutex::new(RingBuffer::new()), fetch_ms: Mutex::new(RingBuffer::new()), + rss_bytes: Mutex::new(None), } } @@ -170,6 +172,18 @@ impl PerfMetrics { self.fetch_ms.lock().unwrap().samples.len() } + pub fn record_rss(&self, bytes: u64) { + *self.rss_bytes.lock().unwrap() = Some(bytes); + } + + pub fn last_rss(&self) -> Option { + *self.rss_bytes.lock().unwrap() + } + + pub fn last_fetch_ms(&self) -> Option { + self.fetch_ms.lock().unwrap().samples.back().copied() + } + pub fn reset(&mut self) { *self.frame_ms.lock().unwrap() = RingBuffer::new(); *self.fetch_ms.lock().unwrap() = RingBuffer::new(); @@ -234,10 +248,144 @@ impl Event { } /// Allowlist of event names. Anything else is rejected at construction. -const ALLOWED_EVENT_NAMES: &[&str] = &["app_launch", "app_quit", "page_view", "query_executed"]; +const ALLOWED_EVENT_NAMES: &[&str] = &[ + "app_launch", + "app_quit", + "app_loaded", + "page_view", + "cluster_connected", + "health_viewed", + "queries_viewed", + "query_executed", +]; /// Page-name allowlist for `page_view.props.page`. -pub const ALLOWED_PAGE_NAMES: &[&str] = &["overview", "queries", "settings"]; +pub const ALLOWED_PAGE_NAMES: &[&str] = &[ + "overview", "queries", "merges", "replicas", "health", "tables", "traffic", "connect", + "settings", +]; + +pub fn http_client() -> &'static reqwest::Client { + static HTTP: OnceLock = OnceLock::new(); + HTTP.get_or_init(reqwest::Client::new) +} + +/// Production ingest (opt-in only). +pub const TELEMETRY_PING_URL: &str = "https://telemetry.chmonitor.dev/v1/ping"; +pub const TELEMETRY_EVENT_URL: &str = "https://telemetry.chmonitor.dev/v1/event"; + +/// Opaque 64-char hex install id, persisted under the user config dir. +pub fn install_id() -> String { + let path = dirs::config_dir().map(|d| d.join("chmonitor").join("install_id")); + if let Some(path) = &path + && let Ok(existing) = std::fs::read_to_string(path) + { + let trimmed = existing.trim().to_ascii_lowercase(); + if trimmed.len() == 64 && trimmed.bytes().all(|b| b.is_ascii_hexdigit()) { + return trimmed; + } + } + let mut raw = [0u8; 32]; + #[cfg(unix)] + { + if let Ok(mut f) = std::fs::File::open("/dev/urandom") { + use std::io::Read; + let _ = f.read_exact(&mut raw); + } + } + if raw.iter().all(|b| *b == 0) { + let seed = format!( + "{}:{}:{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0), + std::env::consts::OS + ); + raw = sha256_bytes(seed.as_bytes()); + } + let hex = hex64(&raw); + if let Some(path) = path { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(path, &hex); + } + hex +} + +fn sha256_bytes(input: &[u8]) -> [u8; 32] { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(input); + h.finalize().into() +} + +fn hex64(bytes: &[u8; 32]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Current process RSS in bytes (best-effort; `ps` on Unix). +pub fn rss_bytes() -> Option { + let pid = std::process::id().to_string(); + let out = std::process::Command::new("ps") + .args(["-o", "rss=", "-p", &pid]) + .output() + .ok()?; + let kb: u64 = String::from_utf8_lossy(&out.stdout).trim().parse().ok()?; + Some(kb.saturating_mul(1024)) +} + +/// POST `/v1/ping` so this install is counted. No-op when `enabled` is false. +pub async fn ping(http: &reqwest::Client, cfg: &TelemetryConfig) -> Result<()> { + if !cfg.enabled { + return Ok(()); + } + let body = json!({ + "instance_hash": install_id(), + "deploy_target": "unknown", + "platform": match std::env::consts::OS { + "macos" => "macos", + "linux" => "linux", + "windows" => "windows", + _ => "unknown", + }, + "chm_version": cfg.app_version, + }); + http.post(TELEMETRY_PING_URL) + .json(&body) + .send() + .await? + .error_for_status()?; + Ok(()) +} + +/// POST `/v1/event` (worker allowlist). No-op when disabled. +pub async fn track(http: &reqwest::Client, cfg: &TelemetryConfig, event: &str) -> Result<()> { + if !cfg.enabled { + return Ok(()); + } + if !matches!( + event, + "app_loaded" | "cluster_connected" | "health_viewed" | "queries_viewed" | "ai_query_sent" + ) { + return Err(Error::DisallowedEvent(event.into())); + } + let body = json!({ + "event": event, + "props": { + "deploy_target": "unknown", + "ch_flavor": "unknown", + } + }); + http.post(TELEMETRY_EVENT_URL) + .json(&body) + .send() + .await? + .error_for_status()?; + Ok(()) +} /// Queues events and flushes them to the configured endpoint. /// @@ -486,6 +634,15 @@ mod tests { assert_eq!(frames.p99, 594.0); } + #[test] + fn install_id_is_64_hex_and_stable() { + let a = install_id(); + let b = install_id(); + assert_eq!(a.len(), 64); + assert!(a.bytes().all(|c| c.is_ascii_hexdigit())); + assert_eq!(a, b); + } + #[test] fn perf_rejects_non_finite_and_negative_samples() { let metrics = PerfMetrics::new(); From 4beb802c8cfe697023f240249625e86122d8107c Mon Sep 17 00:00:00 2001 From: duyetbot Date: Mon, 24 Aug 2026 19:40:07 +0700 Subject: [PATCH 13/20] feat(app): native title bar and compact customizable overview Use gpui-component TitleBar with traffic lights, host switcher, range, and theme/settings in the chrome. Overview defaults to six live-health metrics and compact density; Settings picks density, tiles, chart, and sidebar. --- README.md | 12 +- app/src/config.rs | 70 +++++++++- app/src/density.rs | 272 ++++++++++++++++++++++++++++++++++++ app/src/lib.rs | 1 + app/src/main.rs | 15 +- app/src/pages/health.rs | 7 +- app/src/pages/overview.rs | 143 +++++++------------ app/src/pages/settings.rs | 202 +++++++++++++++++++++++++- app/src/shell.rs | 190 ++++++++++++++++++------- app/src/theme.rs | 19 ++- app/src/widgets/cards.rs | 25 ++-- app/src/widgets/controls.rs | 2 +- app/src/widgets/skeleton.rs | 50 ++++--- app/src/widgets/table.rs | 13 +- 14 files changed, 820 insertions(+), 201 deletions(-) create mode 100644 app/src/density.rs diff --git a/README.md b/README.md index be5296a..7343383 100644 --- a/README.md +++ b/README.md @@ -49,10 +49,14 @@ CHM_CONFIG=/tmp/chmonitor.toml cargo run -p chm-app Named profiles live under `[profiles.]` in `config.toml`; the default connection is `[profile]`. `r` refreshes the current page; keys `1`–`8` switch sidebar destinations; `cmd-b` toggles the sidebar; -`cmd-,` opens Settings; the sun/moon control in the title bar toggles -light/dark. Pages restore from a local cache so they paint immediately, -then refresh; skeletons show when nothing is cached yet. The sidebar host switcher lists `[profile]` plus -`[profiles.]`; Connect's optional Name field saves a named host. +`cmd-,` opens Settings. The native title bar holds the host switcher, +time range, light/dark, and Settings. Overview defaults to compact +density and the six live-health metrics (qps, running, slow, failed, +replicas, disk); Settings can restore a roomier layout or pick which +tiles show. Pages restore from a local cache so they paint immediately, +then refresh; skeletons show when nothing is cached yet. Hosts are +`[profile]` plus `[profiles.]`; Connect's optional Name field +saves a named host. ## Layout diff --git a/app/src/config.rs b/app/src/config.rs index a2386a6..3ba3144 100644 --- a/app/src/config.rs +++ b/app/src/config.rs @@ -50,15 +50,44 @@ pub struct TelemetrySection { pub enabled: bool, } -/// `[ui]` table — appearance preference (`system` / `light` / `dark`) -/// and the selected host id (`default` or a `[profiles.*]` key). -#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +/// `[ui]` table — appearance, density, visible Overview metrics, host. +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] pub struct UiSection { #[serde(default)] pub appearance: Option, /// Active host: `"default"` for `[profile]`, or a `[profiles.]` key. #[serde(default)] pub host: Option, + /// `"compact"` (default) or `"comfortable"`. + #[serde(default)] + pub density: Option, + /// Overview tile ids (`qps`, `running`, `slow`, `failed`, `replicas`, + /// `disk`, …). Empty means the default six. + #[serde(default)] + pub overview_metrics: Vec, + /// Show the queries/sec sparkline on Overview. Default true. + #[serde(default = "default_true")] + pub show_chart: bool, + /// Start with the sidebar collapsed to an icon strip. Default false. + #[serde(default)] + pub compact_sidebar: bool, + /// Show fetch latency and RSS in the status bar. Default true. + #[serde(default = "default_true")] + pub show_perf: bool, +} + +impl Default for UiSection { + fn default() -> Self { + Self { + appearance: None, + host: None, + density: None, + overview_metrics: Vec::new(), + show_chart: true, + compact_sidebar: false, + show_perf: true, + } + } } fn default_true() -> bool { @@ -347,6 +376,9 @@ Config (`config.toml`): [update] enabled = true # check on launch (default) auto_download = false # fetch the archive without a click + [ui] + density = \"compact\" # or comfortable + overview_metrics = [] # empty = qps, running, slow, failed, replicas, disk "; impl Cli { @@ -529,18 +561,50 @@ user = "alice" let path = write_cfg(""); let mut cfg = ConfigFile::default(); cfg.ui.appearance = Some("light".into()); + cfg.ui.density = Some("comfortable".into()); + cfg.ui.overview_metrics = vec!["qps".into(), "replicas".into()]; + cfg.ui.show_chart = false; cfg.profile.channel = Some("beta".into()); cfg.telemetry.enabled = true; cfg.update.auto_download = true; save_config_to(&path, &cfg).unwrap(); let back = load_config_from(&path); assert_eq!(back.ui.appearance.as_deref(), Some("light")); + assert_eq!(back.ui.density.as_deref(), Some("comfortable")); + assert_eq!(back.ui.overview_metrics, vec!["qps", "replicas"]); + assert!(!back.ui.show_chart); assert_eq!(back.profile.channel.as_deref(), Some("beta")); assert!(back.telemetry.enabled); assert!(back.update.enabled); assert!(back.update.auto_download); } + #[test] + fn ui_section_defaults_compact_and_chart_on() { + let cfg: ConfigFile = toml::from_str("").unwrap(); + assert!(cfg.ui.show_chart); + assert!(cfg.ui.show_perf); + assert!(!cfg.ui.compact_sidebar); + assert!(cfg.ui.overview_metrics.is_empty()); + assert!(cfg.ui.density.is_none()); + let cfg: ConfigFile = toml::from_str( + r#" +[ui] +density = "comfortable" +overview_metrics = ["qps", "disk"] +show_chart = false +compact_sidebar = true +show_perf = false +"#, + ) + .unwrap(); + assert_eq!(cfg.ui.density.as_deref(), Some("comfortable")); + assert_eq!(cfg.ui.overview_metrics, vec!["qps", "disk"]); + assert!(!cfg.ui.show_chart); + assert!(cfg.ui.compact_sidebar); + assert!(!cfg.ui.show_perf); + } + #[test] fn update_section_defaults_to_enabled() { let cfg: ConfigFile = toml::from_str("").unwrap(); diff --git a/app/src/density.rs b/app/src/density.rs new file mode 100644 index 0000000..68a08d4 --- /dev/null +++ b/app/src/density.rs @@ -0,0 +1,272 @@ +//! Layout density and which Overview metrics to show. +//! +//! Compact is the default: tighter chrome, the six metrics that answer +//! "is this cluster healthy right now?", optional sparkline. Comfortable +//! restores the roomier dashboard. Both are `[ui]` keys in config.toml. + +use crate::config::load_config; + +/// Card / chrome spacing. Unknown or missing config → Compact. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Density { + Compact, + Comfortable, +} + +impl Density { + pub const ALL: [Density; 2] = [Density::Compact, Density::Comfortable]; + + pub fn from_cfg(s: Option<&str>) -> Self { + match s.map(|s| s.to_ascii_lowercase()).as_deref() { + Some("comfortable") => Self::Comfortable, + _ => Self::Compact, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Compact => "compact", + Self::Comfortable => "comfortable", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Compact => "Compact", + Self::Comfortable => "Comfortable", + } + } + + pub fn hint(self) -> &'static str { + match self { + Self::Compact => "tighter cards, key metrics first", + Self::Comfortable => "roomier dashboard", + } + } + + pub fn current() -> Self { + Self::from_cfg(load_config().ui.density.as_deref()) + } + + pub fn font_size(self) -> f32 { + match self { + Self::Compact => 13.0, + Self::Comfortable => 14.0, + } + } + + pub fn mono_font_size(self) -> f32 { + match self { + Self::Compact => 11.0, + Self::Comfortable => 12.0, + } + } + + pub fn radius(self) -> f32 { + match self { + Self::Compact => 6.0, + Self::Comfortable => 10.0, + } + } + + pub fn radius_lg(self) -> f32 { + self.radius() + 2.0 + } + + pub fn card_pad(self) -> f32 { + match self { + Self::Compact => 8.0, + Self::Comfortable => 14.0, + } + } + + pub fn card_gap(self) -> f32 { + match self { + Self::Compact => 6.0, + Self::Comfortable => 10.0, + } + } + + pub fn card_value(self) -> f32 { + match self { + Self::Compact => 18.0, + Self::Comfortable => 24.0, + } + } + + pub fn card_min_w(self) -> f32 { + match self { + Self::Compact => 108.0, + Self::Comfortable => 140.0, + } + } + + pub fn metrics_per_row(self) -> usize { + match self { + Self::Compact => 3, + Self::Comfortable => 4, + } + } + + pub fn chart_h(self) -> f32 { + match self { + Self::Compact => 128.0, + Self::Comfortable => 200.0, + } + } + + pub fn content_pad(self) -> f32 { + match self { + Self::Compact => 12.0, + Self::Comfortable => 16.0, + } + } + + pub fn table_px(self) -> f32 { + match self { + Self::Compact => 8.0, + Self::Comfortable => 12.0, + } + } + + pub fn table_py(self) -> f32 { + match self { + Self::Compact => 4.0, + Self::Comfortable => 8.0, + } + } +} + +/// One Overview tile. Ids are the `[ui].overview_metrics` strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OverviewMetric { + Qps, + Running, + Slow, + Failed, + Merges, + Replicas, + Tables, + Parts, + Disk, + Uptime, + Version, +} + +impl OverviewMetric { + pub const ALL: [OverviewMetric; 11] = [ + Self::Qps, + Self::Running, + Self::Slow, + Self::Failed, + Self::Merges, + Self::Replicas, + Self::Tables, + Self::Parts, + Self::Disk, + Self::Uptime, + Self::Version, + ]; + + /// Default visible set: live load + replica/disk health. + pub const DEFAULT: [OverviewMetric; 6] = [ + Self::Qps, + Self::Running, + Self::Slow, + Self::Failed, + Self::Replicas, + Self::Disk, + ]; + + pub fn id(self) -> &'static str { + match self { + Self::Qps => "qps", + Self::Running => "running", + Self::Slow => "slow", + Self::Failed => "failed", + Self::Merges => "merges", + Self::Replicas => "replicas", + Self::Tables => "tables", + Self::Parts => "parts", + Self::Disk => "disk", + Self::Uptime => "uptime", + Self::Version => "version", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Qps => "queries / sec", + Self::Running => "running queries", + Self::Slow => "slow · 24h", + Self::Failed => "failed · 24h", + Self::Merges => "active merges", + Self::Replicas => "replicas", + Self::Tables => "tables", + Self::Parts => "parts", + Self::Disk => "disk used", + Self::Uptime => "uptime", + Self::Version => "version", + } + } + + pub fn from_id(s: &str) -> Option { + Self::ALL.iter().copied().find(|m| m.id() == s) + } +} + +/// Resolve the Overview tile list. Empty or all-unknown → the default six. +pub fn visible_metrics(ids: &[String]) -> Vec { + let parsed: Vec = ids + .iter() + .filter_map(|s| OverviewMetric::from_id(s)) + .collect(); + if parsed.is_empty() { + OverviewMetric::DEFAULT.to_vec() + } else { + parsed + } +} + +pub fn default_metric_ids() -> Vec { + OverviewMetric::DEFAULT + .iter() + .map(|m| m.id().to_string()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn density_parses_with_compact_default() { + assert_eq!(Density::from_cfg(None), Density::Compact); + assert_eq!(Density::from_cfg(Some("compact")), Density::Compact); + assert_eq!(Density::from_cfg(Some("COMFORTABLE")), Density::Comfortable); + assert_eq!(Density::from_cfg(Some("nope")), Density::Compact); + assert_eq!(Density::Compact.as_str(), "compact"); + assert_eq!(Density::Comfortable.metrics_per_row(), 4); + assert!(Density::Compact.card_pad() < Density::Comfortable.card_pad()); + assert!(Density::Compact.chart_h() < Density::Comfortable.chart_h()); + } + + #[test] + fn visible_metrics_falls_back_to_default() { + assert_eq!(visible_metrics(&[]), OverviewMetric::DEFAULT); + assert_eq!( + visible_metrics(&["nope".into(), "also-nope".into()]), + OverviewMetric::DEFAULT + ); + assert_eq!( + visible_metrics(&["qps".into(), "disk".into()]), + vec![OverviewMetric::Qps, OverviewMetric::Disk] + ); + assert_eq!( + OverviewMetric::from_id("running"), + Some(OverviewMetric::Running) + ); + assert!(OverviewMetric::from_id("nope").is_none()); + assert_eq!(default_metric_ids().len(), 6); + } +} diff --git a/app/src/lib.rs b/app/src/lib.rs index 111a1ae..5ed419b 100644 --- a/app/src/lib.rs +++ b/app/src/lib.rs @@ -7,6 +7,7 @@ pub mod cache; pub mod config; pub mod connect; +pub mod density; pub mod pages; pub mod shell; pub mod theme; diff --git a/app/src/main.rs b/app/src/main.rs index 1259c13..24e80a2 100644 --- a/app/src/main.rs +++ b/app/src/main.rs @@ -4,10 +4,10 @@ use chm_app::config::{Cli, CliError, load_config}; use chm_app::pages::settings::{appearance_from_cfg, apply_appearance}; use chm_app::shell::{OpenSettings, Refresh, Shell, ToggleSidebar}; use gpui::{ - App, AppContext as _, Bounds, Focusable as _, KeyBinding, Menu, MenuItem, SharedString, - TitlebarOptions, WindowBounds, WindowOptions, actions, px, size, + App, AppContext as _, Bounds, Focusable as _, KeyBinding, Menu, MenuItem, WindowBounds, + WindowOptions, actions, px, size, }; -use gpui_component::Root; +use gpui_component::{Root, TitleBar}; actions!(chm_app, [Quit]); @@ -64,15 +64,12 @@ fn main() { ]); let appearance = appearance_from_cfg(load_config().ui.appearance.as_deref()); - let bounds = Bounds::centered(None, size(px(1280.0), px(800.0)), cx); + let bounds = Bounds::centered(None, size(px(1100.0), px(720.0)), cx); cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), - titlebar: Some(TitlebarOptions { - title: Some(SharedString::from("chmonitor")), - ..Default::default() - }), - ..Default::default() + window_min_size: Some(size(px(720.0), px(480.0))), + ..TitleBar::window_options() }, move |window, cx| { apply_appearance(appearance, window, cx); diff --git a/app/src/pages/health.rs b/app/src/pages/health.rs index 27b8f4c..b7b17f6 100644 --- a/app/src/pages/health.rs +++ b/app/src/pages/health.rs @@ -51,16 +51,17 @@ impl Render for HealthPage { "{:.0}%", (h.background_pool_utilization * 100.0).clamp(0.0, 999.0) ); + let gap = px(crate::density::Density::current().card_gap()); div() .flex() .flex_col() - .gap(px(10.)) + .gap(gap) .w_full() .child( div() .flex() .flex_row() - .gap(px(10.)) + .gap(gap) .child(metric_card( "status", if h.ok { "ok" } else { "not ok" }, @@ -94,7 +95,7 @@ impl Render for HealthPage { div() .flex() .flex_row() - .gap(px(10.)) + .gap(gap) .child(metric_card( "delayed inserts", &h.delayed_inserts.to_string(), diff --git a/app/src/pages/overview.rs b/app/src/pages/overview.rs index c0bb618..b950dea 100644 --- a/app/src/pages/overview.rs +++ b/app/src/pages/overview.rs @@ -1,10 +1,13 @@ -//! Overview page — metric cards plus a queries/sec sparkline. -//! Renders whatever `Shell` fetched (mock data under CHM_SMOKE=1). +//! Overview page — the handful of metrics that answer "is this cluster +//! healthy?", plus an optional queries/sec sparkline. Visible tiles and +//! density come from `[ui]` (Settings). use chm_core::{Overview, TrafficSeries}; use gpui::{Context, Render, Window, div, prelude::*, px}; +use crate::config::load_config; +use crate::density::{Density, OverviewMetric, visible_metrics}; use crate::pages::status; use crate::widgets::geometry::{format_bytes, format_count}; use crate::widgets::{NamedSeries, line_chart, metric_card}; @@ -59,95 +62,23 @@ impl Render for OverviewPage { return crate::widgets::skeleton::metric_grid(cx).into_any_element(); }; - let used_pct = 100.0 * o.disk_used_bytes as f64 / o.disk_total_bytes.max(1) as f64; - let disk_sub = format!( - "{} · {:.0}% used", - format_bytes(o.disk_total_bytes), - used_pct - ); + let ui = load_config().ui; + let density = Density::from_cfg(ui.density.as_deref()); + let metrics = visible_metrics(&ui.overview_metrics); + let gap = px(density.card_gap()); + let per_row = density.metrics_per_row(); - let mut grid = div().flex().flex_col().gap(px(10.)).w_full(); - grid = grid.child( - div() - .flex() - .flex_row() - .gap(px(10.)) - .child(metric_card( - "queries / sec", - &format!("{:.1}", o.qps), - None, - cx, - )) - .child(metric_card( - "running", - &o.running_queries.to_string(), - None, - cx, - )) - .child(metric_card( - "slow · 24h", - &o.slow_queries_24h.to_string(), - None, - cx, - )) - .child(metric_card( - "failed · 24h", - &o.failed_queries_24h.to_string(), - None, - cx, - )), - ); - grid = grid.child( - div() - .flex() - .flex_row() - .gap(px(10.)) - .child(metric_card( - "active merges", - &o.active_merges.to_string(), - None, - cx, - )) - .child(metric_card( - "replicas", - &format!("{} / {}", o.replicas_ok, o.replicas_total), - None, - cx, - )) - .child(metric_card( - "tables", - &format_count(o.tables_total as f64), - None, - cx, - )) - .child(metric_card( - "parts", - &format_count(o.parts_total as f64), - None, - cx, - )), - ); - grid = grid.child( - div() - .flex() - .flex_row() - .gap(px(10.)) - .child(metric_card( - "disk used", - &format_bytes(o.disk_used_bytes), - Some(&disk_sub), - cx, - )) - .child(metric_card( - "uptime", - &fmt_uptime(o.uptime_seconds), - None, - cx, - )) - .child(metric_card("version", &o.clickhouse_version, None, cx)), - ); + let mut grid = div().flex().flex_col().gap(gap).w_full(); + for chunk in metrics.chunks(per_row) { + let mut row = div().flex().flex_row().gap(gap); + for metric in chunk { + row = row.child(tile(*metric, o, cx)); + } + grid = grid.child(row); + } - if let Some(t) = &self.traffic + if ui.show_chart + && let Some(t) = &self.traffic && !t.queries_per_sec.is_empty() { grid = grid.child( @@ -155,7 +86,7 @@ impl Render for OverviewPage { .flex() .flex_col() .w_full() - .h(px(220.)) + .h(px(density.chart_h())) .child(line_chart( "queries / sec", "qps", @@ -172,6 +103,38 @@ impl Render for OverviewPage { } } +fn tile(metric: OverviewMetric, o: &Overview, cx: &gpui::App) -> impl gpui::IntoElement { + let (label, value, sub) = match metric { + OverviewMetric::Qps => ("queries / sec", format!("{:.1}", o.qps), None), + OverviewMetric::Running => ("running", o.running_queries.to_string(), None), + OverviewMetric::Slow => ("slow · 24h", o.slow_queries_24h.to_string(), None), + OverviewMetric::Failed => ("failed · 24h", o.failed_queries_24h.to_string(), None), + OverviewMetric::Merges => ("active merges", o.active_merges.to_string(), None), + OverviewMetric::Replicas => ( + "replicas", + format!("{} / {}", o.replicas_ok, o.replicas_total), + None, + ), + OverviewMetric::Tables => ("tables", format_count(o.tables_total as f64), None), + OverviewMetric::Parts => ("parts", format_count(o.parts_total as f64), None), + OverviewMetric::Disk => { + let used_pct = 100.0 * o.disk_used_bytes as f64 / o.disk_total_bytes.max(1) as f64; + ( + "disk used", + format_bytes(o.disk_used_bytes), + Some(format!( + "{} · {:.0}% used", + format_bytes(o.disk_total_bytes), + used_pct + )), + ) + } + OverviewMetric::Uptime => ("uptime", fmt_uptime(o.uptime_seconds), None), + OverviewMetric::Version => ("version", o.clickhouse_version.clone(), None), + }; + metric_card(label, value, sub, cx) +} + fn fmt_uptime(secs: u64) -> String { let d = secs / 86_400; let h = (secs % 86_400) / 3_600; diff --git a/app/src/pages/settings.rs b/app/src/pages/settings.rs index e80f8f3..62a4ad4 100644 --- a/app/src/pages/settings.rs +++ b/app/src/pages/settings.rs @@ -4,9 +4,12 @@ use chm_update::Channel; use gpui::{App, Context, Render, Window, div, prelude::*, px}; -use gpui_component::{ActiveTheme as _, Theme, ThemeMode, h_flex, v_flex}; +use gpui_component::{ + ActiveTheme as _, Sizable as _, Theme, ThemeMode, checkbox::Checkbox, h_flex, v_flex, +}; use crate::config::{config_path, load_config, save_config}; +use crate::density::{Density, OverviewMetric, default_metric_ids}; use crate::pages::heading; use crate::widgets::controls::{choice_radio, radio_group, theme_switch}; @@ -39,6 +42,11 @@ impl Appearance { pub struct SettingsPage { appearance: Appearance, + density: Density, + overview_metrics: Vec, + show_chart: bool, + compact_sidebar: bool, + show_perf: bool, channel: Channel, telemetry: bool, update_enabled: bool, @@ -55,8 +63,18 @@ impl Default for SettingsPage { impl SettingsPage { pub fn new() -> Self { let cfg = load_config(); + let overview_metrics = if cfg.ui.overview_metrics.is_empty() { + default_metric_ids() + } else { + cfg.ui.overview_metrics.clone() + }; Self { appearance: appearance_from_cfg(cfg.ui.appearance.as_deref()), + density: Density::from_cfg(cfg.ui.density.as_deref()), + overview_metrics, + show_chart: cfg.ui.show_chart, + compact_sidebar: cfg.ui.compact_sidebar, + show_perf: cfg.ui.show_perf, channel: channel_from_cfg(cfg.profile.channel.as_deref()), telemetry: cfg.telemetry.enabled, update_enabled: cfg.update.enabled, @@ -68,6 +86,11 @@ impl SettingsPage { fn persist(&mut self) { let mut cfg = load_config(); cfg.ui.appearance = Some(appearance_to_cfg(self.appearance).into()); + cfg.ui.density = Some(self.density.as_str().into()); + cfg.ui.overview_metrics = self.overview_metrics.clone(); + cfg.ui.show_chart = self.show_chart; + cfg.ui.compact_sidebar = self.compact_sidebar; + cfg.ui.show_perf = self.show_perf; cfg.profile.channel = Some(self.channel.as_str().into()); cfg.telemetry.enabled = self.telemetry; cfg.update.enabled = self.update_enabled; @@ -105,6 +128,52 @@ impl SettingsPage { self.persist(); cx.notify(); } + + fn set_density(&mut self, density: Density, window: &mut Window, cx: &mut Context) { + self.density = density; + self.persist(); + crate::theme::apply_brand(cx); + window.refresh(); + cx.notify(); + } + + fn set_metric(&mut self, metric: OverviewMetric, on: bool, cx: &mut Context) { + let id = metric.id(); + if on { + if !self.overview_metrics.iter().any(|s| s == id) { + self.overview_metrics.push(id.into()); + } + } else { + self.overview_metrics.retain(|s| s != id); + if self.overview_metrics.is_empty() { + self.overview_metrics = default_metric_ids(); + } + } + self.persist(); + cx.notify(); + } + + fn set_show_chart(&mut self, enabled: bool, cx: &mut Context) { + self.show_chart = enabled; + self.persist(); + cx.notify(); + } + + fn set_compact_sidebar(&mut self, enabled: bool, cx: &mut Context) { + self.compact_sidebar = enabled; + self.persist(); + cx.notify(); + } + + fn set_show_perf(&mut self, enabled: bool, cx: &mut Context) { + self.show_perf = enabled; + self.persist(); + cx.notify(); + } + + fn metric_on(&self, metric: OverviewMetric) -> bool { + self.overview_metrics.iter().any(|s| s == metric.id()) + } } impl Render for SettingsPage { @@ -113,6 +182,10 @@ impl Render for SettingsPage { .map(|p| p.display().to_string()) .unwrap_or_else(|| "(no config directory)".into()); let appearance = self.appearance; + let density = self.density; + let show_chart = self.show_chart; + let compact_sidebar = self.compact_sidebar; + let show_perf = self.show_perf; let channel = self.channel; let telemetry = self.telemetry; let update_enabled = self.update_enabled; @@ -146,6 +219,133 @@ impl Render for SettingsPage { } group }) + .child(heading("Density")) + .child({ + let entity = cx.entity().downgrade(); + let mut group = radio_group("density"); + for &mode in &Density::ALL { + let entity = entity.clone(); + group = group.child( + choice_radio( + format!("den-{}", mode.as_str()), + density == mode, + mode.label(), + mode.hint(), + cx, + ) + .on_change(move |next, _, window, cx| { + if next { + let _ = entity.update(cx, |this, cx| { + this.set_density(mode, window, cx); + }); + } + }), + ); + } + group + }) + .child(heading("Overview metrics")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("tiles on Overview — defaults to load, errors, replicas, disk"), + ) + .child({ + let entity = cx.entity().downgrade(); + let mut cols = h_flex().gap_4().items_start(); + for column in OverviewMetric::ALL.chunks(6) { + let mut col = v_flex().gap_2().flex_1(); + for &metric in column { + let on = self.metric_on(metric); + let entity = entity.clone(); + col = col.child( + Checkbox::new(format!("m-{}", metric.id())) + .label(metric.label()) + .checked(on) + .small() + .on_click(move |next, _, cx| { + let on = *next; + let _ = entity.update(cx, |this, cx| { + this.set_metric(metric, on, cx); + }); + }), + ); + } + cols = cols.child(col); + } + cols + }) + .child({ + let entity = cx.entity().downgrade(); + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child(div().text_sm().child("Queries / sec chart")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("sparkline under the metric tiles"), + ), + ) + .child(theme_switch("show-chart", show_chart, cx).on_change( + move |next, _, _, cx| { + let _ = entity.update(cx, |this, cx| this.set_show_chart(next, cx)); + }, + )) + }) + .child({ + let entity = cx.entity().downgrade(); + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child(div().text_sm().child("Compact sidebar")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("start with the icon strip (⌘B still toggles)"), + ), + ) + .child(theme_switch("compact-sidebar", compact_sidebar, cx).on_change( + move |next, _, _, cx| { + let _ = + entity.update(cx, |this, cx| this.set_compact_sidebar(next, cx)); + }, + )) + }) + .child({ + let entity = cx.entity().downgrade(); + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex() + .gap_1() + .child(div().text_sm().child("Status bar timing")) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("fetch latency and memory in the status bar"), + ), + ) + .child(theme_switch("show-perf", show_perf, cx).on_change( + move |next, _, _, cx| { + let _ = entity.update(cx, |this, cx| this.set_show_perf(next, cx)); + }, + )) + }) .child(heading("Updates")) .child({ let entity = cx.entity().downgrade(); diff --git a/app/src/shell.rs b/app/src/shell.rs index c0215ce..ff6b075 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -10,17 +10,20 @@ use chm_core::{ }; use gpui::{ - App, AppContext as _, AsyncApp, Context, Entity, FocusHandle, Focusable, Hsla, KeyBinding, - KeyDownEvent, Render, SharedString, WeakEntity, Window, actions, div, prelude::*, px, + App, AppContext as _, AsyncApp, Context, Entity, FocusHandle, Focusable, FontWeight, Hsla, + KeyBinding, KeyDownEvent, MouseButton, Render, SharedString, WeakEntity, Window, actions, div, + prelude::*, px, }; use gpui_component::{ - ActiveTheme as _, Icon, IconName, Root, Sizable as _, + ActiveTheme as _, Icon, IconName, Root, Sizable as _, TitleBar, button::{Button, ButtonVariants as _}, h_flex, + menu::{DropdownMenu as _, PopupMenuItem}, sidebar::{ Sidebar, SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarToggleButton, }, + spinner::Spinner, status_bar::StatusBar, v_flex, }; @@ -50,7 +53,7 @@ const POLL_SECS: u64 = 30; /// Viewport width below which the sidebar collapses to an icon strip. const COMPACT_BELOW: f32 = 900.0; /// Sidebar width expanded / collapsed. -const SIDEBAR_W: f32 = 190.0; +const SIDEBAR_W: f32 = 176.0; /// Perf metrics live for the whole process; recording is gated by /// `[telemetry] enabled=true` in config.toml (never on by default). @@ -230,7 +233,11 @@ impl Shell { } else { UpdateUi::Disabled }, - sidebar_collapsed: None, + sidebar_collapsed: if load_config().ui.compact_sidebar { + Some(true) + } else { + None + }, active_host, host_status: HostStatus::default(), fetching: false, @@ -828,7 +835,114 @@ impl Shell { ), ), ) - .footer(SidebarFooter::new().child("chmonitor")) + .footer( + SidebarFooter::new().child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("v{}", env!("CARGO_PKG_VERSION"))), + ), + ) + } + + fn host_switcher(&self, cx: &mut Context) -> impl IntoElement { + let entity = cx.entity().downgrade(); + let hosts = self.hosts(); + let label = self.active_host_label(); + let active = self.active_host.clone(); + Button::new("host-switch") + .ghost() + .compact() + .xsmall() + .label(label) + .dropdown_caret(true) + .dropdown_menu(move |menu, _, _| { + let mut menu = menu; + for host in &hosts { + let id = host.id.clone(); + let entity = entity.clone(); + let selected = active.as_deref() == Some(id.as_str()); + menu = menu.item( + PopupMenuItem::new(host.label.clone()) + .icon(Self::host_icon(host.profile.mode.as_deref())) + .checked(selected) + .on_click(move |_, _, cx| { + let _ = + entity.update(cx, |this, cx| this.switch_host(id.clone(), cx)); + }), + ); + } + let entity = entity.clone(); + menu.separator().item( + PopupMenuItem::new("Add host") + .icon(IconName::Plus) + .on_click(move |_, _, cx| { + let _ = entity.update(cx, |this, cx| { + this.page = Page::Connect; + cx.notify(); + }); + }), + ) + }) + } + + fn render_title_bar(&self, show_range: bool, cx: &mut Context) -> impl IntoElement { + let fetching = self.fetching; + let muted = cx.theme().muted_foreground; + TitleBar::new() + .child( + h_flex() + .items_center() + .gap_2() + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(SharedString::from(self.page.title())), + ) + .when(fetching, |row| { + row.child(Spinner::new().xsmall().color(muted)) + }), + ) + .child( + h_flex() + .items_center() + .justify_end() + .gap_2() + .px_2() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .child(self.host_switcher(cx)) + .when(show_range, |row| row.child(self.range_bar(cx))) + .child({ + let dark = + crate::theme::current_mode(cx) == gpui_component::ThemeMode::Dark; + Button::new("theme-toggle") + .ghost() + .compact() + .xsmall() + .icon(if dark { + Icon::new(IconName::Sun) + } else { + Icon::new(IconName::Moon) + }) + .tooltip(if dark { "Light mode" } else { "Dark mode" }) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_dark(window, cx); + })) + }) + .child( + Button::new("open-settings") + .ghost() + .compact() + .xsmall() + .icon(Icon::new(IconName::Settings)) + .tooltip("Settings") + .on_click(cx.listener(|this, _, _, cx| { + this.page = Page::Settings; + cx.notify(); + })), + ), + ) } fn range_bar(&self, cx: &mut Context) -> impl IntoElement { @@ -922,11 +1036,13 @@ impl Shell { .child(div().text_color(self.conn.color(cx)).child(status)) .right({ let mut bits = vec![refreshed]; - if let Some(ms) = self.host_status.fetch_ms { - bits.push(format!("{ms:.0}ms")); - } - if let Some(rss) = self.host_status.rss_bytes { - bits.push(crate::widgets::geometry::format_bytes(rss)); + if load_config().ui.show_perf { + if let Some(ms) = self.host_status.fetch_ms { + bits.push(format!("{ms:.0}ms")); + } + if let Some(rss) = self.host_status.rss_bytes { + bits.push(crate::widgets::geometry::format_bytes(rss)); + } } bits.join(" · ") }) @@ -966,7 +1082,8 @@ impl Render for Shell { let compact = sidebar_is_compact(self.sidebar_collapsed, narrow); let show_range = self.page.uses_range() && self.source.is_some(); - h_flex() + let pad = crate::density::Density::current().content_pad(); + v_flex() .id("shell") .key_context("Shell") .track_focus(&self.focus) @@ -1007,59 +1124,26 @@ impl Render for Shell { .size_full() .bg(cx.theme().background) .text_color(cx.theme().foreground) - .child(self.render_sidebar(compact, cx)) + .child(self.render_title_bar(show_range, cx)) .child( - v_flex() + h_flex() .flex_1() - .min_w_0() - .child( - h_flex() - .items_center() - .px_4() - .pt_3() - .pb_1() - .gap_3() - .child(div().text_lg().child(SharedString::from(self.page.title()))) - .when(self.fetching, |row| { - row.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("refreshing"), - ) - }) - .child(div().flex_1()) - .when(show_range, |row| row.child(self.range_bar(cx))) - .child({ - let dark = crate::theme::current_mode(cx) - == gpui_component::ThemeMode::Dark; - Button::new("theme-toggle") - .ghost() - .xsmall() - .icon(if dark { - Icon::new(IconName::Sun) - } else { - Icon::new(IconName::Moon) - }) - .tooltip(if dark { "Light mode" } else { "Dark mode" }) - .on_click(cx.listener(|this, _, window, cx| { - this.toggle_dark(window, cx); - })) - }), - ) + .min_h_0() + .child(self.render_sidebar(compact, cx)) .child( div() .id("content-scroll") .flex() .flex_col() .flex_1() + .min_w_0() .min_h_0() - .p_4() + .p(px(pad)) .overflow_y_scroll() .child(self.content(cx)), - ) - .child(self.status_bar(cx)), + ), ) + .child(self.status_bar(cx)) .children(Root::render_notification_layer(window, cx)) } } diff --git a/app/src/theme.rs b/app/src/theme.rs index fb87f29..0262ff1 100644 --- a/app/src/theme.rs +++ b/app/src/theme.rs @@ -4,6 +4,8 @@ use gpui::{App, Hsla, rgb}; use gpui_component::{Theme, ThemeMode}; +use crate::density::Density; + fn hx(v: u32) -> Hsla { rgb(v).into() } @@ -11,11 +13,12 @@ fn hx(v: u32) -> Hsla { /// Paint dashboard colors onto the active gpui-component theme. pub fn apply_brand(cx: &mut App) { let dark = Theme::global(cx).is_dark(); + let density = Density::current(); let theme = Theme::global_mut(cx); - theme.font_size = gpui::px(14.); - theme.mono_font_size = gpui::px(12.); - theme.radius = gpui::px(10.); - theme.radius_lg = gpui::px(12.); + theme.font_size = gpui::px(density.font_size()); + theme.mono_font_size = gpui::px(density.mono_font_size()); + theme.radius = gpui::px(density.radius()); + theme.radius_lg = gpui::px(density.radius_lg()); theme.mono_font_family = "Menlo".into(); if dark { paint_dark(theme); @@ -63,6 +66,10 @@ fn paint_light(theme: &mut Theme) { theme.skeleton = hx(0xe5e5e5); theme.popover = hx(0xffffff); theme.popover_foreground = hx(0x252525); + theme.title_bar = hx(0xfafafa); + theme.title_bar_border = hx(0xe5e5e5); + theme.status_bar = hx(0xfafafa); + theme.status_bar_border = hx(0xe5e5e5); } fn paint_dark(theme: &mut Theme) { @@ -103,6 +110,10 @@ fn paint_dark(theme: &mut Theme) { theme.skeleton = hx(0x3f3f46); theme.popover = hx(0x2a2a2a); theme.popover_foreground = hx(0xfafafa); + theme.title_bar = hx(0x1c1c1c); + theme.title_bar_border = hx(0x3f3f46); + theme.status_bar = hx(0x1c1c1c); + theme.status_bar_border = hx(0x3f3f46); } pub fn current_mode(cx: &App) -> ThemeMode { diff --git a/app/src/widgets/cards.rs b/app/src/widgets/cards.rs index 5c5ac99..dc5a0ef 100644 --- a/app/src/widgets/cards.rs +++ b/app/src/widgets/cards.rs @@ -3,18 +3,25 @@ use gpui::{App, FontWeight, div, prelude::*, px}; use gpui_component::ActiveTheme as _; +use crate::density::Density; + /// A stat tile: small muted label, large value, optional muted sub-line. -pub fn metric_card(label: &str, value: &str, sub: Option<&str>, cx: &App) -> impl IntoElement { - let label = label.to_string(); - let value = value.to_string(); - let sub = sub.map(str::to_string); +pub fn metric_card( + label: impl Into, + value: impl Into, + sub: Option, + cx: &App, +) -> impl IntoElement { + let label = label.into(); + let value = value.into(); + let d = Density::current(); div() .flex() .flex_col() - .gap(px(6.)) - .p(px(14.)) - .min_w(px(140.)) + .gap(px(4.)) + .p(px(d.card_pad())) + .min_w(px(d.card_min_w())) .flex_1() .bg(cx.theme().background) .border_1() @@ -29,8 +36,8 @@ pub fn metric_card(label: &str, value: &str, sub: Option<&str>, cx: &App) -> imp ) .child( div() - .text_size(px(26.)) - .font_weight(FontWeight::BOLD) + .text_size(px(d.card_value())) + .font_weight(FontWeight::SEMIBOLD) .text_color(cx.theme().foreground) .child(value), ) diff --git a/app/src/widgets/controls.rs b/app/src/widgets/controls.rs index 888b839..ce666bc 100644 --- a/app/src/widgets/controls.rs +++ b/app/src/widgets/controls.rs @@ -103,7 +103,7 @@ pub fn range_toggle( Toggle::new(id) .pressed(pressed) .px_2() - .h_7() + .h_6() .flex() .items_center() .justify_center() diff --git a/app/src/widgets/skeleton.rs b/app/src/widgets/skeleton.rs index 982bf39..a90f9a1 100644 --- a/app/src/widgets/skeleton.rs +++ b/app/src/widgets/skeleton.rs @@ -3,6 +3,8 @@ use gpui::{App, div, prelude::*, px}; use gpui_component::{ActiveTheme as _, skeleton::Skeleton}; +use crate::density::Density; + fn bone(w: f32, h: f32) -> Skeleton { Skeleton::new().w(px(w)).h(px(h)).rounded_md() } @@ -10,42 +12,50 @@ fn bone(w: f32, h: f32) -> Skeleton { pub fn metric_grid(cx: &App) -> impl IntoElement { let border = cx.theme().border; let radius = cx.theme().radius; + let d = Density::current(); + let per_row = d.metrics_per_row(); + let rows = 6usize.div_ceil(per_row); div() .flex() .flex_col() - .gap_3() + .gap(px(d.card_gap())) .w_full() - .children((0..3).map(|_| { - div().flex().flex_row().gap_3().children((0..4).map(|_| { - div() - .flex() - .flex_col() - .gap_2() - .p_3() - .flex_1() - .min_w(px(140.)) - .border_1() - .border_color(border) - .rounded(radius) - .child(bone(72., 10.)) - .child(bone(96., 22.)) - })) + .children((0..rows).map(move |_| { + div() + .flex() + .flex_row() + .gap(px(d.card_gap())) + .children((0..per_row).map(move |_| { + div() + .flex() + .flex_col() + .gap_2() + .p(px(d.card_pad())) + .flex_1() + .min_w(px(d.card_min_w())) + .border_1() + .border_color(border) + .rounded(radius) + .child(bone(64., 8.)) + .child(bone(80., 16.)) + })) })) } pub fn chart_block(cx: &App) -> impl IntoElement { + let d = Density::current(); div() .flex() .flex_col() .gap_2() .w_full() - .h(px(200.)) - .p_3() + .h(px(d.chart_h())) + .p(px(d.card_pad())) .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius) - .child(bone(120., 12.)) - .child(div().flex_1().w_full().child(bone(400., 140.))) + .child(bone(96., 10.)) + .child(div().flex_1().w_full().child(bone(360., d.chart_h() - 36.))) } pub fn table_block(cx: &App) -> impl IntoElement { diff --git a/app/src/widgets/table.rs b/app/src/widgets/table.rs index 0698c37..cbbb713 100644 --- a/app/src/widgets/table.rs +++ b/app/src/widgets/table.rs @@ -5,6 +5,8 @@ use gpui::{App, ElementId, div, prelude::*, px}; use gpui_base::{Table, TableBody, TableCell, TableHead, TableHeader, TableRow}; use gpui_component::ActiveTheme as _; +use crate::density::Density; + use super::geometry::{format_bytes, format_count, format_duration_ms}; /// One cell's value; the variant picks the formatter and the alignment. @@ -57,12 +59,15 @@ pub fn data_table( let header_bg = cx.theme().secondary; let n_cols = columns.len(); let n_rows = rows.len(); + let d = Density::current(); + let px_cell = d.table_px(); + let py_cell = d.table_py(); let header = TableHeader::new("header").child(TableRow::new("header-row", 1).flex().children( columns.iter().enumerate().map(|(i, col)| { let mut head = TableHead::new(("head", i), i + 1) - .px_3() - .py_2() + .px(px(px_cell)) + .py(px(py_cell)) .text_xs() .text_color(muted) .child(col.name.clone()); @@ -86,8 +91,8 @@ pub fn data_table( let mut cell = match cells.get(i) { Some(value) => { let mut c = TableCell::new(format!("cell-{row_ix}-{i}"), i + 1) - .px_3() - .py_1() + .px(px(px_cell)) + .py(px(py_cell)) .text_xs() .child(value.display()); if value.numeric() { From 015e6cb67e4f6488c953b8dc63b874cbc2c11969 Mon Sep 17 00:00:00 2001 From: duyetbot Date: Mon, 24 Aug 2026 19:47:52 +0700 Subject: [PATCH 14/20] feat(app): use dash.chmonitor.dev overview KPIs Port the four dashboard headline cards (Active Queries, Schema, Storage, Uptime) plus the matching system.tables / query_log SQL. Settings still lets you add the extra tiles. --- README.md | 8 +- app/src/config.rs | 2 +- app/src/density.rs | 64 +++++------ app/src/pages/overview.rs | 134 +++++++++++++++++++---- app/src/pages/settings.rs | 2 +- app/src/widgets/cards.rs | 63 +++++++++-- app/src/widgets/mod.rs | 2 +- app/src/widgets/skeleton.rs | 2 +- crates/chm-clickhouse/src/lib.rs | 33 +++++- crates/chm-cloud-api/src/lib.rs | 2 + crates/chm-core/src/lib.rs | 13 +++ crates/chm-core/tests/serde_roundtrip.rs | 2 + crates/chm-postgres/src/lib.rs | 3 + 13 files changed, 260 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 7343383..cfc8779 100644 --- a/README.md +++ b/README.md @@ -50,10 +50,10 @@ Named profiles live under `[profiles.]` in `config.toml`; the default connection is `[profile]`. `r` refreshes the current page; keys `1`–`8` switch sidebar destinations; `cmd-b` toggles the sidebar; `cmd-,` opens Settings. The native title bar holds the host switcher, -time range, light/dark, and Settings. Overview defaults to compact -density and the six live-health metrics (qps, running, slow, failed, -replicas, disk); Settings can restore a roomier layout or pick which -tiles show. Pages restore from a local cache so they paint immediately, +time range, light/dark, and Settings. Overview defaults to the four +[dash.chmonitor.dev](https://dash.chmonitor.dev) KPI cards (active queries, +schema, storage, uptime); Settings can restore a roomier layout or pick +which tiles show. Pages restore from a local cache so they paint immediately, then refresh; skeletons show when nothing is cached yet. Hosts are `[profile]` plus `[profiles.]`; Connect's optional Name field saves a named host. diff --git a/app/src/config.rs b/app/src/config.rs index 3ba3144..a2a7556 100644 --- a/app/src/config.rs +++ b/app/src/config.rs @@ -378,7 +378,7 @@ Config (`config.toml`): auto_download = false # fetch the archive without a click [ui] density = \"compact\" # or comfortable - overview_metrics = [] # empty = qps, running, slow, failed, replicas, disk + overview_metrics = [] # empty = running, schema, disk, uptime "; impl Cli { diff --git a/app/src/density.rs b/app/src/density.rs index 68a08d4..1c71451 100644 --- a/app/src/density.rs +++ b/app/src/density.rs @@ -1,8 +1,8 @@ //! Layout density and which Overview metrics to show. //! -//! Compact is the default: tighter chrome, the six metrics that answer -//! "is this cluster healthy right now?", optional sparkline. Comfortable -//! restores the roomier dashboard. Both are `[ui]` keys in config.toml. +//! Compact is the default: tighter chrome, the four dash.chmonitor.dev +//! KPI cards (active queries, schema, storage, uptime), optional sparkline. +//! Comfortable restores the roomier dashboard. Both are `[ui]` keys. use crate::config::load_config; @@ -103,8 +103,7 @@ impl Density { pub fn metrics_per_row(self) -> usize { match self { - Self::Compact => 3, - Self::Comfortable => 4, + Self::Compact | Self::Comfortable => 4, } } @@ -140,72 +139,70 @@ impl Density { /// One Overview tile. Ids are the `[ui].overview_metrics` strings. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OverviewMetric { - Qps, Running, + Schema, + Disk, + Uptime, + Qps, Slow, Failed, Merges, Replicas, Tables, Parts, - Disk, - Uptime, Version, } impl OverviewMetric { - pub const ALL: [OverviewMetric; 11] = [ - Self::Qps, + pub const ALL: [OverviewMetric; 12] = [ Self::Running, + Self::Schema, + Self::Disk, + Self::Uptime, + Self::Qps, Self::Slow, Self::Failed, Self::Merges, Self::Replicas, Self::Tables, Self::Parts, - Self::Disk, - Self::Uptime, Self::Version, ]; - /// Default visible set: live load + replica/disk health. - pub const DEFAULT: [OverviewMetric; 6] = [ - Self::Qps, - Self::Running, - Self::Slow, - Self::Failed, - Self::Replicas, - Self::Disk, - ]; + /// Default visible set: the four dash.chmonitor.dev overview KPIs. + pub const DEFAULT: [OverviewMetric; 4] = + [Self::Running, Self::Schema, Self::Disk, Self::Uptime]; pub fn id(self) -> &'static str { match self { - Self::Qps => "qps", Self::Running => "running", + Self::Schema => "schema", + Self::Disk => "disk", + Self::Uptime => "uptime", + Self::Qps => "qps", Self::Slow => "slow", Self::Failed => "failed", Self::Merges => "merges", Self::Replicas => "replicas", Self::Tables => "tables", Self::Parts => "parts", - Self::Disk => "disk", - Self::Uptime => "uptime", Self::Version => "version", } } pub fn label(self) -> &'static str { match self { + Self::Running => "Active Queries", + Self::Schema => "Schema", + Self::Disk => "Storage", + Self::Uptime => "Uptime", Self::Qps => "queries / sec", - Self::Running => "running queries", Self::Slow => "slow · 24h", Self::Failed => "failed · 24h", Self::Merges => "active merges", Self::Replicas => "replicas", Self::Tables => "tables", Self::Parts => "parts", - Self::Disk => "disk used", - Self::Uptime => "uptime", Self::Version => "version", } } @@ -246,13 +243,13 @@ mod tests { assert_eq!(Density::from_cfg(Some("COMFORTABLE")), Density::Comfortable); assert_eq!(Density::from_cfg(Some("nope")), Density::Compact); assert_eq!(Density::Compact.as_str(), "compact"); - assert_eq!(Density::Comfortable.metrics_per_row(), 4); + assert_eq!(Density::Compact.metrics_per_row(), 4); assert!(Density::Compact.card_pad() < Density::Comfortable.card_pad()); assert!(Density::Compact.chart_h() < Density::Comfortable.chart_h()); } #[test] - fn visible_metrics_falls_back_to_default() { + fn visible_metrics_falls_back_to_dashboard_kpis() { assert_eq!(visible_metrics(&[]), OverviewMetric::DEFAULT); assert_eq!( visible_metrics(&["nope".into(), "also-nope".into()]), @@ -263,10 +260,13 @@ mod tests { vec![OverviewMetric::Qps, OverviewMetric::Disk] ); assert_eq!( - OverviewMetric::from_id("running"), - Some(OverviewMetric::Running) + OverviewMetric::from_id("schema"), + Some(OverviewMetric::Schema) ); assert!(OverviewMetric::from_id("nope").is_none()); - assert_eq!(default_metric_ids().len(), 6); + assert_eq!( + default_metric_ids(), + ["running", "schema", "disk", "uptime"] + ); } } diff --git a/app/src/pages/overview.rs b/app/src/pages/overview.rs index b950dea..414ead0 100644 --- a/app/src/pages/overview.rs +++ b/app/src/pages/overview.rs @@ -10,7 +10,7 @@ use crate::config::load_config; use crate::density::{Density, OverviewMetric, visible_metrics}; use crate::pages::status; use crate::widgets::geometry::{format_bytes, format_count}; -use crate::widgets::{NamedSeries, line_chart, metric_card}; +use crate::widgets::{NamedSeries, kpi_card, line_chart}; pub struct OverviewPage { data: Option, @@ -104,35 +104,128 @@ impl Render for OverviewPage { } fn tile(metric: OverviewMetric, o: &Overview, cx: &gpui::App) -> impl gpui::IntoElement { - let (label, value, sub) = match metric { - OverviewMetric::Qps => ("queries / sec", format!("{:.1}", o.qps), None), - OverviewMetric::Running => ("running", o.running_queries.to_string(), None), - OverviewMetric::Slow => ("slow · 24h", o.slow_queries_24h.to_string(), None), - OverviewMetric::Failed => ("failed · 24h", o.failed_queries_24h.to_string(), None), - OverviewMetric::Merges => ("active merges", o.active_merges.to_string(), None), - OverviewMetric::Replicas => ( - "replicas", - format!("{} / {}", o.replicas_ok, o.replicas_total), + match metric { + OverviewMetric::Running => kpi_card( + "Active Queries", + o.running_queries.to_string(), + Some("running"), + Some(format!( + "{} queries today", + format_count(o.queries_today as f64) + )), None, + cx, ), - OverviewMetric::Tables => ("tables", format_count(o.tables_total as f64), None), - OverviewMetric::Parts => ("parts", format_count(o.parts_total as f64), None), + OverviewMetric::Schema => { + let unit = if o.databases_total == 1 { + "database" + } else { + "databases" + }; + let tables = if o.tables_total == 1 { + "1 table".into() + } else { + format!("{} tables", format_count(o.tables_total as f64)) + }; + kpi_card( + "Schema", + o.databases_total.to_string(), + Some(unit), + Some(tables), + None, + cx, + ) + } OverviewMetric::Disk => { let used_pct = 100.0 * o.disk_used_bytes as f64 / o.disk_total_bytes.max(1) as f64; - ( - "disk used", + let free = o.disk_total_bytes.saturating_sub(o.disk_used_bytes); + kpi_card( + "Storage", format_bytes(o.disk_used_bytes), + None, Some(format!( - "{} · {:.0}% used", + "{:.0}% of {} · {} free", + used_pct, format_bytes(o.disk_total_bytes), - used_pct + format_bytes(free) )), + Some(used_pct as f32), + cx, ) } - OverviewMetric::Uptime => ("uptime", fmt_uptime(o.uptime_seconds), None), - OverviewMetric::Version => ("version", o.clickhouse_version.clone(), None), - }; - metric_card(label, value, sub, cx) + OverviewMetric::Uptime => kpi_card( + "Uptime", + fmt_uptime(o.uptime_seconds), + None, + Some(o.clickhouse_version.clone()), + None, + cx, + ), + OverviewMetric::Qps => kpi_card( + "queries / sec", + format!("{:.1}", o.qps), + None, + None, + None, + cx, + ), + OverviewMetric::Slow => kpi_card( + "slow · 24h", + o.slow_queries_24h.to_string(), + None, + None, + None, + cx, + ), + OverviewMetric::Failed => kpi_card( + "failed · 24h", + o.failed_queries_24h.to_string(), + None, + None, + None, + cx, + ), + OverviewMetric::Merges => kpi_card( + "active merges", + o.active_merges.to_string(), + None, + None, + None, + cx, + ), + OverviewMetric::Replicas => kpi_card( + "replicas", + format!("{} / {}", o.replicas_ok, o.replicas_total), + None, + None, + None, + cx, + ), + OverviewMetric::Tables => kpi_card( + "tables", + format_count(o.tables_total as f64), + None, + None, + None, + cx, + ), + OverviewMetric::Parts => kpi_card( + "parts", + format_count(o.parts_total as f64), + None, + None, + None, + cx, + ), + OverviewMetric::Version => kpi_card( + "version", + o.clickhouse_version.clone(), + None, + None, + None, + cx, + ), + } } fn fmt_uptime(secs: u64) -> String { @@ -141,6 +234,7 @@ fn fmt_uptime(secs: u64) -> String { match (d, h) { (0, 0) => format!("{}m", secs / 60), (0, h) => format!("{h}h"), + (d, 0) => format!("{d}d"), (d, h) => format!("{d}d {h}h"), } } diff --git a/app/src/pages/settings.rs b/app/src/pages/settings.rs index 62a4ad4..053dfaa 100644 --- a/app/src/pages/settings.rs +++ b/app/src/pages/settings.rs @@ -249,7 +249,7 @@ impl Render for SettingsPage { div() .text_xs() .text_color(cx.theme().muted_foreground) - .child("tiles on Overview — defaults to load, errors, replicas, disk"), + .child("tiles on Overview — defaults to Active Queries, Schema, Storage, Uptime"), ) .child({ let entity = cx.entity().downgrade(); diff --git a/app/src/widgets/cards.rs b/app/src/widgets/cards.rs index dc5a0ef..289ece5 100644 --- a/app/src/widgets/cards.rs +++ b/app/src/widgets/cards.rs @@ -1,6 +1,8 @@ //! Metric card: a labeled headline number with an optional sub-line. +//! KPI variant matches dash.chmonitor.dev overview tiles (uppercase label, +//! unit, optional storage bar). -use gpui::{App, FontWeight, div, prelude::*, px}; +use gpui::{App, FontWeight, div, prelude::*, px, relative}; use gpui_component::ActiveTheme as _; use crate::density::Density; @@ -11,15 +13,34 @@ pub fn metric_card( value: impl Into, sub: Option, cx: &App, +) -> impl IntoElement { + kpi_card(label, value, None, sub, None, cx) +} + +/// Dashboard-style KPI: `LABEL` → `value unit` → optional bar → sub-line. +pub fn kpi_card( + label: impl Into, + value: impl Into, + unit: Option<&str>, + sub: Option, + progress: Option, + cx: &App, ) -> impl IntoElement { let label = label.into(); let value = value.into(); + let unit = unit.map(str::to_string); let d = Density::current(); + let pct = progress.map(|p| p.clamp(0.0, 100.0)); + let bar_color = match pct { + Some(p) if p > 90.0 => cx.theme().danger, + Some(p) if p > 75.0 => cx.theme().warning, + _ => cx.theme().primary, + }; div() .flex() .flex_col() - .gap(px(4.)) + .gap(px(6.)) .p(px(d.card_pad())) .min_w(px(d.card_min_w())) .flex_1() @@ -30,17 +51,45 @@ pub fn metric_card( .child( div() .text_xs() - .font_weight(FontWeight::MEDIUM) + .font_weight(FontWeight::SEMIBOLD) .text_color(cx.theme().muted_foreground) .child(label), ) .child( div() - .text_size(px(d.card_value())) - .font_weight(FontWeight::SEMIBOLD) - .text_color(cx.theme().foreground) - .child(value), + .flex() + .flex_row() + .items_baseline() + .gap(px(6.)) + .child( + div() + .text_size(px(d.card_value())) + .font_weight(FontWeight::BOLD) + .text_color(cx.theme().foreground) + .child(value), + ) + .children(unit.map(|unit| { + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(cx.theme().muted_foreground) + .child(unit) + })), ) + .children(pct.map(|p| { + div() + .w_full() + .h(px(4.)) + .rounded_full() + .bg(cx.theme().muted) + .child( + div() + .h_full() + .w(relative(p / 100.0)) + .rounded_full() + .bg(bar_color), + ) + })) .children(sub.map(|sub| { div() .text_xs() diff --git a/app/src/widgets/mod.rs b/app/src/widgets/mod.rs index e2bccac..3c1551c 100644 --- a/app/src/widgets/mod.rs +++ b/app/src/widgets/mod.rs @@ -8,6 +8,6 @@ pub mod geometry; pub mod skeleton; pub mod table; -pub use cards::metric_card; +pub use cards::{kpi_card, metric_card}; pub use chart::{NamedSeries, line_chart}; pub use table::{CellVal, Column, data_table}; diff --git a/app/src/widgets/skeleton.rs b/app/src/widgets/skeleton.rs index a90f9a1..e1275d2 100644 --- a/app/src/widgets/skeleton.rs +++ b/app/src/widgets/skeleton.rs @@ -14,7 +14,7 @@ pub fn metric_grid(cx: &App) -> impl IntoElement { let radius = cx.theme().radius; let d = Density::current(); let per_row = d.metrics_per_row(); - let rows = 6usize.div_ceil(per_row); + let rows = 4usize.div_ceil(per_row); div() .flex() .flex_col() diff --git a/crates/chm-clickhouse/src/lib.rs b/crates/chm-clickhouse/src/lib.rs index 3f63e30..b1782db 100644 --- a/crates/chm-clickhouse/src/lib.rs +++ b/crates/chm-clickhouse/src/lib.rs @@ -83,16 +83,26 @@ ORDER BY elapsed DESC LIMIT 100"#; /// chmonitor dashboard overview headline block. +/// Running / schema counts match `apps/dashboard/src/lib/api/charts/overview-charts.ts`. pub const Q_OVERVIEW_MAIN: &str = r#" SELECT - (SELECT count() FROM system.processes) AS running_queries, + (SELECT count() FROM system.processes WHERE is_cancelled = 0) AS running_queries, (SELECT count() FROM system.merges) + (SELECT countIf(NOT is_done) FROM system.mutations) AS active_merges, - (SELECT count() FROM system.tables) AS tables_total, + (SELECT countDistinct(database) FROM system.tables + WHERE lower(database) NOT IN ('system', 'information_schema')) AS databases_total, + (SELECT countDistinct(format('{}.{}', database, name)) FROM system.tables + WHERE lower(database) NOT IN ('system', 'information_schema')) AS tables_total, (SELECT count() FROM system.parts WHERE active) AS parts_total, (SELECT coalesce(sum(bytes_on_disk), 0) FROM system.parts WHERE active) AS disk_used_bytes, uptime() AS uptime_seconds, version() AS clickhouse_version"#; +/// Dashboard `query-count-today`: QueryFinish rows since local midnight. +pub const Q_OVERVIEW_TODAY: &str = r#" +SELECT count() AS v +FROM system.query_log +WHERE type = 'QueryFinish' AND toDate(event_time) = today()"#; + /// qps denominator comes from the caller-selected range window. pub const Q_OVERVIEW_QPS: &str = r#" SELECT countIf(type = 'QueryStart') AS started_queries @@ -298,6 +308,7 @@ impl DataSource for ClickHouseClient { .await?; let reps: Vec = self.query_rows(Q_OVERVIEW_REPLICAS).await?; let disk_total_bytes = self.scalar_u64(Q_OVERVIEW_DISKS).await?; + let queries_today = self.scalar_u64(Q_OVERVIEW_TODAY).await.unwrap_or(0); let main = main.into_iter().next().unwrap_or_default(); let qps_row = qps_row.into_iter().next().unwrap_or_default(); @@ -319,6 +330,8 @@ impl DataSource for ClickHouseClient { disk_total_bytes, uptime_seconds: main.uptime_seconds, clickhouse_version: main.clickhouse_version, + databases_total: main.databases_total, + queries_today, }) }) } @@ -596,6 +609,8 @@ mod raw { #[serde(default)] pub active_merges: u64, #[serde(default)] + pub databases_total: u64, + #[serde(default)] pub tables_total: u64, #[serde(default)] pub parts_total: u64, @@ -783,6 +798,15 @@ mod sql_snapshots { assert!(Q_RUNNING.contains("LIMIT 100")); } + #[test] + fn overview_main_matches_dashboard_kpis() { + assert!(Q_OVERVIEW_MAIN.contains("FROM system.processes WHERE is_cancelled = 0")); + assert!(Q_OVERVIEW_MAIN.contains("NOT IN ('system', 'information_schema')")); + assert!(Q_OVERVIEW_MAIN.contains("AS databases_total")); + assert!(Q_OVERVIEW_TODAY.contains("toDate(event_time) = today()")); + assert!(Q_OVERVIEW_TODAY.contains("type = 'QueryFinish'")); + } + #[test] fn slow_queries_shape() { assert!(Q_SLOW_QUERIES.contains("FROM system.query_log")); @@ -1165,11 +1189,12 @@ mod wiremock_tests { fn overview_routes() -> Vec<(&'static str, String)> { vec![ - ("uptime()", r#"{"running_queries":12,"active_merges":5,"tables_total":142,"parts_total":8931,"disk_used_bytes":549755813888,"uptime_seconds":1036800,"clickhouse_version":"25.3.1.1"}"#.to_string()), + ("uptime()", r#"{"running_queries":12,"active_merges":5,"databases_total":8,"tables_total":142,"parts_total":8931,"disk_used_bytes":549755813888,"uptime_seconds":1036800,"clickhouse_version":"25.3.1.1"}"#.to_string()), ("countIf(type = 'QueryStart')", r#"{"started_queries":462000}"#.to_string()), ("countIf(exception != '')", r#"{"slow_queries_24h":37,"failed_queries_24h":3}"#.to_string()), ("FROM system.replicas", r#"{"replicas_ok":3,"replicas_total":3}"#.to_string()), ("FROM system.disks", r#"{"v":1099511627776}"#.to_string()), + ("toDate(event_time) = today()", r#"{"v":48210}"#.to_string()), ] } @@ -1179,6 +1204,8 @@ mod wiremock_tests { let o = c.overview(TimeRange::SixHours).await.expect("overview"); assert_eq!(o.running_queries, 12); assert_eq!(o.active_merges, 5); + assert_eq!(o.databases_total, 8); + assert_eq!(o.queries_today, 48_210); assert_eq!(o.tables_total, 142); assert_eq!(o.parts_total, 8931); assert_eq!(o.disk_used_bytes, 512 << 30); diff --git a/crates/chm-cloud-api/src/lib.rs b/crates/chm-cloud-api/src/lib.rs index aa4213c..9500aac 100644 --- a/crates/chm-cloud-api/src/lib.rs +++ b/crates/chm-cloud-api/src/lib.rs @@ -303,6 +303,8 @@ fn overview_of(v: &Value) -> Overview { disk_total_bytes: first_u64(v, &["disk_total_bytes", "disk_total", "total_bytes"]), uptime_seconds: first_u64(v, &["uptime_seconds", "uptime"]), clickhouse_version: first_str(v, &["clickhouse_version", "version", "ch_version"]), + databases_total: first_u64(v, &["databases_total", "databases"]), + queries_today: first_u64(v, &["queries_today", "today_queries"]), } } diff --git a/crates/chm-core/src/lib.rs b/crates/chm-core/src/lib.rs index c01fd80..6b54358 100644 --- a/crates/chm-core/src/lib.rs +++ b/crates/chm-core/src/lib.rs @@ -129,6 +129,9 @@ pub struct TrafficSeries { } /// Headline numbers for the overview page. +/// +/// The four dashboard KPIs on dash.chmonitor.dev are running queries +/// (with queries today), schema (databases + tables), storage, and uptime. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct Overview { pub qps: f64, @@ -144,6 +147,12 @@ pub struct Overview { pub disk_total_bytes: u64, pub uptime_seconds: u64, pub clickhouse_version: String, + /// User databases, excluding `system` / `information_schema`. + #[serde(default)] + pub databases_total: u64, + /// QueryFinish rows on `today()` (dashboard `query-count-today`). + #[serde(default)] + pub queries_today: u64, } /// One query row (list views for running/slow/failed). @@ -282,6 +291,8 @@ impl DataSource for MockDataSource { disk_total_bytes: 1024_u64 * 1024 * 1024 * 1024, uptime_seconds: 86_400 * 12, clickhouse_version: "25.3.1.1 (smoke)".into(), + databases_total: 8, + queries_today: 48_210, }; // Vary by range so charts differ across selections in smoke shots. let mut o = base; @@ -571,6 +582,8 @@ mod tests { assert_eq!(o.replicas_ok, 3); assert_eq!(o.replicas_total, 3); assert_eq!(o.tables_total, 142); + assert_eq!(o.databases_total, 8); + assert_eq!(o.queries_today, 48_210); assert_eq!(o.parts_total, 8931); assert_eq!(o.disk_used_bytes, 512 * 1024 * 1024 * 1024); assert_eq!(o.disk_total_bytes, 1024_u64 * 1024 * 1024 * 1024); diff --git a/crates/chm-core/tests/serde_roundtrip.rs b/crates/chm-core/tests/serde_roundtrip.rs index 42389de..8fe4266 100644 --- a/crates/chm-core/tests/serde_roundtrip.rs +++ b/crates/chm-core/tests/serde_roundtrip.rs @@ -727,6 +727,8 @@ fn overview_round_trips_with_all_fields_populated() { disk_total_bytes: 1024_u64 * 1024 * 1024 * 1024, uptime_seconds: 86_400 * 12, clickhouse_version: "25.3.1.1 (smoke)".into(), + databases_total: 8, + queries_today: 48_210, }; assert_eq!(rt(&overview), overview); } diff --git a/crates/chm-postgres/src/lib.rs b/crates/chm-postgres/src/lib.rs index 63dfc84..e064374 100644 --- a/crates/chm-postgres/src/lib.rs +++ b/crates/chm-postgres/src/lib.rs @@ -199,6 +199,7 @@ impl DataSource for PostgresClient { SELECT (SELECT count(*)::bigint FROM pg_stat_activity WHERE state = 'active' AND pid <> pg_backend_pid()) AS running_queries, + (SELECT count(*)::bigint FROM pg_database WHERE datallowconn) AS databases_total, (SELECT count(*)::bigint FROM pg_stat_user_tables) AS tables_total, (SELECT coalesce(sum(pg_database_size(oid)), 0)::bigint FROM pg_database) AS disk_used_bytes, extract(epoch FROM (now() - pg_postmaster_start_time()))::bigint AS uptime_seconds, @@ -216,6 +217,7 @@ SELECT .await .map_err(map_pg_err)?; let running = get_i64(&row, "running_queries").max(0) as u64; + let databases = get_i64(&row, "databases_total").max(0) as u64; let tables = get_i64(&row, "tables_total").max(0) as u64; let disk = get_i64(&row, "disk_used_bytes").max(0) as u64; let uptime = get_i64(&row, "uptime_seconds").max(0) as u64; @@ -233,6 +235,7 @@ SELECT clickhouse_version: version, replicas_total, replicas_ok, + databases_total: databases, ..Overview::default() }) }) From e1df7549d384eb0af1f1977d858999b46de04c67 Mon Sep 17 00:00:00 2001 From: duyetbot Date: Mon, 24 Aug 2026 19:53:23 +0700 Subject: [PATCH 15/20] feat(app): make the left sidebar resizable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag the split between nav and content (140–360px). The width is saved as [ui].sidebar_width. Collapsed icon mode is unchanged. --- README.md | 1 + app/src/config.rs | 7 +++ app/src/shell.rs | 112 +++++++++++++++++++++++++++++++++++++--------- 3 files changed, 98 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index cfc8779..4472f93 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ CHM_CONFIG=/tmp/chmonitor.toml cargo run -p chm-app Named profiles live under `[profiles.]` in `config.toml`; the default connection is `[profile]`. `r` refreshes the current page; keys `1`–`8` switch sidebar destinations; `cmd-b` toggles the sidebar; +drag the sidebar edge to resize it (saved in `[ui].sidebar_width`); `cmd-,` opens Settings. The native title bar holds the host switcher, time range, light/dark, and Settings. Overview defaults to the four [dash.chmonitor.dev](https://dash.chmonitor.dev) KPI cards (active queries, diff --git a/app/src/config.rs b/app/src/config.rs index a2a7556..16ed038 100644 --- a/app/src/config.rs +++ b/app/src/config.rs @@ -71,6 +71,9 @@ pub struct UiSection { /// Start with the sidebar collapsed to an icon strip. Default false. #[serde(default)] pub compact_sidebar: bool, + /// Expanded sidebar width in pixels. Missing uses the app default. + #[serde(default)] + pub sidebar_width: Option, /// Show fetch latency and RSS in the status bar. Default true. #[serde(default = "default_true")] pub show_perf: bool, @@ -85,6 +88,7 @@ impl Default for UiSection { overview_metrics: Vec::new(), show_chart: true, compact_sidebar: false, + sidebar_width: None, show_perf: true, } } @@ -585,6 +589,7 @@ user = "alice" assert!(cfg.ui.show_chart); assert!(cfg.ui.show_perf); assert!(!cfg.ui.compact_sidebar); + assert!(cfg.ui.sidebar_width.is_none()); assert!(cfg.ui.overview_metrics.is_empty()); assert!(cfg.ui.density.is_none()); let cfg: ConfigFile = toml::from_str( @@ -595,6 +600,7 @@ overview_metrics = ["qps", "disk"] show_chart = false compact_sidebar = true show_perf = false +sidebar_width = 220 "#, ) .unwrap(); @@ -602,6 +608,7 @@ show_perf = false assert_eq!(cfg.ui.overview_metrics, vec!["qps", "disk"]); assert!(!cfg.ui.show_chart); assert!(cfg.ui.compact_sidebar); + assert_eq!(cfg.ui.sidebar_width, Some(220)); assert!(!cfg.ui.show_perf); } diff --git a/app/src/shell.rs b/app/src/shell.rs index ff6b075..f60160d 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -12,13 +12,14 @@ use chm_core::{ use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, FocusHandle, Focusable, FontWeight, Hsla, KeyBinding, KeyDownEvent, MouseButton, Render, SharedString, WeakEntity, Window, actions, div, - prelude::*, px, + prelude::*, px, relative, }; use gpui_component::{ ActiveTheme as _, Icon, IconName, Root, Sizable as _, TitleBar, button::{Button, ButtonVariants as _}, - h_flex, + h_flex, h_resizable, menu::{DropdownMenu as _, PopupMenuItem}, + resizable_panel, sidebar::{ Sidebar, SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarToggleButton, @@ -54,6 +55,18 @@ const POLL_SECS: u64 = 30; const COMPACT_BELOW: f32 = 900.0; /// Sidebar width expanded / collapsed. const SIDEBAR_W: f32 = 176.0; +const SIDEBAR_W_MIN: f32 = 140.0; +const SIDEBAR_W_MAX: f32 = 360.0; + +fn clamp_sidebar_width(width: f32) -> f32 { + width.clamp(SIDEBAR_W_MIN, SIDEBAR_W_MAX) +} + +fn sidebar_width_from_cfg(width: Option) -> f32 { + width + .map(|w| clamp_sidebar_width(w as f32)) + .unwrap_or(SIDEBAR_W) +} /// Perf metrics live for the whole process; recording is gated by /// `[telemetry] enabled=true` in config.toml (never on by default). @@ -132,6 +145,8 @@ pub struct Shell { update: UpdateUi, /// `None` follows the viewport; `Some` is a click/`cmd-b` override. sidebar_collapsed: Option, + /// Expanded sidebar width in px (drag-handle, persisted as `[ui].sidebar_width`). + sidebar_width: f32, active_host: Option, host_status: HostStatus, fetching: bool, @@ -238,6 +253,7 @@ impl Shell { } else { None }, + sidebar_width: sidebar_width_from_cfg(load_config().ui.sidebar_width), active_host, host_status: HostStatus::default(), fetching: false, @@ -303,6 +319,18 @@ impl Shell { cx.notify(); } + fn set_sidebar_width(&mut self, width: f32, cx: &mut Context) { + let width = clamp_sidebar_width(width); + if (self.sidebar_width - width).abs() < 0.5 { + return; + } + self.sidebar_width = width; + let mut cfg = load_config(); + cfg.ui.sidebar_width = Some(width.round() as u32); + let _ = save_config(&cfg); + cx.notify(); + } + fn apply_host(&mut self, host_id: String, profile: ProfileConfig) { self.active_host = Some(host_id); self.source = source_from_profile(&profile).map(Arc::new); @@ -805,7 +833,8 @@ impl Shell { Sidebar::new("nav") .collapsed(compact) .collapsible(true) - .w(px(SIDEBAR_W)) + .when(compact, |sb| sb.w(px(self.sidebar_width))) + .when(!compact, |sb| sb.w(relative(1.))) .header( SidebarHeader::new().child( h_flex().w_full().items_center().justify_between().child( @@ -1083,6 +1112,52 @@ impl Render for Shell { let show_range = self.page.uses_range() && self.source.is_some(); let pad = crate::density::Density::current().content_pad(); + let content = div() + .id("content-scroll") + .flex() + .flex_col() + .flex_1() + .min_w_0() + .min_h_0() + .p(px(pad)) + .overflow_y_scroll() + .child(self.content(cx)); + let split = if compact { + h_flex() + .flex_1() + .min_h_0() + .child(self.render_sidebar(true, cx)) + .child(content) + .into_any_element() + } else { + let entity = cx.entity().downgrade(); + div() + .flex_1() + .min_h_0() + .h_full() + .child( + h_resizable("shell-split") + .on_resize(move |state, _, cx| { + let width = state + .read(cx) + .sizes() + .first() + .copied() + .map(f32::from) + .unwrap_or(SIDEBAR_W); + let _ = + entity.update(cx, |this, cx| this.set_sidebar_width(width, cx)); + }) + .child( + resizable_panel() + .size(px(self.sidebar_width)) + .size_range(px(SIDEBAR_W_MIN)..px(SIDEBAR_W_MAX)) + .child(self.render_sidebar(false, cx)), + ) + .child(resizable_panel().child(content)), + ) + .into_any_element() + }; v_flex() .id("shell") .key_context("Shell") @@ -1125,24 +1200,7 @@ impl Render for Shell { .bg(cx.theme().background) .text_color(cx.theme().foreground) .child(self.render_title_bar(show_range, cx)) - .child( - h_flex() - .flex_1() - .min_h_0() - .child(self.render_sidebar(compact, cx)) - .child( - div() - .id("content-scroll") - .flex() - .flex_col() - .flex_1() - .min_w_0() - .min_h_0() - .p(px(pad)) - .overflow_y_scroll() - .child(self.content(cx)), - ), - ) + .child(split) .child(self.status_bar(cx)) .children(Root::render_notification_layer(window, cx)) } @@ -1237,7 +1295,7 @@ fn sidebar_is_compact(user: Option, narrow: bool) -> bool { #[cfg(test)] mod tests { - use super::sidebar_is_compact; + use super::{SIDEBAR_W, clamp_sidebar_width, sidebar_is_compact, sidebar_width_from_cfg}; #[test] fn sidebar_follows_viewport_until_toggled() { @@ -1248,4 +1306,14 @@ mod tests { assert!(sidebar_is_compact(Some(true), true)); assert!(!sidebar_is_compact(Some(false), false)); } + + #[test] + fn sidebar_width_clamps_and_defaults() { + assert_eq!(clamp_sidebar_width(80.0), 140.0); + assert_eq!(clamp_sidebar_width(500.0), 360.0); + assert_eq!(clamp_sidebar_width(200.0), 200.0); + assert_eq!(sidebar_width_from_cfg(None), SIDEBAR_W); + assert_eq!(sidebar_width_from_cfg(Some(220)), 220.0); + assert_eq!(sidebar_width_from_cfg(Some(10)), 140.0); + } } From 839f37e01b6ff2e0d5f767621c68e9fd553947c9 Mon Sep 17 00:00:00 2001 From: duyetbot Date: Mon, 24 Aug 2026 19:59:34 +0700 Subject: [PATCH 16/20] feat(ui): use macOS system font, colors, and controls SF Pro via .SystemUIFont, Menlo numbers, system blue/red/green. Time range is a segmented control; settings switches use the native Switch. Charts and the storage bar follow accent blue. --- app/src/density.rs | 2 +- app/src/pages/mod.rs | 5 +- app/src/pages/queries.rs | 2 +- app/src/pages/settings.rs | 56 +++++---- app/src/shell.rs | 5 +- app/src/theme.rs | 230 +++++++++++++++++++++++------------- app/src/widgets/cards.rs | 3 +- app/src/widgets/controls.rs | 56 ++++----- 8 files changed, 215 insertions(+), 144 deletions(-) diff --git a/app/src/density.rs b/app/src/density.rs index 1c71451..ada7231 100644 --- a/app/src/density.rs +++ b/app/src/density.rs @@ -65,7 +65,7 @@ impl Density { pub fn radius(self) -> f32 { match self { Self::Compact => 6.0, - Self::Comfortable => 10.0, + Self::Comfortable => 8.0, } } diff --git a/app/src/pages/mod.rs b/app/src/pages/mod.rs index 6898f92..8afd11b 100644 --- a/app/src/pages/mod.rs +++ b/app/src/pages/mod.rs @@ -96,10 +96,11 @@ pub(crate) fn status(text: impl Into, cx: &App) -> gpui::Div { .child(text.into()) } -pub(crate) fn heading(title: &str) -> gpui::Div { +pub(crate) fn heading(title: &str, cx: &App) -> gpui::Div { div() - .text_sm() + .text_xs() .font_weight(FontWeight::SEMIBOLD) + .text_color(cx.theme().muted_foreground) .child(SharedString::from(title.to_string())) } diff --git a/app/src/pages/queries.rs b/app/src/pages/queries.rs index 7c31873..9d4b0c1 100644 --- a/app/src/pages/queries.rs +++ b/app/src/pages/queries.rs @@ -133,7 +133,7 @@ fn section( .flex() .flex_col() .gap(px(6.)) - .child(heading(title)) + .child(heading(title, cx)) .child(body) .into_any_element() } diff --git a/app/src/pages/settings.rs b/app/src/pages/settings.rs index 053dfaa..1fd5e4e 100644 --- a/app/src/pages/settings.rs +++ b/app/src/pages/settings.rs @@ -194,7 +194,7 @@ impl Render for SettingsPage { v_flex() .gap_5() .max_w(px(520.)) - .child(heading("Appearance")) + .child(heading("Appearance", cx)) .child({ let entity = cx.entity().downgrade(); let mut group = radio_group("appearance"); @@ -219,7 +219,7 @@ impl Render for SettingsPage { } group }) - .child(heading("Density")) + .child(heading("Density", cx)) .child({ let entity = cx.entity().downgrade(); let mut group = radio_group("density"); @@ -244,7 +244,7 @@ impl Render for SettingsPage { } group }) - .child(heading("Overview metrics")) + .child(heading("Overview metrics", cx)) .child( div() .text_xs() @@ -293,9 +293,10 @@ impl Render for SettingsPage { .child("sparkline under the metric tiles"), ), ) - .child(theme_switch("show-chart", show_chart, cx).on_change( - move |next, _, _, cx| { - let _ = entity.update(cx, |this, cx| this.set_show_chart(next, cx)); + .child(theme_switch("show-chart", show_chart, cx).on_click( + move |next, _, cx| { + let on = *next; + let _ = entity.update(cx, |this, cx| this.set_show_chart(on, cx)); }, )) }) @@ -316,10 +317,11 @@ impl Render for SettingsPage { .child("start with the icon strip (⌘B still toggles)"), ), ) - .child(theme_switch("compact-sidebar", compact_sidebar, cx).on_change( - move |next, _, _, cx| { + .child(theme_switch("compact-sidebar", compact_sidebar, cx).on_click( + move |next, _, cx| { + let on = *next; let _ = - entity.update(cx, |this, cx| this.set_compact_sidebar(next, cx)); + entity.update(cx, |this, cx| this.set_compact_sidebar(on, cx)); }, )) }) @@ -340,13 +342,14 @@ impl Render for SettingsPage { .child("fetch latency and memory in the status bar"), ), ) - .child(theme_switch("show-perf", show_perf, cx).on_change( - move |next, _, _, cx| { - let _ = entity.update(cx, |this, cx| this.set_show_perf(next, cx)); + .child(theme_switch("show-perf", show_perf, cx).on_click( + move |next, _, cx| { + let on = *next; + let _ = entity.update(cx, |this, cx| this.set_show_perf(on, cx)); }, )) }) - .child(heading("Updates")) + .child(heading("Updates", cx)) .child({ let entity = cx.entity().downgrade(); h_flex() @@ -364,9 +367,10 @@ impl Render for SettingsPage { .child("fetch the channel manifest from updates.chmonitor.dev"), ), ) - .child(theme_switch("upd-enabled", update_enabled, cx).on_change( - move |next, _, _, cx| { - let _ = entity.update(cx, |this, cx| this.set_update_enabled(next, cx)); + .child(theme_switch("upd-enabled", update_enabled, cx).on_click( + move |next, _, cx| { + let on = *next; + let _ = entity.update(cx, |this, cx| this.set_update_enabled(on, cx)); }, )) }) @@ -387,9 +391,10 @@ impl Render for SettingsPage { .child("save the archive when a newer build is found"), ), ) - .child(theme_switch("upd-auto", auto_download, cx).on_change( - move |next, _, _, cx| { - let _ = entity.update(cx, |this, cx| this.set_auto_download(next, cx)); + .child(theme_switch("upd-auto", auto_download, cx).on_click( + move |next, _, cx| { + let on = *next; + let _ = entity.update(cx, |this, cx| this.set_auto_download(on, cx)); }, )) }) @@ -432,7 +437,7 @@ impl Render for SettingsPage { }), ) }) - .child(heading("Telemetry")) + .child(heading("Telemetry", cx)) .child({ let entity = cx.entity().downgrade(); h_flex() @@ -452,13 +457,14 @@ impl Render for SettingsPage { ), ), ) - .child(theme_switch("telemetry", telemetry, cx).on_change( - move |next, _, _, cx| { - let _ = entity.update(cx, |this, cx| this.set_telemetry(next, cx)); + .child(theme_switch("telemetry", telemetry, cx).on_click( + move |next, _, cx| { + let on = *next; + let _ = entity.update(cx, |this, cx| this.set_telemetry(on, cx)); }, )) }) - .child(heading("Shortcuts")) + .child(heading("Shortcuts", cx)) .child( v_flex() .gap_1() @@ -470,7 +476,7 @@ impl Render for SettingsPage { .child("⌘, settings") .child("⌘Q quit"), ) - .child(heading("Config file")) + .child(heading("Config file", cx)) .child( v_flex() .gap_1() diff --git a/app/src/shell.rs b/app/src/shell.rs index f60160d..448f257 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -976,7 +976,7 @@ impl Shell { fn range_bar(&self, cx: &mut Context) -> impl IntoElement { let entity = cx.entity().downgrade(); - let mut group = crate::widgets::controls::range_group("time-range"); + let mut group = crate::widgets::controls::range_group("time-range", cx); for range in TimeRange::ALL { let entity = entity.clone(); let pressed = self.range == range; @@ -1145,8 +1145,7 @@ impl Render for Shell { .copied() .map(f32::from) .unwrap_or(SIDEBAR_W); - let _ = - entity.update(cx, |this, cx| this.set_sidebar_width(width, cx)); + let _ = entity.update(cx, |this, cx| this.set_sidebar_width(width, cx)); }) .child( resizable_panel() diff --git a/app/src/theme.rs b/app/src/theme.rs index 0262ff1..973baa9 100644 --- a/app/src/theme.rs +++ b/app/src/theme.rs @@ -1,5 +1,8 @@ -//! Brand theme matching the chmonitor.dev dashboard (Rhea: indigo primary, -//! amber charts, 10px radius, SF/system UI + Menlo). +//! macOS-native chrome: system UI font, SF/Menlo mono, HIG colors. +//! +//! Accent is system blue (`#007AFF` / `#0A84FF`). Surfaces follow window / +//! sidebar / separator labels from Apple's Human Interface palette so the +//! desktop client reads like Activity Monitor rather than the web dashboard. use gpui::{App, Hsla, rgb}; use gpui_component::{Theme, ThemeMode}; @@ -10,16 +13,21 @@ fn hx(v: u32) -> Hsla { rgb(v).into() } -/// Paint dashboard colors onto the active gpui-component theme. +/// Apply system type and macOS semantic colors onto the active theme. pub fn apply_brand(cx: &mut App) { let dark = Theme::global(cx).is_dark(); let density = Density::current(); let theme = Theme::global_mut(cx); + theme.font_family = ".SystemUIFont".into(); theme.font_size = gpui::px(density.font_size()); + theme.mono_font_family = if cfg!(target_os = "macos") { + "Menlo".into() + } else { + "ui-monospace".into() + }; theme.mono_font_size = gpui::px(density.mono_font_size()); theme.radius = gpui::px(density.radius()); theme.radius_lg = gpui::px(density.radius_lg()); - theme.mono_font_family = "Menlo".into(); if dark { paint_dark(theme); } else { @@ -29,91 +37,153 @@ pub fn apply_brand(cx: &mut App) { } fn paint_light(theme: &mut Theme) { - theme.background = hx(0xffffff); - theme.foreground = hx(0x252525); - theme.secondary = hx(0xf4f4f7); - theme.secondary_foreground = hx(0x252525); - theme.muted = hx(0xf4f4f5); - theme.muted_foreground = hx(0x737373); - theme.accent = hx(0xf4f4f5); - theme.accent_foreground = hx(0x252525); - theme.primary = hx(0x4f46e5); - theme.primary_foreground = hx(0xf5f7ff); - theme.primary_hover = hx(0x4338ca); - theme.primary_active = hx(0x3730a3); + let blue = hx(0x007AFF); + let label = hx(0x1D1D1F); + let fill = hx(0xF2F2F7); + let hairline = hx(0xD1D1D6); + + theme.background = hx(0xFFFFFF); + theme.foreground = label; + theme.secondary = fill; + theme.secondary_foreground = label; + theme.secondary_hover = hx(0xE5E5EA); + theme.secondary_active = hx(0xD1D1D6); + theme.muted = fill; + theme.muted_foreground = hx(0x6E6E73); + theme.accent = hx(0xE5E5EA); + theme.accent_foreground = label; + theme.primary = blue; + theme.primary_foreground = hx(0xFFFFFF); + theme.primary_hover = hx(0x0066D6); + theme.primary_active = hx(0x0055C4); theme.button_primary = theme.primary; theme.button_primary_foreground = theme.primary_foreground; theme.button_primary_hover = theme.primary_hover; - theme.border = hx(0xe5e5e5); - theme.input = hx(0xe5e5e5); - theme.ring = hx(0xa5b4fc); - theme.danger = hx(0xe11d48); - theme.danger_foreground = hx(0xffffff); - theme.warning = hx(0xd97706); - theme.green = hx(0x16a34a); - theme.sidebar = hx(0xfafafa); - theme.sidebar_foreground = hx(0x252525); - theme.sidebar_accent = hx(0xf4f4f5); - theme.sidebar_accent_foreground = hx(0x252525); - theme.sidebar_border = hx(0xe5e5e5); - theme.sidebar_primary = hx(0x4f46e5); - theme.sidebar_primary_foreground = hx(0xf5f7ff); - theme.chart_1 = hx(0xeab308); - theme.chart_2 = hx(0xf59e0b); - theme.chart_3 = hx(0xf97316); - theme.chart_4 = hx(0xea580c); - theme.chart_5 = hx(0xc2410c); - theme.skeleton = hx(0xe5e5e5); - theme.popover = hx(0xffffff); - theme.popover_foreground = hx(0x252525); - theme.title_bar = hx(0xfafafa); - theme.title_bar_border = hx(0xe5e5e5); - theme.status_bar = hx(0xfafafa); - theme.status_bar_border = hx(0xe5e5e5); + theme.border = hairline; + theme.input = hairline; + theme.ring = blue; + theme.selection = blue.opacity(0.28); + theme.danger = hx(0xFF3B30); + theme.danger_foreground = hx(0xFFFFFF); + theme.warning = hx(0xFF9F0A); + theme.warning_foreground = hx(0x1D1D1F); + theme.success = hx(0x34C759); + theme.success_foreground = hx(0xFFFFFF); + theme.green = hx(0x34C759); + theme.red = hx(0xFF3B30); + theme.blue = blue; + theme.sidebar = hx(0xF5F5F7); + theme.sidebar_foreground = label; + theme.sidebar_accent = hx(0xE5E5EA); + theme.sidebar_accent_foreground = label; + theme.sidebar_border = hairline; + theme.sidebar_primary = blue; + theme.sidebar_primary_foreground = hx(0xFFFFFF); + theme.chart_1 = blue; + theme.chart_2 = hx(0x5AC8FA); + theme.chart_3 = hx(0x64D2FF); + theme.chart_4 = hx(0x0A84FF); + theme.chart_5 = hx(0x0055C4); + theme.skeleton = hx(0xE5E5EA); + theme.popover = hx(0xFFFFFF); + theme.popover_foreground = label; + theme.title_bar = hx(0xF5F5F7); + theme.title_bar_border = hairline; + theme.status_bar = hx(0xF5F5F7); + theme.status_bar_border = hairline; + theme.switch = hx(0xD1D1D6); + theme.switch_thumb = hx(0xFFFFFF); + theme.tab_bar = fill; + theme.tab_bar_segmented = fill; + theme.tab = Hsla::transparent_black(); + theme.tab_active = hx(0xFFFFFF); + theme.tab_active_foreground = label; + theme.tab_foreground = hx(0x6E6E73); + theme.list_hover = hx(0xE5E5EA); + theme.list_active = blue.opacity(0.12); + theme.list_active_border = blue; + theme.table_head = fill; + theme.table_head_foreground = hx(0x6E6E73); + theme.table_row_border = hairline; + theme.table_hover = hx(0xF2F2F7); + theme.progress_bar = blue; + theme.scrollbar_thumb = hx(0xC7C7CC); + theme.overlay = hx(0x000000).opacity(0.18); } fn paint_dark(theme: &mut Theme) { - theme.background = hx(0x171717); - theme.foreground = hx(0xfafafa); - theme.secondary = hx(0x2a2a2e); - theme.secondary_foreground = hx(0xfafafa); - theme.muted = hx(0x262626); - theme.muted_foreground = hx(0xa1a1aa); - theme.accent = hx(0x262626); - theme.accent_foreground = hx(0xfafafa); - theme.primary = hx(0x818cf8); - theme.primary_foreground = hx(0x1e1b4b); - theme.primary_hover = hx(0xa5b4fc); - theme.primary_active = hx(0x6366f1); + let blue = hx(0x0A84FF); + let label = hx(0xF5F5F7); + let fill = hx(0x2C2C2E); + let hairline = hx(0x3A3A3C); + + theme.background = hx(0x1C1C1E); + theme.foreground = label; + theme.secondary = fill; + theme.secondary_foreground = label; + theme.secondary_hover = hx(0x3A3A3C); + theme.secondary_active = hx(0x48484A); + theme.muted = fill; + theme.muted_foreground = hx(0x8E8E93); + theme.accent = hx(0x3A3A3C); + theme.accent_foreground = label; + theme.primary = blue; + theme.primary_foreground = hx(0xFFFFFF); + theme.primary_hover = hx(0x409CFF); + theme.primary_active = hx(0x0070E0); theme.button_primary = theme.primary; theme.button_primary_foreground = theme.primary_foreground; theme.button_primary_hover = theme.primary_hover; - theme.border = hx(0x3f3f46); - theme.input = hx(0x3f3f46); - theme.ring = hx(0x818cf8); - theme.danger = hx(0xfb7185); - theme.danger_foreground = hx(0x1c1917); - theme.warning = hx(0xfbbf24); - theme.green = hx(0x4ade80); - theme.sidebar = hx(0x2a2a2a); - theme.sidebar_foreground = hx(0xfafafa); - theme.sidebar_accent = hx(0x3f3f46); - theme.sidebar_accent_foreground = hx(0xfafafa); - theme.sidebar_border = hx(0x3f3f46); - theme.sidebar_primary = hx(0x818cf8); - theme.sidebar_primary_foreground = hx(0x1e1b4b); - theme.chart_1 = hx(0xeab308); - theme.chart_2 = hx(0xf59e0b); - theme.chart_3 = hx(0xf97316); - theme.chart_4 = hx(0xea580c); - theme.chart_5 = hx(0xc2410c); - theme.skeleton = hx(0x3f3f46); - theme.popover = hx(0x2a2a2a); - theme.popover_foreground = hx(0xfafafa); - theme.title_bar = hx(0x1c1c1c); - theme.title_bar_border = hx(0x3f3f46); - theme.status_bar = hx(0x1c1c1c); - theme.status_bar_border = hx(0x3f3f46); + theme.border = hairline; + theme.input = hairline; + theme.ring = blue; + theme.selection = blue.opacity(0.40); + theme.danger = hx(0xFF453A); + theme.danger_foreground = hx(0xFFFFFF); + theme.warning = hx(0xFF9F0A); + theme.warning_foreground = hx(0x1C1C1E); + theme.success = hx(0x30D158); + theme.success_foreground = hx(0x1C1C1E); + theme.green = hx(0x30D158); + theme.red = hx(0xFF453A); + theme.blue = blue; + theme.sidebar = hx(0x2C2C2E); + theme.sidebar_foreground = label; + theme.sidebar_accent = hx(0x3A3A3C); + theme.sidebar_accent_foreground = label; + theme.sidebar_border = hairline; + theme.sidebar_primary = blue; + theme.sidebar_primary_foreground = hx(0xFFFFFF); + theme.chart_1 = blue; + theme.chart_2 = hx(0x64D2FF); + theme.chart_3 = hx(0x5AC8FA); + theme.chart_4 = hx(0x007AFF); + theme.chart_5 = hx(0x409CFF); + theme.skeleton = hx(0x3A3A3C); + theme.popover = hx(0x2C2C2E); + theme.popover_foreground = label; + theme.title_bar = hx(0x2C2C2E); + theme.title_bar_border = hairline; + theme.status_bar = hx(0x2C2C2E); + theme.status_bar_border = hairline; + theme.switch = hx(0x39393D); + theme.switch_thumb = hx(0xFFFFFF); + theme.tab_bar = fill; + theme.tab_bar_segmented = fill; + theme.tab = Hsla::transparent_black(); + theme.tab_active = hx(0x3A3A3C); + theme.tab_active_foreground = label; + theme.tab_foreground = hx(0x8E8E93); + theme.list_hover = hx(0x3A3A3C); + theme.list_active = blue.opacity(0.22); + theme.list_active_border = blue; + theme.table_head = fill; + theme.table_head_foreground = hx(0x8E8E93); + theme.table_row_border = hairline; + theme.table_hover = hx(0x3A3A3C); + theme.progress_bar = blue; + theme.scrollbar_thumb = hx(0x636366); + theme.overlay = hx(0x000000).opacity(0.45); } pub fn current_mode(cx: &App) -> ThemeMode { diff --git a/app/src/widgets/cards.rs b/app/src/widgets/cards.rs index 289ece5..7aec189 100644 --- a/app/src/widgets/cards.rs +++ b/app/src/widgets/cards.rs @@ -64,7 +64,8 @@ pub fn kpi_card( .child( div() .text_size(px(d.card_value())) - .font_weight(FontWeight::BOLD) + .font_weight(FontWeight::SEMIBOLD) + .font_family(cx.theme().mono_font_family.clone()) .text_color(cx.theme().foreground) .child(value), ) diff --git a/app/src/widgets/controls.rs b/app/src/widgets/controls.rs index ce666bc..cccd46d 100644 --- a/app/src/widgets/controls.rs +++ b/app/src/widgets/controls.rs @@ -3,9 +3,9 @@ //! Base owns focus, keyboard, and accessibility. Theme tokens and layout //! stay here so the product is not locked to gpui-component's default look. -use gpui::{App, ElementId, SharedString, div, prelude::*, px, relative}; -use gpui_base::{Button, Radio, RadioGroup, Switch, SwitchThumb, SwitchTrack, Toggle, ToggleGroup}; -use gpui_component::ActiveTheme as _; +use gpui::{App, ElementId, FontWeight, SharedString, div, prelude::*, px, relative}; +use gpui_base::{Button, Radio, RadioGroup, Toggle, ToggleGroup}; +use gpui_component::{ActiveTheme as _, Sizable as _, switch::Switch}; pub fn primary_button( id: impl Into, @@ -103,45 +103,39 @@ pub fn range_toggle( Toggle::new(id) .pressed(pressed) .px_2() - .h_6() + .h(px(22.)) .flex() .items_center() .justify_center() .text_xs() + .font_weight(if pressed { + FontWeight::SEMIBOLD + } else { + FontWeight::NORMAL + }) .line_height(relative(1.)) - .rounded(cx.theme().radius) + .rounded(px(5.)) .when(pressed, |t| { - t.bg(cx.theme().primary) - .text_color(cx.theme().primary_foreground) + t.bg(cx.theme().background) + .text_color(cx.theme().foreground) }) .when(!pressed, |t| t.text_color(cx.theme().muted_foreground)) - .hover(|s| s.bg(cx.theme().accent)) + .hover(|s| s.bg(cx.theme().background.opacity(0.7))) .child(label.into()) } -pub fn range_group(id: impl Into) -> ToggleGroup { - ToggleGroup::new(id).flex().items_center().gap_1() +/// macOS segmented control track. +pub fn range_group(id: impl Into, cx: &App) -> ToggleGroup { + ToggleGroup::new(id) + .flex() + .items_center() + .gap(px(1.)) + .p(px(2.)) + .rounded(px(7.)) + .bg(cx.theme().muted) } -/// Compact on/off switch styled from theme tokens. -pub fn theme_switch(id: impl Into, checked: bool, cx: &App) -> Switch { - let on = cx.theme().primary; - let off = cx.theme().border; - let thumb = cx.theme().background; - Switch::new(id).checked(checked).child( - SwitchTrack::new("switch-track") - .checked(checked) - .w(px(36.)) - .h(px(20.)) - .p(px(2.)) - .rounded_full() - .bg(if checked { on } else { off }) - .child( - SwitchThumb::new(checked) - .size_4() - .rounded_full() - .bg(thumb) - .ml(if checked { px(16.) } else { px(0.) }), - ), - ) +/// Native gpui-component switch (system-blue when on). +pub fn theme_switch(id: impl Into, checked: bool, _cx: &App) -> Switch { + Switch::new(id).checked(checked).small() } From dbce530a51723bb3048d5178a0bad515f22cbd67 Mon Sep 17 00:00:00 2001 From: duyetbot Date: Tue, 25 Aug 2026 00:26:17 +0700 Subject: [PATCH 17/20] feat(ui): redesign sidebar for native macOS layout Move host switching into the sidebar header with the app icon, put the collapse toggle in the title bar, and anchor settings at the bottom of the sidebar. Co-authored-by: Cursor --- app/src/shell.rs | 254 +++++++++++++++++++++++++---------------------- 1 file changed, 136 insertions(+), 118 deletions(-) diff --git a/app/src/shell.rs b/app/src/shell.rs index 448f257..d7d6e2a 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -1,7 +1,7 @@ //! App shell — window layout, sidebar nav, page routing, status bar, //! 30-second poll loop and the startup update-check hook. -use std::sync::{Arc, OnceLock}; +use std::sync::{Arc, LazyLock, OnceLock}; use std::time::{Duration, Instant}; use chm_core::{ @@ -11,18 +11,18 @@ use chm_core::{ use gpui::{ App, AppContext as _, AsyncApp, Context, Entity, FocusHandle, Focusable, FontWeight, Hsla, - KeyBinding, KeyDownEvent, MouseButton, Render, SharedString, WeakEntity, Window, actions, div, - prelude::*, px, relative, + Image, ImageFormat, ImageSource, KeyBinding, KeyDownEvent, MouseButton, Render, SharedString, + WeakEntity, Window, actions, div, img, prelude::*, px, relative, }; use gpui_component::{ - ActiveTheme as _, Icon, IconName, Root, Sizable as _, TitleBar, + ActiveTheme as _, Icon, IconName, Root, Selectable as _, Sizable as _, TitleBar, button::{Button, ButtonVariants as _}, h_flex, h_resizable, menu::{DropdownMenu as _, PopupMenuItem}, resizable_panel, + separator::Separator, sidebar::{ - Sidebar, SidebarFooter, SidebarGroup, SidebarHeader, SidebarMenu, SidebarMenuItem, - SidebarToggleButton, + Sidebar, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarToggleButton, }, spinner::Spinner, status_bar::StatusBar, @@ -58,6 +58,13 @@ const SIDEBAR_W: f32 = 176.0; const SIDEBAR_W_MIN: f32 = 140.0; const SIDEBAR_W_MAX: f32 = 360.0; +static APP_ICON: LazyLock> = LazyLock::new(|| { + Arc::new(Image::from_bytes( + ImageFormat::Png, + include_bytes!("../../assets/icon/icon-1024.png").to_vec(), + )) +}); + fn clamp_sidebar_width(width: f32) -> f32 { width.clamp(SIDEBAR_W_MIN, SIDEBAR_W_MAX) } @@ -780,33 +787,34 @@ impl Shell { } } + fn host_engine_label(&self) -> &'static str { + match self.source_engine() { + SourceEngine::Postgres => "PostgreSQL", + SourceEngine::ClickHouse => "ClickHouse", + SourceEngine::Cloud => "Cloud API", + SourceEngine::Mock => "Mock", + } + } + + fn app_icon(&self, compact: bool) -> impl IntoElement { + let size = if compact { px(16.) } else { px(24.) }; + img(ImageSource::Image(APP_ICON.clone())) + .size(size) + .rounded(px(6.)) + .flex_shrink_0() + .object_fit(gpui::ObjectFit::Cover) + } + // -- rendering ---------------------------------------------------------- fn render_sidebar(&self, compact: bool, cx: &mut Context) -> impl IntoElement { let engine = self.source_engine(); - let active_host = self.active_host.clone(); + let entity = cx.entity().downgrade(); let hosts = self.hosts(); - - let mut host_menu = SidebarMenu::new(); - for host in hosts { - let id = host.id.clone(); - let selected = active_host.as_deref() == Some(id.as_str()); - let icon = Self::host_icon(host.profile.mode.as_deref()); - host_menu = host_menu.child( - SidebarMenuItem::new(host.label.clone()) - .icon(icon) - .active(selected) - .on_click(cx.listener(move |this, _, _, cx| this.switch_host(id.clone(), cx))), - ); - } - host_menu = host_menu.child( - SidebarMenuItem::new("Add host") - .icon(IconName::Plus) - .on_click(cx.listener(|this, _, _, cx| { - this.page = Page::Connect; - cx.notify(); - })), - ); + let host_label = self.active_host_label(); + let engine_label = self.host_engine_label(); + let active = self.active_host.clone(); + let muted = cx.theme().muted_foreground; let mut nav = SidebarMenu::new(); for (i, page) in Page::ALL @@ -815,15 +823,15 @@ impl Shell { .filter(|page| page.available(engine)) .enumerate() { - let active = page == self.page; + let active_page = page == self.page; let hotkey = format!("{}", i + 1); nav = nav.child( SidebarMenuItem::new(page.title()) .icon(page.icon()) - .active(active) + .active(active_page) .suffix({ let hotkey = hotkey.clone(); - let muted = cx.theme().muted_foreground; + let muted = muted; move |_, _| div().text_xs().text_color(muted).child(hotkey.clone()) }) .on_click(cx.listener(move |this, _, _, cx| this.goto(page, cx))), @@ -836,86 +844,98 @@ impl Shell { .when(compact, |sb| sb.w(px(self.sidebar_width))) .when(!compact, |sb| sb.w(relative(1.))) .header( - SidebarHeader::new().child( - h_flex().w_full().items_center().justify_between().child( - SidebarToggleButton::new() - .collapsed(compact) - .on_click(cx.listener(|this, _, window, cx| { - this.toggle_sidebar( - window.viewport_size().width < px(COMPACT_BELOW), - cx, - ); - })), - ), - ), + SidebarHeader::new() + .child(self.app_icon(compact)) + .when(!compact, |header| { + header.child( + v_flex() + .flex_1() + .min_w_0() + .overflow_hidden() + .line_height(relative(1.25)) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_ellipsis() + .child(host_label.clone()), + ) + .child( + div() + .text_xs() + .text_color(muted) + .text_ellipsis() + .child(engine_label), + ), + ) + }) + .when(!compact, |header| { + header.child(Icon::new(IconName::ChevronsUpDown).size_4().flex_shrink_0()) + }) + .dropdown_menu(move |menu, _, _| { + let mut menu = menu; + for host in &hosts { + let id = host.id.clone(); + let entity = entity.clone(); + let selected = active.as_deref() == Some(id.as_str()); + menu = menu.item( + PopupMenuItem::new(host.label.clone()) + .icon(Self::host_icon(host.profile.mode.as_deref())) + .checked(selected) + .on_click(move |_, _, cx| { + let _ = entity.update(cx, |this, cx| { + this.switch_host(id.clone(), cx) + }); + }), + ); + } + let entity = entity.clone(); + menu.separator().item( + PopupMenuItem::new("Add host") + .icon(IconName::Plus) + .on_click(move |_, _, cx| { + let _ = entity.update(cx, |this, cx| { + this.page = Page::Connect; + cx.notify(); + }); + }), + ) + }), ) - .child(SidebarGroup::new("Host").child(host_menu)) - .child(SidebarGroup::new("Monitor").child(nav)) - .child( - SidebarGroup::new("App").child( - SidebarMenu::new().child( - SidebarMenuItem::new(Page::Settings.title()) - .icon(Page::Settings.icon()) - .active(self.page == Page::Settings) + .child(nav) + .footer( + SidebarFooter::new() + .justify_between() + .child( + Button::new("sidebar-settings") + .ghost() + .compact() + .icon(Icon::new(IconName::Settings)) + .when(!compact, |btn| btn.label("Settings")) + .selected(self.page == Page::Settings) + .tooltip("Settings") .on_click(cx.listener(|this, _, _, cx| { this.page = Page::Settings; cx.notify(); })), - ), - ), - ) - .footer( - SidebarFooter::new().child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(format!("v{}", env!("CARGO_PKG_VERSION"))), - ), + ) + .when(!compact, |footer| { + footer.child( + div() + .text_xs() + .text_color(muted) + .child(format!("v{}", env!("CARGO_PKG_VERSION"))), + ) + }), ) } - fn host_switcher(&self, cx: &mut Context) -> impl IntoElement { - let entity = cx.entity().downgrade(); - let hosts = self.hosts(); - let label = self.active_host_label(); - let active = self.active_host.clone(); - Button::new("host-switch") - .ghost() - .compact() - .xsmall() - .label(label) - .dropdown_caret(true) - .dropdown_menu(move |menu, _, _| { - let mut menu = menu; - for host in &hosts { - let id = host.id.clone(); - let entity = entity.clone(); - let selected = active.as_deref() == Some(id.as_str()); - menu = menu.item( - PopupMenuItem::new(host.label.clone()) - .icon(Self::host_icon(host.profile.mode.as_deref())) - .checked(selected) - .on_click(move |_, _, cx| { - let _ = - entity.update(cx, |this, cx| this.switch_host(id.clone(), cx)); - }), - ); - } - let entity = entity.clone(); - menu.separator().item( - PopupMenuItem::new("Add host") - .icon(IconName::Plus) - .on_click(move |_, _, cx| { - let _ = entity.update(cx, |this, cx| { - this.page = Page::Connect; - cx.notify(); - }); - }), - ) - }) - } - - fn render_title_bar(&self, show_range: bool, cx: &mut Context) -> impl IntoElement { + fn render_title_bar( + &self, + compact: bool, + show_range: bool, + cx: &mut Context, + ) -> impl IntoElement { let fetching = self.fetching; let muted = cx.theme().muted_foreground; TitleBar::new() @@ -923,6 +943,17 @@ impl Shell { h_flex() .items_center() .gap_2() + .child( + SidebarToggleButton::new() + .collapsed(compact) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_sidebar( + window.viewport_size().width < px(COMPACT_BELOW), + cx, + ); + })), + ) + .child(Separator::vertical().h_4()) .child( div() .text_sm() @@ -940,7 +971,6 @@ impl Shell { .gap_2() .px_2() .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .child(self.host_switcher(cx)) .when(show_range, |row| row.child(self.range_bar(cx))) .child({ let dark = @@ -958,19 +988,7 @@ impl Shell { .on_click(cx.listener(|this, _, window, cx| { this.toggle_dark(window, cx); })) - }) - .child( - Button::new("open-settings") - .ghost() - .compact() - .xsmall() - .icon(Icon::new(IconName::Settings)) - .tooltip("Settings") - .on_click(cx.listener(|this, _, _, cx| { - this.page = Page::Settings; - cx.notify(); - })), - ), + }), ) } @@ -1198,7 +1216,7 @@ impl Render for Shell { .size_full() .bg(cx.theme().background) .text_color(cx.theme().foreground) - .child(self.render_title_bar(show_range, cx)) + .child(self.render_title_bar(compact, show_range, cx)) .child(split) .child(self.status_bar(cx)) .children(Root::render_notification_layer(window, cx)) From 5971cdff61addf92a750a5ec3e8a0ddb3bbcc74c Mon Sep 17 00:00:00 2001 From: duyetbot Date: Tue, 25 Aug 2026 00:32:48 +0700 Subject: [PATCH 18/20] fix(ci): satisfy clippy on sidebar and macOS-only imports Gate Path behind target_os = macos and drop a redundant local in the sidebar nav suffix closure. Co-authored-by: Cursor --- app/src/shell.rs | 1 - app/src/updater.rs | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/shell.rs b/app/src/shell.rs index d7d6e2a..0596f36 100644 --- a/app/src/shell.rs +++ b/app/src/shell.rs @@ -831,7 +831,6 @@ impl Shell { .active(active_page) .suffix({ let hotkey = hotkey.clone(); - let muted = muted; move |_, _| div().text_xs().text_color(muted).child(hotkey.clone()) }) .on_click(cx.listener(move |this, _, _, cx| this.goto(page, cx))), diff --git a/app/src/updater.rs b/app/src/updater.rs index 15f87c2..6f3cef9 100644 --- a/app/src/updater.rs +++ b/app/src/updater.rs @@ -1,6 +1,9 @@ //! Download cache path and macOS `.app` install from a release zip. -use std::path::{Path, PathBuf}; +use std::path::PathBuf; + +#[cfg(target_os = "macos")] +use std::path::Path; use chm_update::ReleaseInfo; From dffe91b6c263f111c162a479620b58fe34a236dd Mon Sep 17 00:00:00 2001 From: duyetbot Date: Tue, 25 Aug 2026 00:37:34 +0700 Subject: [PATCH 19/20] fix(test): make multi-target manifest test platform-aware Assert the matching manifest row for the current target triple instead of always expecting the macOS artifact on Linux CI. Co-authored-by: Cursor --- crates/chm-update/src/lib.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/crates/chm-update/src/lib.rs b/crates/chm-update/src/lib.rs index 563ea65..4e138fa 100644 --- a/crates/chm-update/src/lib.rs +++ b/crates/chm-update/src/lib.rs @@ -419,9 +419,15 @@ mod tests { .await .unwrap() .expect("newer"); - assert_eq!(release.url(), "https://dl.example/mac.zip"); - assert_eq!(release.target(), Some(current_target())); - assert_eq!(release.sha256(), Some("abc")); + let target = current_target(); + let (expected_url, expected_sha) = if target == "x86_64-unknown-linux-gnu" { + ("https://dl.example/linux.tar.gz", None) + } else { + ("https://dl.example/mac.zip", Some("abc")) + }; + assert_eq!(release.url(), expected_url); + assert_eq!(release.target(), Some(target)); + assert_eq!(release.sha256(), expected_sha); } #[tokio::test] From 3466fabc62834e2f70d503814057e049786daac3 Mon Sep 17 00:00:00 2001 From: duyetbot Date: Tue, 25 Aug 2026 00:54:42 +0700 Subject: [PATCH 20/20] fix(ci): keep app and screenshots on the same Xvfb display Start Xvfb in the smoke script and export DISPLAY so screenshot capture uses the same headless display as the app under xvfb-run on GitHub-hosted runners. Co-authored-by: Cursor --- scripts/smoke.sh | 33 ++++++++++++++++----------------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 7a0d8ba..d7cd49e 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -9,6 +9,7 @@ set -euo pipefail DISPLAY_NUM="${1:-${DISPLAY:-:1}}" SHOTS_DIR="${SHOTS_DIR:-shots}" WAIT_SECS=6 +XVFB_PID="" log() { printf '[smoke] %s\n' "$*"; } fail() { printf '[smoke] FAIL: %s\n' "$*" >&2; exit 1; } @@ -16,8 +17,14 @@ fail() { printf '[smoke] FAIL: %s\n' "$*" >&2; exit 1; } command -v cargo >/dev/null || fail "cargo not on PATH" # --- preflight --------------------------------------------------------------- -if [ -z "${DISPLAY:-}" ] && ! command -v xvfb-run >/dev/null; then - fail "no DISPLAY and no xvfb-run" +if [ -z "${DISPLAY:-}" ]; then + command -v Xvfb >/dev/null || fail "no DISPLAY and no Xvfb" + DISPLAY_NUM=":99" + Xvfb "$DISPLAY_NUM" -screen 0 1440x900x24 >/dev/null 2>&1 & + XVFB_PID=$! + export DISPLAY="$DISPLAY_NUM" + sleep 1 + log "started Xvfb on $DISPLAY (pid $XVFB_PID)" fi shot_tool="" @@ -31,16 +38,8 @@ for t in wmctrl xdotool; do if command -v "$t" >/dev/null; then wm_tool="$t"; break; fi done -mkdir -p "$SHOTS_DIR" - # --- launch ------------------------------------------------------------------ -RUNNER=(env CHM_SMOKE=1) -if [ -n "${DISPLAY:-}" ]; then - RUNNER+=(DISPLAY="$DISPLAY_NUM") -else - log "no DISPLAY — using xvfb-run" - RUNNER=(xvfb-run -a -s "-screen 0 1440x900x24" env CHM_SMOKE=1) -fi +mkdir -p "$SHOTS_DIR" log "building debug binary…" cargo build -p chm-app @@ -49,9 +48,9 @@ BIN="target/debug/chm-app" [ -x "$BIN" ] || fail "binary missing at $BIN" LOG="$(mktemp /tmp/chm-smoke.XXXXXX.log)" -"${RUNNER[@]}" RUST_LOG=info "$BIN" >"$LOG" 2>&1 & +env CHM_SMOKE=1 RUST_LOG=info "$BIN" >"$LOG" 2>&1 & APP_PID=$! -trap 'kill $APP_PID 2>/dev/null || true' EXIT +trap 'kill $APP_PID $XVFB_PID 2>/dev/null || true' EXIT sleep "$WAIT_SECS" kill -0 "$APP_PID" 2>/dev/null || { tail -30 "$LOG"; fail "app exited early"; } @@ -81,13 +80,13 @@ esac shot() { local name="$1" out="$SHOTS_DIR/$1.png" case "$shot_tool" in - import) DISPLAY="$DISPLAY_NUM" import -window root "$out" 2>/dev/null ;; - scrot) DISPLAY="$DISPLAY_NUM" scrot -o "$out" 2>/dev/null ;; - gnome-screenshot) DISPLAY="$DISPLAY_NUM" gnome-screenshot -f "$out" 2>/dev/null ;; + import) import -window root "$out" || return 1 ;; + scrot) scrot -o "$out" || return 1 ;; + gnome-screenshot) gnome-screenshot -f "$out" || return 1 ;; esac } -shot "01-connect-or-overview" +shot "01-connect-or-overview" || fail "screenshot failed on $DISPLAY" PAGES=("02-overview" "03-queries" "04-merges" "05-replicas" "06-health" "07-tables" "08-traffic") # Page switching is keyboard-driven when supported (keys 1..8 bound in shell);