feat(admin): add server status tab (sysinfo host metrics + cache hit rate)

新增 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/手动,默认手动)。
This commit is contained in:
xfy 2026-06-29 18:44:53 +08:00
parent 7553dcf405
commit c4c490b881
8 changed files with 745 additions and 22 deletions

86
Cargo.lock generated
View File

@ -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",

View File

@ -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",

View File

@ -8,3 +8,5 @@
/// 数据库运行状态聚合查询。
pub mod status;
/// 服务器状态聚合查询(应用内 + 主机层)。
pub mod system_status;

View File

@ -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.01.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<CacheStat>,
}
/// 进程启动时刻LazyLock用于计算运行时间。
#[cfg(feature = "server")]
static STARTED_AT: std::sync::LazyLock<std::time::Instant> =
std::sync::LazyLock::new(std::time::Instant::now);
/// 获取服务器状态(只读,管理员)。
#[server(GetServerStatus, "/api")]
pub async fn get_server_status() -> Result<ServerStatus, ServerFnError> {
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![],
})
}
}

View File

@ -203,6 +203,98 @@ static SEARCH_CACHE: LazyLock<SearchCache> = 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<CacheStatSnapshot> {
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<SearchCache> = LazyLock::new(|| {
/// 读取文章分页列表缓存。
#[cfg(feature = "server")]
pub async fn get_post_list(key: &CacheKey) -> Option<(Vec<PostListItem>, 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<PostListItem>, total: i64)
/// 读取已发布文章总数缓存。
#[cfg(feature = "server")]
pub async fn get_total_published_posts() -> Option<i64> {
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<Vec<Tag>> {
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<Tag>) {
/// 按 slug 读取单篇文章缓存。
#[cfg(feature = "server")]
pub async fn get_post_by_slug(slug: &str) -> Option<Option<Post>> {
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<Post>) {
/// 按标签读取文章列表缓存。
#[cfg(feature = "server")]
pub async fn get_posts_by_tag(tag: &str) -> Option<(Vec<PostListItem>, 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<PostListItem>, total: i64) {
/// 按标签+分页读取文章列表缓存。
#[cfg(feature = "server")]
pub async fn get_posts_by_tag_paged(key: &CacheKey) -> Option<(Vec<PostListItem>, 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<PostListItem>, to
/// 读取文章统计缓存。
#[cfg(feature = "server")]
pub async fn get_post_stats() -> Option<PostStats> {
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<Vec<PublicComment>> {
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<PublicComment>) {
/// 读取待审核评论总数缓存。
#[cfg(feature = "server")]
pub async fn get_pending_count() -> Option<i64> {
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<SessionUser> {
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<PostListItem>, 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
}
/// 写入搜索结果缓存。

View File

@ -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 可用

View File

@ -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::<ServerStatus>::None);
let mut loading = use_signal(|| true);
let error = use_signal(|| Option::<String>::None);
// 自动刷新间隔毫秒None = 手动。主机层后台采样,前端可高频轮询。
let mut refresh_ms: Signal<Option<u32>> = 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", "暂无数据" }
}
}
}
}

103
src/sysinfo_sampler.rs Normal file
View File

@ -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<tokio::sync::RwLock<SystemSnapshot>> =
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::<f64>().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()
}