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:
parent
7553dcf405
commit
c4c490b881
86
Cargo.lock
generated
86
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -8,3 +8,5 @@
|
||||
|
||||
/// 数据库运行状态聚合查询。
|
||||
pub mod status;
|
||||
/// 服务器状态聚合查询(应用内 + 主机层)。
|
||||
pub mod system_status;
|
||||
|
||||
118
src/api/database/system_status.rs
Normal file
118
src/api/database/system_status.rs
Normal 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.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<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![],
|
||||
})
|
||||
}
|
||||
}
|
||||
190
src/cache.rs
190
src/cache.rs
@ -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
|
||||
}
|
||||
|
||||
/// 写入搜索结果缓存。
|
||||
|
||||
@ -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 可用
|
||||
|
||||
@ -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
103
src/sysinfo_sampler.rs
Normal 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()
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user