From c4c490b881cf54d8a3feaea5dcb5244fed9c022c Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 29 Jun 2026 18:44:53 +0800 Subject: [PATCH] feat(admin): add server status tab (sysinfo host metrics + cache hit rate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 sysinfo 依赖(optional+server),sysinfo_sampler 后台采样任务 (SYSINFO_SAMPLE_SECS 可配,默认 0.5s)周期刷新 CPU/内存/磁盘/load 到 RwLock 快照,get_server_status 只读快照零成本。cache.rs 给 9 个缓存加 AtomicU64 hit/miss 计数 + cache_stats() 聚合命中率。 前端 ServerStatusTab:应用内指标(运行时间/连接池/会话/CPU/内存/磁盘/load)+ 缓存命中率表 + 刷新按钮 + 自动刷新开关(500ms/1s/2s/5s/手动,默认手动)。 --- Cargo.lock | 86 +++++++++- Cargo.toml | 2 + src/api/database/mod.rs | 2 + src/api/database/system_status.rs | 118 ++++++++++++++ src/cache.rs | 190 ++++++++++++++++++++-- src/main.rs | 6 + src/pages/admin/system.rs | 260 +++++++++++++++++++++++++++++- src/sysinfo_sampler.rs | 103 ++++++++++++ 8 files changed, 745 insertions(+), 22 deletions(-) create mode 100644 src/api/database/system_status.rs create mode 100644 src/sysinfo_sampler.rs diff --git a/Cargo.lock b/Cargo.lock index a4dd9dc..cc1542e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2248,7 +2248,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -2908,6 +2908,15 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4111,6 +4120,19 @@ dependencies = [ "yaml-rust", ] +[[package]] +name = "sysinfo" +version = "0.34.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4b93974b3d3aeaa036504b8eefd4c039dced109171c1ae973f1dc63b2c7e4b2" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "windows", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -4962,19 +4984,52 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", + "windows-result 0.4.1", "windows-strings", ] +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -4986,6 +5041,17 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -5010,10 +5076,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ "windows-link", - "windows-result", + "windows-result 0.4.1", "windows-strings", ] +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -5415,6 +5490,7 @@ dependencies = [ "serial_test", "sha2 0.10.9", "syntect", + "sysinfo", "tokio", "tokio-postgres", "tower-http", diff --git a/Cargo.toml b/Cargo.toml index 86c9568..806cc49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ image = { version = "0.25", optional = true, default-features = false, features zenwebp = { version = "0.3", optional = true } moka = { version = "0.12", features = ["future", "sync"], optional = true } governor = { version = "0.8", optional = true } +sysinfo = { version = "0.34", optional = true } md-5 = { version = "0.10", optional = true } futures = { version = "0.3", optional = true } bytes = { version = "1", optional = true } @@ -87,6 +88,7 @@ server = [ "dep:zenwebp", "dep:moka", "dep:governor", + "dep:sysinfo", "dep:md-5", "dep:futures", "dep:bytes", diff --git a/src/api/database/mod.rs b/src/api/database/mod.rs index 7b87c98..0076df7 100644 --- a/src/api/database/mod.rs +++ b/src/api/database/mod.rs @@ -8,3 +8,5 @@ /// 数据库运行状态聚合查询。 pub mod status; +/// 服务器状态聚合查询(应用内 + 主机层)。 +pub mod system_status; diff --git a/src/api/database/system_status.rs b/src/api/database/system_status.rs new file mode 100644 index 0000000..6bc71bf --- /dev/null +++ b/src/api/database/system_status.rs @@ -0,0 +1,118 @@ +#![allow(clippy::unused_unit, deprecated)] + +//! 服务器状态聚合查询(只读):应用内指标 + 主机层指标。 +//! +//! 应用内:DB 连接池、moka 缓存命中率、活跃会话数、进程运行时间。 +//! 主机层:sysinfo 后台采样快照(CPU/内存/磁盘/load),由 [`sysinfo_sampler`] 维护。 + +use dioxus::prelude::*; +use serde::{Deserialize, Serialize}; + +use crate::api::auth::get_current_admin_user; +use crate::api::error::AppError; + +/// 单个缓存的统计快照(前端展示用)。 +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CacheStat { + pub name: String, + pub entry_count: u64, + pub hits: u64, + pub misses: u64, + /// 命中率(0.0–1.0)。 + pub hit_rate: f64, +} + +/// 服务器状态聚合数据。 +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ServerStatus { + /// 进程运行时间(秒)。 + pub uptime_secs: u64, + /// DB 连接池总大小(已创建连接数)。 + pub pool_size: usize, + /// 连接池最大容量。 + pub pool_max_size: usize, + /// 当前空闲可用连接数。 + pub pool_available: usize, + /// 正在等待获取连接的请求数。 + pub pool_waiting: usize, + /// 有效会话数(sessions 表 expires_at > now())。 + pub active_sessions: i64, + /// 主机层指标快照(CPU/内存/磁盘等)。 + pub host: crate::sysinfo_sampler::SystemSnapshot, + /// 各缓存命中率与条目数。 + pub caches: Vec, +} + +/// 进程启动时刻(LazyLock),用于计算运行时间。 +#[cfg(feature = "server")] +static STARTED_AT: std::sync::LazyLock = + std::sync::LazyLock::new(std::time::Instant::now); + +/// 获取服务器状态(只读,管理员)。 +#[server(GetServerStatus, "/api")] +pub async fn get_server_status() -> Result { + let _user = get_current_admin_user().await?; + + #[cfg(feature = "server")] + { + use crate::cache::cache_stats; + use crate::db::pool::{get_conn, DB_POOL}; + + // 连接池状态 + let pool_status = DB_POOL.status(); + let pool_size = pool_status.size; + let pool_max_size = pool_status.max_size; + let pool_available = pool_status.available; + let pool_waiting = pool_status.waiting; + + // 活跃会话数 + let client = get_conn().await.map_err(AppError::db_conn)?; + let active_sessions: i64 = client + .query_one("SELECT count(*) FROM sessions WHERE expires_at > now()", &[]) + .await + .map_err(AppError::query)? + .get(0); + + // 进程运行时间 + let uptime_secs = STARTED_AT.elapsed().as_secs(); + + // 主机层快照 + let host = crate::sysinfo_sampler::read_snapshot().await; + + // 缓存统计 + let caches = cache_stats() + .into_iter() + .map(|s| CacheStat { + name: s.name.to_string(), + entry_count: s.entry_count, + hits: s.hits, + misses: s.misses, + hit_rate: s.hit_rate, + }) + .collect(); + + Ok(ServerStatus { + uptime_secs, + pool_size, + pool_max_size, + pool_available, + pool_waiting, + active_sessions, + host, + caches, + }) + } + #[cfg(not(feature = "server"))] + { + Ok(ServerStatus { + uptime_secs: 0, + pool_size: 0, + pool_max_size: 0, + pool_available: 0, + pool_waiting: 0, + active_sessions: 0, + host: crate::sysinfo_sampler::read_snapshot().await, + caches: vec![], + }) + } +} diff --git a/src/cache.rs b/src/cache.rs index 4debf69..93e2b9a 100644 --- a/src/cache.rs +++ b/src/cache.rs @@ -203,6 +203,98 @@ static SEARCH_CACHE: LazyLock = LazyLock::new(|| { .build() }); +// ============================================================================ +// 命中率统计(供系统状态面板展示) +// ============================================================================ + +/// 单个缓存的命中/未命中计数。用 AtomicU64 在 get_* 路径上记录。 +#[cfg(feature = "server")] +pub struct CacheStats { + pub name: &'static str, + hits: std::sync::atomic::AtomicU64, + misses: std::sync::atomic::AtomicU64, +} + +#[cfg(feature = "server")] +impl CacheStats { + pub const fn new(name: &'static str) -> Self { + Self { + name, + hits: std::sync::atomic::AtomicU64::new(0), + misses: std::sync::atomic::AtomicU64::new(0), + } + } + fn record_hit(&self) { + self.hits.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + fn record_miss(&self) { + self.misses.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } +} + +// 每个缓存一份统计实例(const,启动时零开销初始化)。 +#[cfg(feature = "server")] +static POST_LIST_STATS: CacheStats = CacheStats::new("文章列表"); +#[cfg(feature = "server")] +static TAG_STATS: CacheStats = CacheStats::new("标签"); +#[cfg(feature = "server")] +static SINGLE_POST_STATS: CacheStats = CacheStats::new("单篇文章"); +#[cfg(feature = "server")] +static POST_STATS_STATS: CacheStats = CacheStats::new("文章统计"); +#[cfg(feature = "server")] +static TAG_POSTS_STATS: CacheStats = CacheStats::new("标签文章"); +#[cfg(feature = "server")] +static COMMENT_STATS: CacheStats = CacheStats::new("评论"); +#[cfg(feature = "server")] +static PENDING_COUNT_STATS: CacheStats = CacheStats::new("待审评论数"); +#[cfg(feature = "server")] +static SESSION_STATS: CacheStats = CacheStats::new("会话用户"); +#[cfg(feature = "server")] +static SEARCH_STATS: CacheStats = CacheStats::new("搜索"); + +/// 缓存统计快照项(序列化给前端展示)。 +#[cfg(feature = "server")] +#[derive(Debug)] +pub struct CacheStatSnapshot { + pub name: &'static str, + pub entry_count: u64, + pub hits: u64, + pub misses: u64, + pub hit_rate: f64, +} + +/// 聚合所有缓存的统计快照(供 get_server_status 调用)。 +#[cfg(feature = "server")] +pub fn cache_stats() -> Vec { + fn snap( + stats: &CacheStats, + entry_count: u64, + ) -> CacheStatSnapshot { + let hits = stats.hits.load(std::sync::atomic::Ordering::Relaxed); + let misses = stats.misses.load(std::sync::atomic::Ordering::Relaxed); + let total = hits + misses; + let hit_rate = if total == 0 { 0.0 } else { hits as f64 / total as f64 }; + CacheStatSnapshot { + name: stats.name, + entry_count, + hits, + misses, + hit_rate, + } + } + vec![ + snap(&POST_LIST_STATS, POST_LIST_CACHE.entry_count()), + snap(&TAG_STATS, TAG_LIST_CACHE.entry_count()), + snap(&SINGLE_POST_STATS, SINGLE_POST_CACHE.entry_count()), + snap(&POST_STATS_STATS, POST_STATS_CACHE.entry_count()), + snap(&TAG_POSTS_STATS, TAG_POSTS_CACHE.entry_count()), + snap(&COMMENT_STATS, COMMENT_CACHE.entry_count()), + snap(&PENDING_COUNT_STATS, PENDING_COUNT_CACHE.entry_count()), + snap(&SESSION_STATS, SESSION_CACHE.entry_count()), + snap(&SEARCH_STATS, SEARCH_CACHE.entry_count()), + ] +} + // ============================================================================ // 公共缓存 API // ============================================================================ @@ -210,7 +302,13 @@ static SEARCH_CACHE: LazyLock = LazyLock::new(|| { /// 读取文章分页列表缓存。 #[cfg(feature = "server")] pub async fn get_post_list(key: &CacheKey) -> Option<(Vec, i64)> { - POST_LIST_CACHE.get(key).await + let v = POST_LIST_CACHE.get(key).await; + if v.is_some() { + POST_LIST_STATS.record_hit(); + } else { + POST_LIST_STATS.record_miss(); + } + v } /// 写入文章分页列表缓存。 @@ -222,10 +320,16 @@ pub async fn set_post_list(key: &CacheKey, posts: Vec, total: i64) /// 读取已发布文章总数缓存。 #[cfg(feature = "server")] pub async fn get_total_published_posts() -> Option { - POST_LIST_CACHE + let v = POST_LIST_CACHE .get(&CacheKey::TotalPublishedPosts) .await - .map(|(_, total)| total) + .map(|(_, total)| total); + if v.is_some() { + POST_LIST_STATS.record_hit(); + } else { + POST_LIST_STATS.record_miss(); + } + v } /// 写入已发布文章总数缓存,文章列表部分置空以节省内存。 @@ -239,7 +343,13 @@ pub async fn set_total_published_posts(total: i64) { /// 读取全部标签缓存。 #[cfg(feature = "server")] pub async fn get_tag_list() -> Option> { - TAG_LIST_CACHE.get(&CacheKey::AllTags).await + let v = TAG_LIST_CACHE.get(&CacheKey::AllTags).await; + if v.is_some() { + TAG_STATS.record_hit(); + } else { + TAG_STATS.record_miss(); + } + v } /// 写入全部标签缓存。 @@ -251,9 +361,15 @@ pub async fn set_tag_list(tags: Vec) { /// 按 slug 读取单篇文章缓存。 #[cfg(feature = "server")] pub async fn get_post_by_slug(slug: &str) -> Option> { - SINGLE_POST_CACHE + let v = SINGLE_POST_CACHE .get(&CacheKey::PostBySlug(slug.to_string())) - .await + .await; + if v.is_some() { + SINGLE_POST_STATS.record_hit(); + } else { + SINGLE_POST_STATS.record_miss(); + } + v } /// 按 slug 写入单篇文章缓存,None 表示文章不存在。 @@ -267,9 +383,15 @@ pub async fn set_post_by_slug(slug: &str, post: Option) { /// 按标签读取文章列表缓存。 #[cfg(feature = "server")] pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec, i64)> { - TAG_POSTS_CACHE + let v = TAG_POSTS_CACHE .get(&CacheKey::PostsByTag(tag.to_string())) - .await + .await; + if v.is_some() { + TAG_POSTS_STATS.record_hit(); + } else { + TAG_POSTS_STATS.record_miss(); + } + v } /// 按标签写入文章列表缓存。 @@ -283,7 +405,13 @@ pub async fn set_posts_by_tag(tag: &str, posts: Vec, total: i64) { /// 按标签+分页读取文章列表缓存。 #[cfg(feature = "server")] pub async fn get_posts_by_tag_paged(key: &CacheKey) -> Option<(Vec, i64)> { - TAG_POSTS_CACHE.get(key).await + let v = TAG_POSTS_CACHE.get(key).await; + if v.is_some() { + TAG_POSTS_STATS.record_hit(); + } else { + TAG_POSTS_STATS.record_miss(); + } + v } /// 按标签+分页写入文章列表缓存。 @@ -295,7 +423,13 @@ pub async fn set_posts_by_tag_paged(key: &CacheKey, posts: Vec, to /// 读取文章统计缓存。 #[cfg(feature = "server")] pub async fn get_post_stats() -> Option { - POST_STATS_CACHE.get(&CacheKey::PostStats).await + let v = POST_STATS_CACHE.get(&CacheKey::PostStats).await; + if v.is_some() { + POST_STATS_STATS.record_hit(); + } else { + POST_STATS_STATS.record_miss(); + } + v } /// 写入文章统计缓存。 @@ -370,9 +504,15 @@ pub fn invalidate_all_post_caches() { /// 按文章主键读取评论列表缓存。 #[cfg(feature = "server")] pub async fn get_comments_by_post(post_id: i32) -> Option> { - COMMENT_CACHE + let v = COMMENT_CACHE .get(&CacheKey::CommentsByPost { post_id }) - .await + .await; + if v.is_some() { + COMMENT_STATS.record_hit(); + } else { + COMMENT_STATS.record_miss(); + } + v } /// 按文章主键写入评论列表缓存。 @@ -386,9 +526,15 @@ pub async fn set_comments_by_post(post_id: i32, comments: Vec) { /// 读取待审核评论总数缓存。 #[cfg(feature = "server")] pub async fn get_pending_count() -> Option { - PENDING_COUNT_CACHE + let v = PENDING_COUNT_CACHE .get(&CacheKey::PendingCommentCount) - .await + .await; + if v.is_some() { + PENDING_COUNT_STATS.record_hit(); + } else { + PENDING_COUNT_STATS.record_miss(); + } + v } /// 写入待审核评论总数缓存。 @@ -408,7 +554,13 @@ pub fn normalize_search_key(query: &str) -> String { /// 读取会话用户缓存。 #[cfg(feature = "server")] pub async fn get_session_user(token_hash: &str) -> Option { - SESSION_CACHE.get(token_hash).await + let v = SESSION_CACHE.get(token_hash).await; + if v.is_some() { + SESSION_STATS.record_hit(); + } else { + SESSION_STATS.record_miss(); + } + v } /// 写入会话用户缓存。 @@ -426,7 +578,13 @@ pub async fn invalidate_session_user(token_hash: &str) { /// 读取搜索结果缓存。 #[cfg(feature = "server")] pub async fn get_search_results(query: &str) -> Option<(Vec, i64)> { - SEARCH_CACHE.get(&normalize_search_key(query)).await + let v = SEARCH_CACHE.get(&normalize_search_key(query)).await; + if v.is_some() { + SEARCH_STATS.record_hit(); + } else { + SEARCH_STATS.record_miss(); + } + v } /// 写入搜索结果缓存。 diff --git a/src/main.rs b/src/main.rs index 773542e..ea3a052 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,6 +23,9 @@ mod hooks; mod models; mod pages; mod router; +// sysinfo_sampler 仅在 server feature 启用时编译:主机指标后台采样 + RwLock 快照。 +#[cfg(feature = "server")] +mod sysinfo_sampler; // ssr_cache 仅在 server feature 启用时编译;保存 SSR 世代号失效状态。 #[cfg(feature = "server")] mod ssr_cache; @@ -325,6 +328,9 @@ fn main() { tasks::image_cache_cleanup::run_cleanup().await; }); + // 启动后台采样任务:sysinfo 主机指标(CPU/内存/磁盘),server function 只读快照。 + sysinfo_sampler::spawn_sampler(); + // 配置增量渲染缓存,默认缓存 3600 秒,可通过 SSR_CACHE_SECS 覆盖。 // 注意:src/ssr_cache.rs 中的世代号是未来就绪基础设施,当前并不会使 // Dioxus 0.7 的 SSR 缓存实际失效(Dioxus 未暴露相应 API)。在 API 可用 diff --git a/src/pages/admin/system.rs b/src/pages/admin/system.rs index 3ce175e..2a76362 100644 --- a/src/pages/admin/system.rs +++ b/src/pages/admin/system.rs @@ -60,7 +60,7 @@ pub fn System() -> Element { div { match active_tab() { SystemTab::DbStatus => rsx! { DbStatusTab {} }, - SystemTab::ServerStatus => rsx! { div { class: "text-paper-secondary py-8", "服务器状态(待实现)" } }, + SystemTab::ServerStatus => rsx! { ServerStatusTab {} }, SystemTab::SqlConsole => rsx! { div { class: "text-paper-secondary py-8", "SQL 控制台(待实现)" } }, SystemTab::Export => rsx! { div { class: "text-paper-secondary py-8", "数据导出(待实现)" } }, SystemTab::Backup => rsx! { div { class: "text-paper-secondary py-8", "备份恢复(待实现)" } }, @@ -376,3 +376,261 @@ fn DbStatusTab() -> Element { } } } + +/// 秒数 → 人类可读运行时间(如 1d 2h 3m)。 +fn format_uptime(secs: u64) -> String { + let d = secs / 86400; + let h = (secs % 86400) / 3600; + let m = (secs % 3600) / 60; + if d > 0 { + format!("{d}d {h}h {m}m") + } else if h > 0 { + format!("{h}h {m}m") + } else if m > 0 { + format!("{m}m") + } else { + format!("{secs}s") + } +} + +/// 服务器状态 tab:应用内指标(连接池/会话/缓存命中率)+ 主机层(CPU/内存/磁盘)。 +/// 手动刷新 + 自动刷新开关(500ms/1s/2s/5s/手动,默认手动)。 +/// 主机层数据由后台 500ms 采样,前端轮询只读快照零成本,故可高频。 +#[allow(non_snake_case)] +fn ServerStatusTab() -> Element { + use crate::api::database::system_status::ServerStatus; + #[cfg(target_arch = "wasm32")] + use crate::api::database::system_status::get_server_status; + use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS}; + + let status = use_signal(|| Option::::None); + let mut loading = use_signal(|| true); + let error = use_signal(|| Option::::None); + // 自动刷新间隔(毫秒);None = 手动。主机层后台采样,前端可高频轮询。 + let mut refresh_ms: Signal> = use_signal(|| None); + + let mut load_once = move || { + loading.set(true); + #[cfg(target_arch = "wasm32")] + { + spawn(async move { + match get_server_status().await { + Ok(s) => { + status.set(Some(s)); + error.set(None); + } + Err(e) => error.set(Some(e.to_string())), + } + loading.set(false); + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + loading.set(false); + } + }; + + use_effect(move || { + load_once(); + }); + + // 自动刷新:refresh_ms 变化时重建 future。 + use_future(move || { + let interval_ms = refresh_ms(); + let status_f = status; + let loading_f = loading; + let error_f = error; + async move { + #[cfg(target_arch = "wasm32")] + { + let ms = interval_ms.unwrap_or(0); + if ms == 0 { + return; + } + loop { + wasm_sleep(ms).await; + loading_f.set(true); + spawn(async move { + match get_server_status().await { + Ok(s) => { + status_f.set(Some(s)); + error_f.set(None); + } + Err(e) => error_f.set(Some(e.to_string())), + } + loading_f.set(false); + }); + } + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (interval_ms, status_f, loading_f, error_f); + } + } + }); + + let current = status.read().clone(); + // rsx 不支持格式说明符({:.1}),也不允许在 for 循环体内 let,故预格式化所有展示值。 + let cpu_pct = current + .as_ref() + .map(|s| format!("{:.1}%", s.host.cpu_usage)) + .unwrap_or_default(); + let load_1 = current + .as_ref() + .map(|s| format!("{:.2}", s.host.load_avg_1)) + .unwrap_or_default(); + // 缓存表预格式化:把每行需要展示的值都算好字符串,避免在 rsx 里做格式化。 + let cache_rows: Vec<(String, u64, u64, u64, String)> = current + .as_ref() + .map(|s| { + s.caches + .iter() + .map(|c| { + ( + c.name.clone(), + c.entry_count, + c.hits, + c.misses, + format!("{:.1}%", c.hit_rate * 100.0), + ) + }) + .collect() + }) + .unwrap_or_default(); + + rsx! { + div { class: "space-y-6", + div { class: "flex items-center justify-between", + button { + class: "px-3 py-1.5 text-sm bg-paper-accent text-paper-theme rounded hover:brightness-110 transition disabled:opacity-50", + disabled: loading(), + onclick: move |_| { + loading.set(true); + #[cfg(target_arch = "wasm32")] + { + spawn(async move { + match get_server_status().await { + Ok(s) => { + status.set(Some(s)); + error.set(None); + } + Err(e) => error.set(Some(e.to_string())), + } + loading.set(false); + }); + } + #[cfg(not(target_arch = "wasm32"))] + { + loading.set(false); + } + }, + if loading() { "加载中..." } else { "刷新" } + } + div { class: "flex items-center gap-2", + span { class: "text-sm text-paper-secondary", "自动刷新" } + select { + class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary", + value: "{refresh_ms().map(|s| s.to_string()).unwrap_or_default()}", + onchange: move |e| { + let v = e.value(); + refresh_ms.set(match v.as_str() { + "500" => Some(500), + "1000" => Some(1000), + "2000" => Some(2000), + "5000" => Some(5000), + _ => None, + }); + }, + option { value: "", "手动" } + option { value: "500", "500ms" } + option { value: "1000", "1s" } + option { value: "2000", "2s" } + option { value: "5000", "5s" } + } + } + } + + if let Some(err) = error.read().clone() { + div { class: "bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 text-sm text-red-700 dark:text-red-300", + "加载失败:{err}" + } + } else if let Some(s) = current { + // 应用内指标卡片 + div { class: "grid grid-cols-2 md:grid-cols-4 gap-4", + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "运行时间" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", "{format_uptime(s.uptime_secs)}" } + } + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "DB 连接池" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", "{s.pool_size} / {s.pool_max_size}" } + p { class: "text-xs text-paper-secondary", "空闲 {s.pool_available} · 等待 {s.pool_waiting}" } + } + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "活跃会话" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", "{s.active_sessions}" } + } + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "CPU" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", "{cpu_pct}" } + } + } + + // 主机层指标卡片 + div { class: "grid grid-cols-2 md:grid-cols-4 gap-4", + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "内存" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", "{format_bytes(s.host.used_memory as i64)} / {format_bytes(s.host.total_memory as i64)}" } + } + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "磁盘" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", "{format_bytes((s.host.disk_total - s.host.disk_available) as i64)} / {format_bytes(s.host.disk_total as i64)}" } + } + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "Load (1m)" } + p { class: "mt-1 text-lg font-semibold text-paper-primary", "{load_1}" } + } + div { class: "{ADMIN_CARD_CLASS} p-4", + p { class: "text-xs text-paper-secondary", "系统" } + p { class: "mt-1 text-sm font-medium text-paper-primary truncate", "{s.host.os_name}" } + } + } + + // 缓存命中率表 + div { class: "{ADMIN_TABLE_CLASS}", + div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary", + "缓存命中率" + } + div { class: "overflow-x-auto", + table { class: "w-full text-sm", + thead { + tr { class: "border-b border-paper-border text-left text-paper-secondary", + th { class: "px-4 py-2 font-medium", "缓存" } + th { class: "px-4 py-2 font-medium text-right", "条目" } + th { class: "px-4 py-2 font-medium text-right", "命中" } + th { class: "px-4 py-2 font-medium text-right", "未命中" } + th { class: "px-4 py-2 font-medium text-right", "命中率" } + } + } + tbody { + for (name, entry_count, hits, misses, rate_pct) in cache_rows.iter() { + tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors", + td { class: "px-4 py-2 text-paper-primary", "{name}" } + td { class: "px-4 py-2 text-right text-paper-secondary", "{entry_count}" } + td { class: "px-4 py-2 text-right text-paper-secondary", "{hits}" } + td { class: "px-4 py-2 text-right text-paper-secondary", "{misses}" } + td { class: "px-4 py-2 text-right text-paper-primary font-medium", "{rate_pct}" } + } + } + } + } + } + } + } else if loading() { + div { class: "text-paper-secondary py-8", "加载中..." } + } else { + div { class: "text-paper-secondary py-8", "暂无数据" } + } + } + } +} diff --git a/src/sysinfo_sampler.rs b/src/sysinfo_sampler.rs new file mode 100644 index 0000000..93c37a5 --- /dev/null +++ b/src/sysinfo_sampler.rs @@ -0,0 +1,103 @@ +//! sysinfo 主机指标后台采样。 +//! +//! sysinfo 的 CPU% **不是即时值**,需要两次采样间的 delta。因此用后台任务周期 +//! 刷新,server function 只读 [`RwLock`] 快照(毫秒级返回,零采样成本)。 +//! +//! 采样间隔由环境变量 `SYSINFO_SAMPLE_SECS` 配置(默认 0.5 秒,支持小数如 0.1)。 + +use std::sync::LazyLock; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +/// 主机指标快照(由后台采样任务周期更新)。 +#[derive(Clone, Default, Serialize, Deserialize, Debug)] +pub struct SystemSnapshot { + /// 总体 CPU 使用率(百分比)。 + pub cpu_usage: f32, + /// 系统 1 分钟平均负载。 + pub load_avg_1: f64, + /// 总物理内存(字节)。 + pub total_memory: u64, + /// 已用物理内存(字节)。 + pub used_memory: u64, + /// 主磁盘总空间(字节,取根分区或最大盘)。 + pub disk_total: u64, + /// 主磁盘可用空间(字节)。 + pub disk_available: u64, + /// 操作系统版本(如 "macOS 15.5")。 + pub os_name: String, + /// 内核版本。 + pub kernel_version: String, + /// 系统启动后秒数。 + pub uptime_secs: u64, +} + +#[cfg(feature = "server")] +static SNAPSHOT: LazyLock> = + LazyLock::new(|| tokio::sync::RwLock::new(SystemSnapshot::default())); + +/// 采样间隔,由环境变量 `SYSINFO_SAMPLE_SECS` 配置,默认 0.5 秒,下限 0.05 秒。 +#[cfg(feature = "server")] +fn sample_interval() -> Duration { + let secs = std::env::var("SYSINFO_SAMPLE_SECS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(0.5); + Duration::from_secs_f64(secs.max(0.05)) +} + +/// 启动后台采样任务。应在 main 启动流程(migrate 之后、serve 之前)调用一次。 +/// +/// CPU% 需两次采样 delta,故循环里先 refresh 再 sleep 再 refresh 才有有效值。 +#[cfg(feature = "server")] +pub fn spawn_sampler() { + tokio::spawn(async move { + use sysinfo::{Disks, System}; + + let mut sys = System::new(); + let interval = sample_interval(); + // 首次 refresh 建立基线,CPU% 在下一轮才有意义。 + sys.refresh_cpu_usage(); + let disks = Disks::new_with_refreshed_list(); + + loop { + tokio::time::sleep(interval).await; + sys.refresh_cpu_usage(); + sys.refresh_memory(); + let load = System::load_average(); + + // 主磁盘:取空间最大的盘(通常是数据盘)。 + let (disk_total, disk_available) = disks + .list() + .iter() + .max_by_key(|d| d.total_space()) + .map(|d| (d.total_space(), d.available_space())) + .unwrap_or((0, 0)); + + let snap = SystemSnapshot { + cpu_usage: sys.global_cpu_usage(), + load_avg_1: load.one, + total_memory: sys.total_memory(), + used_memory: sys.used_memory(), + disk_total, + disk_available, + os_name: System::long_os_version().unwrap_or_default(), + kernel_version: System::kernel_version().unwrap_or_default(), + uptime_secs: System::uptime(), + }; + *SNAPSHOT.write().await = snap; + } + }); +} + +/// 读取最新快照(只读,毫秒级返回,不触发采样)。 +#[cfg(feature = "server")] +pub async fn read_snapshot() -> SystemSnapshot { + SNAPSHOT.read().await.clone() +} + +#[cfg(not(feature = "server"))] +pub async fn read_snapshot() -> SystemSnapshot { + SystemSnapshot::default() +}