feat: 资源监控对话框(CPU/内存/线程/每连接预算)
- 头部:logo + 运行时长 + 自动刷新开关(默认 1.5s)+ 立即刷新按钮 - 2×2 指标卡:CPU 占用、工作集内存(对比峰值进度条)、提交内存、 线程·句柄;每卡带图标与说明副文本 - 消息与连接:总数汇总 + 每连接卡片(状态、消息数、32MB 预算 占用进度条) - 入口:more-menu「资源监控」与全局快捷键 Ctrl+Shift+R;刷新循环 持弱引用,对话框关闭即停止 - MQTTX_OPEN_RESMON=1 启动直开(自动化 UI 验证后门)
This commit is contained in:
+24
-1
@@ -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| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
)
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user