feat: 资源监控对话框(CPU/内存/线程/每连接预算)

- 头部:logo + 运行时长 + 自动刷新开关(默认 1.5s)+ 立即刷新按钮
- 2×2 指标卡:CPU 占用、工作集内存(对比峰值进度条)、提交内存、
  线程·句柄;每卡带图标与说明副文本
- 消息与连接:总数汇总 + 每连接卡片(状态、消息数、32MB 预算
  占用进度条)
- 入口:more-menu「资源监控」与全局快捷键 Ctrl+Shift+R;刷新循环
  持弱引用,对话框关闭即停止
- MQTTX_OPEN_RESMON=1 启动直开(自动化 UI 验证后门)
This commit is contained in:
2026-09-29 17:02:44 +08:00
parent 7de886410d
commit 68409b19d8
3 changed files with 534 additions and 1 deletions
+24 -1
View File
@@ -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::<OpenResourceMonitor>(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::<ExportConnections>(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<Self>) {
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| {
+1
View File
@@ -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;
+509
View File
@@ -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<MqttXApp>,
sampler: Sampler,
sample: Sample,
conns: Vec<ConnStat>,
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<MqttXApp>, cx: &mut Context<Self>) -> 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<Self>) {
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<String>,
progress: Option<f32>,
cx: &mut Context<Self>,
) -> 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<Self>) -> 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<Self>,
) -> 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<Self>) -> 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<MqttXApp>, 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()),
)
});
}