diff --git a/gpui-app/src/ui/app.rs b/gpui-app/src/ui/app.rs index 7c27eac..5b5b291 100644 --- a/gpui-app/src/ui/app.rs +++ b/gpui-app/src/ui/app.rs @@ -40,7 +40,7 @@ use crate::ui::{ const MAX_LOGS: usize = 3000; // 应用级快捷键动作(context=None:任意焦点状态下都匹配)。 -gpui_kit::actions!(mqttx, [NewConnection, OpenSettings, ExportConnections, ImportConnections]); +gpui_kit::actions!(mqttx, [NewConnection, OpenSettings, ExportConnections, ImportConnections, OpenResourceMonitor]); /// 侧边栏分组过滤;chips 只做列表过滤,分组重命名/删除通过编辑连接的分组字段完成。 #[derive(Clone, PartialEq, Eq)] @@ -267,6 +267,7 @@ impl MqttXApp { KeyBinding::new("ctrl-,", OpenSettings, None), KeyBinding::new("ctrl-shift-e", ExportConnections, None), KeyBinding::new("ctrl-shift-i", ImportConnections, None), + KeyBinding::new("ctrl-shift-r", OpenResourceMonitor, None), ]); let weak = cx.entity().downgrade(); // Context 上有同名 on_action(绘制期注册),这里必须走 App 的全局注册 @@ -290,6 +291,16 @@ impl MqttXApp { }); }); let weak = cx.entity().downgrade(); + App::on_action::(cx, move |_, cx| { + let w = weak.clone(); + run_on_active_window(cx, w, |entity, window, cx| { + if window.has_active_dialog(cx) { + return; + } + crate::ui::resource_dialog::open(entity, window, cx); + }); + }); + let weak = cx.entity().downgrade(); App::on_action::(cx, move |_, cx| { let w = weak.clone(); run_on_active_window(cx, w, |entity, window, cx| { @@ -655,6 +666,11 @@ impl MqttXApp { } } + /// 打开的标签数(资源监控展示用)。 + pub fn open_tab_count(&self) -> usize { + self.open_tabs.len() + } + pub fn open_tab(&mut self, conn_id: &str, window: &mut Window, cx: &mut Context) { if !self.open_tabs.contains(&conn_id.to_string()) { self.open_tabs.push(conn_id.to_string()); @@ -1136,6 +1152,13 @@ impl MqttXApp { } }, )) + .item(PopupMenuItem::new("资源监控").on_click( + move |_, window, cx| { + if let Some(entity) = w6.upgrade() { + crate::ui::resource_dialog::open(entity, window, cx); + } + }, + )) .separator() .item(PopupMenuItem::new("导出连接").on_click( move |_, window, cx| { diff --git a/gpui-app/src/ui/mod.rs b/gpui-app/src/ui/mod.rs index 6918898..0cc828d 100644 --- a/gpui-app/src/ui/mod.rs +++ b/gpui-app/src/ui/mod.rs @@ -6,6 +6,7 @@ pub mod connection_form; pub mod connection_view; pub mod logo; pub mod presets_dialog; +pub mod resource_dialog; pub mod settings_dialog; pub mod variables_dialog; pub mod widgets; diff --git a/gpui-app/src/ui/resource_dialog.rs b/gpui-app/src/ui/resource_dialog.rs new file mode 100644 index 0000000..1b6c5af --- /dev/null +++ b/gpui-app/src/ui/resource_dialog.rs @@ -0,0 +1,509 @@ +//! 资源监控对话框:软件自身的 CPU / 内存 / 线程 / 句柄占用, +//! 以及每连接的消息内存预算使用情况。 +//! +//! 交互:右上角可暂停/恢复自动刷新(默认开启,1.5s),并可手动立即刷新; +//! 全局快捷键 Ctrl+Shift+R 打开;`MQTTX_OPEN_RESMON=1` 启动时直开(自动化验证)。 + +use std::time::Duration; + +use gpui_kit::component::button::Button; +use gpui_kit::component::progress::Progress; +use gpui_kit::component::switch::Switch; +use gpui_kit::component::{ + h_flex, v_flex, ActiveTheme as _, Icon, Sizable as _, StyledExt as _, WindowExt as _, +}; +use gpui_kit::prelude::FluentBuilder as _; +use gpui_kit::{ + div, px, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, + SharedString, Styled as _, Window, +}; + +use crate::model::MAX_CONNECTION_MESSAGE_BYTES; +use crate::sysmon::{Sample, Sampler}; +use crate::ui::app::MqttXApp; +use crate::ui::IconName; + +const REFRESH_INTERVAL: Duration = Duration::from_millis(1500); + +struct ConnStat { + name: String, + address: String, + status: &'static str, + connected: bool, + messages: usize, + bytes: usize, +} + +pub struct ResourceMonitorDialog { + app: Entity, + sampler: Sampler, + sample: Sample, + conns: Vec, + logs: usize, + tabs: usize, + total_messages: usize, + auto_refresh: bool, + /// 首次 render 时才做初始采样:构造发生在 MqttXApp::new 的借用中, + /// 过早 read app 会触发双重借用 panic + first_render: bool, +} + +impl ResourceMonitorDialog { + fn new(app: Entity, cx: &mut Context) -> Self { + Self { + app, + sampler: Sampler::new(), + sample: Sample::default(), + conns: Vec::new(), + logs: 0, + tabs: 0, + total_messages: 0, + auto_refresh: true, + first_render: true, + } + } + + /// 采样进程资源 + 快照每连接的消息内存占用。 + fn refresh(&mut self, cx: &mut Context) { + if let Some(s) = self.sampler.sample() { + self.sample = s; + } + let app = self.app.read(cx); + self.logs = app.logs.len(); + self.tabs = app.open_tab_count(); + self.total_messages = 0; + self.conns = app + .connections + .iter() + .map(|c| { + let ring = app.messages.get(c.id.as_str()); + let (messages, bytes) = ring + .map(|m| (m.len(), m.retained_bytes())) + .unwrap_or((0, 0)); + self.total_messages += messages; + ConnStat { + name: c.name.clone(), + address: c.display_address(), + status: app + .statuses + .get(&c.id) + .copied() + .map(|s| s.label()) + .unwrap_or("未连接"), + connected: app.engine.is_connected(&c.id), + messages, + bytes, + } + }) + .collect(); + } + + fn fmt_bytes(bytes: u64) -> String { + if bytes >= 1024 * 1024 { + format!("{:.1} MB", bytes as f64 / (1024. * 1024.)) + } else if bytes >= 1024 { + format!("{:.1} KB", bytes as f64 / 1024.) + } else { + format!("{bytes} B") + } + } + + /// 指标卡片:图标 + 标签行、大号数值、可选副文本与进度条。 + #[allow(clippy::too_many_arguments)] + fn tile( + &self, + id: &'static str, + icon: IconName, + label: &str, + value: String, + sub: Option, + progress: Option, + cx: &mut Context, + ) -> impl IntoElement { + let muted = cx.theme().muted_foreground; + v_flex() + .flex_1() + .min_w(px(248.)) + .gap_1p5() + .p_3() + .rounded_lg() + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().muted.alpha(0.25)) + .child( + h_flex() + .gap_1p5() + .items_center() + .child(Icon::new(icon).size_4().text_color(muted)) + .child( + div() + .text_xs() + .text_color(muted) + .child(label.to_string()), + ), + ) + .child(div().text_xl().font_semibold().child(value)) + .when_some(sub, |t, s| { + t.child(div().text_xs().text_color(muted).child(s)) + }) + .when_some(progress, |t, p| { + t.child(Progress::new(id).value(p.clamp(0., 100.)).small()) + }) + } + + fn section_header(&self, icon: IconName, title: &str, cx: &mut Context) -> impl IntoElement { + h_flex() + .gap_1p5() + .items_center() + .child(Icon::new(icon).size_4().text_color(cx.theme().accent)) + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().muted_foreground) + .child(title.to_string()), + ) + } + + fn conn_card( + &self, + index: usize, + c: &ConnStat, + cx: &mut Context, + ) -> impl IntoElement { + let muted = cx.theme().muted_foreground; + let pct = (c.bytes as f32 / MAX_CONNECTION_MESSAGE_BYTES as f32 * 100.).min(100.); + v_flex() + .gap_1p5() + .p_2p5() + .rounded_lg() + .border_1() + .border_color(cx.theme().border) + .child( + h_flex() + .justify_between() + .items_center() + .child( + h_flex() + .gap_1p5() + .items_center() + .child(crate::ui::logo::logo( + px(16.), + cx.theme().primary, + cx.theme().primary_foreground, + )) + .child(div().text_sm().font_semibold().child(c.name.clone())) + .child( + div() + .text_xs() + .text_color(muted) + .child(c.address.clone()), + ), + ) + .child( + h_flex() + .gap_1() + .items_center() + .child( + Icon::new(if c.connected { + IconName::Zap + } else { + IconName::Clock + }) + .size_3p5() + .text_color(if c.connected { + cx.theme().accent + } else { + muted + }), + ) + .child( + div() + .text_xs() + .px_1p5() + .py_0p5() + .rounded_full() + .when(c.connected, |d| { + d.bg(cx.theme().accent.alpha(0.12)) + .text_color(cx.theme().accent) + }) + .when(!c.connected, |d| { + d.bg(cx.theme().muted).text_color(muted) + }) + .child(c.status.to_string()), + ), + ), + ) + .child( + h_flex() + .justify_between() + .child( + h_flex() + .gap_1() + .items_center() + .child(Icon::new(IconName::List).size_3p5().text_color(muted)) + .child( + div() + .text_xs() + .text_color(muted) + .child(format!("{} 条消息", c.messages)), + ), + ) + .child( + h_flex() + .gap_1() + .items_center() + .child( + Icon::new(IconName::Database) + .size_3p5() + .text_color(muted), + ) + .child( + div() + .text_xs() + .text_color(muted) + .child(format!( + "预算占用 {:.0}%({} / {})", + pct, + Self::fmt_bytes(c.bytes as u64), + Self::fmt_bytes(MAX_CONNECTION_MESSAGE_BYTES as u64) + )), + ), + ), + ) + .child( + Progress::new(SharedString::from(format!("conn-mem-{index}"))) + .value(pct) + .small(), + ) + } +} + +impl Render for ResourceMonitorDialog { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + if self.first_render { + self.first_render = false; + self.refresh(cx); + } + let s = &self.sample; + let up = crate::sysmon::uptime(); + let uptime_text = if up.as_secs() >= 3600 { + format!("{}h{}m", up.as_secs() / 3600, (up.as_secs() % 3600) / 60) + } else { + format!("{}m{}s", up.as_secs() / 60, up.as_secs() % 60) + }; + let muted = cx.theme().muted_foreground; + let connected_count = self.conns.iter().filter(|c| c.connected).count(); + let cpu = s.cpu_percent; + + // ── 头部:标题 + 运行时长 + 刷新控制 ── + let header = h_flex() + .justify_between() + .items_center() + .child( + h_flex() + .gap_2() + .items_center() + .child(crate::ui::logo::logo( + px(22.), + cx.theme().primary, + cx.theme().primary_foreground, + )) + .child(div().text_base().font_semibold().child("资源监控")) + .child( + div() + .text_xs() + .px_2() + .py_0p5() + .rounded_full() + .bg(cx.theme().muted) + .text_color(muted) + .child(format!("运行 {uptime_text}")), + ), + ) + .child( + h_flex() + .gap_2() + .items_center() + .child( + h_flex() + .gap_1p5() + .items_center() + .child( + div() + .text_xs() + .text_color(muted) + .child("自动刷新"), + ) + .child( + Switch::new("resmon-auto") + .checked(self.auto_refresh) + .on_change(cx.listener(|this, v: &bool, _, cx| { + this.auto_refresh = *v; + cx.notify(); + })), + ), + ) + .child( + Button::new("resmon-refresh") + .icon(IconName::RefreshCw) + .label("立即刷新") + .outline() + .xsmall() + .on_click(cx.listener(|this, _, _, cx| { + this.refresh(cx); + cx.notify(); + })), + ), + ); + + // ── 指标卡片 2×2 ── + let ws = s.working_set; + let peak = s.peak_working_set.max(1); + let tiles = h_flex() + .flex_wrap() + .gap_2() + .child(self.tile( + "tile-cpu", + IconName::Cpu, + "CPU 占用", + format!("{cpu:.1}%"), + Some("按可用核数归一".into()), + Some(cpu), + cx, + )) + .child(self.tile( + "tile-ws", + IconName::MemoryStick, + "工作集内存", + Self::fmt_bytes(ws), + Some(format!("峰值 {}", Self::fmt_bytes(s.peak_working_set))), + Some(ws as f32 / peak as f32 * 100.), + cx, + )) + .child(self.tile( + "tile-private", + IconName::HardDrive, + "提交内存", + Self::fmt_bytes(s.private_bytes), + Some("进程独占的物理页承诺".into()), + None, + cx, + )) + .child(self.tile( + "tile-threads", + IconName::Layers, + "线程 · 句柄", + format!("{} / {}", s.threads, s.handles), + Some("tokio 引擎 + GPUI 渲染".into()), + None, + cx, + )); + + // ── 连接卡片 ── + let mut conns_block = v_flex().gap_2(); + if self.conns.is_empty() { + conns_block = conns_block.child( + h_flex() + .gap_2() + .items_center() + .p_3() + .rounded_lg() + .border_1() + .border_color(cx.theme().border) + .child(Icon::new(IconName::Info).size_4().text_color(muted)) + .child(div().text_sm().text_color(muted).child("暂无连接")), + ); + } + for (i, c) in self.conns.iter().enumerate() { + conns_block = conns_block.child(self.conn_card(i, c, cx)); + } + + v_flex() + .gap_3() + .w(px(600.)) + .child(header) + .child(tiles) + .child( + v_flex() + .gap_2() + .child(self.section_header(IconName::Database, "消息与连接", cx)) + .child( + div() + .text_xs() + .text_color(muted) + .child(format!( + "{} 个连接(已连接 {})· 打开标签 {} · 内存消息 {} 条(每连接上限 {} 条)· 日志 {} 条", + self.conns.len(), + connected_count, + self.tabs, + self.total_messages, + self.app.read(cx).settings.max_messages, + self.logs + )), + ) + .child(conns_block), + ) + .child( + h_flex() + .gap_1p5() + .items_center() + .child(Icon::new(IconName::Tag).size_3p5().text_color(muted)) + .child( + div() + .text_xs() + .text_color(muted) + .child(if self.auto_refresh { + format!( + "版本 v{} · 每 1.5 秒自动刷新", + crate::update::current_version() + ) + } else { + format!( + "版本 v{} · 自动刷新已暂停", + crate::update::current_version() + ) + }), + ), + ) + } +} + +pub fn open(app: Entity, window: &mut Window, cx: &mut App) { + let dialog_view = cx.new(|cx| ResourceMonitorDialog::new(app, cx)); + // 用弱引用刷新:对话框关闭、实体释放后 update 返回 Err,循环退出。 + // 强引用的 update 在实体释放后会 panic。 + let weak_view = dialog_view.downgrade(); + cx.spawn(async move |cx: &mut gpui_kit::AsyncApp| { + loop { + cx.background_executor() + .timer(REFRESH_INTERVAL) + .await; + if weak_view + .update(cx, |d, cx| { + if d.auto_refresh { + d.refresh(cx); + cx.notify(); + } + }) + .is_err() + { + break; + } + } + }) + .detach(); + + window.open_dialog(cx, move |dialog, _, _| { + dialog + .w(px(640.)) + .title("资源监控") + .child(dialog_view.clone()) + .footer( + h_flex() + .justify_end() + .w_full() + .child(Button::new("res-close").label("关闭").outline()), + ) + }); +}