Skip to main content

yggdrasil/pages/admin/system/
server_status.rs

1//! 服务器状态 tab。
2
3use dioxus::prelude::*;
4
5use crate::components::forms::{FormSelect, FORM_SELECT_COMPACT_CLASS};
6use crate::components::skeletons::atoms::SkeletonBox;
7use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
8use crate::components::ui::LoadingButton;
9
10use crate::utils::format_bytes;
11
12/// 秒数 → 人类可读运行时间(如 1d 2h 3m)。
13fn format_uptime(secs: u64) -> String {
14    let d = secs / 86400;
15    let h = (secs % 86400) / 3600;
16    let m = (secs % 3600) / 60;
17    if d > 0 {
18        format!("{d}d {h}h {m}m")
19    } else if h > 0 {
20        format!("{h}h {m}m")
21    } else if m > 0 {
22        format!("{m}m")
23    } else {
24        format!("{secs}s")
25    }
26}
27/// 自动刷新间隔可选项(毫秒;None = 手动)。
28const REFRESH_MS_OPTIONS: &[(Option<u32>, &str)] = &[
29    (None, "手动"),
30    (Some(500), "500ms"),
31    (Some(1000), "1s"),
32    (Some(2000), "2s"),
33    (Some(5000), "5s"),
34];
35
36/// 服务器状态 tab:应用内指标(连接池/会话/缓存命中率)+ 主机层(CPU/内存/磁盘)。
37/// 手动刷新 + 自动刷新开关(500ms/1s/2s/5s/手动,默认手动)。
38/// 主机层数据由后台 500ms 采样,前端轮询只读快照零成本,故可高频。
39#[allow(non_snake_case)]
40#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
41pub(super) fn ServerStatusTab() -> Element {
42    #[cfg(target_arch = "wasm32")]
43    use crate::api::database::system_status::get_server_status;
44    use crate::api::database::system_status::ServerStatus;
45
46    let mut status = use_signal(|| Option::<ServerStatus>::None);
47    let mut loading = use_signal(|| true);
48    let mut error = use_signal(|| Option::<String>::None);
49    // 自动刷新间隔(毫秒);None = 手动。主机层后台采样,前端可高频轮询。
50    let mut refresh_ms: Signal<Option<u32>> = use_signal(|| None);
51
52    let mut load_once = move || {
53        loading.set(true);
54        #[cfg(target_arch = "wasm32")]
55        {
56            spawn(async move {
57                match get_server_status().await {
58                    Ok(s) => {
59                        status.set(Some(s));
60                        error.set(None);
61                    }
62                    Err(e) => error.set(Some(e.to_string())),
63                }
64                loading.set(false);
65            });
66        }
67        #[cfg(not(target_arch = "wasm32"))]
68        {
69            loading.set(false);
70        }
71    };
72
73    use_effect(move || {
74        load_once();
75    });
76
77    // 自动刷新:同 DbStatusTab,采用官方推荐的单一长生命周期 loop 模式。
78    // 闭包体内不读任何 signal(避免隐式依赖追踪导致重建),loop 内部实时读取。
79    use_future(move || async move {
80        #[cfg(target_arch = "wasm32")]
81        {
82            loop {
83                let ms = refresh_ms().unwrap_or(0);
84                if ms == 0 {
85                    crate::utils::time::sleep_ms(200).await;
86                    continue;
87                }
88                crate::utils::time::sleep_ms(ms).await;
89                if refresh_ms().is_none() {
90                    continue;
91                }
92                loading.set(true);
93                spawn(async move {
94                    match get_server_status().await {
95                        Ok(s) => {
96                            status.set(Some(s));
97                            error.set(None);
98                        }
99                        Err(e) => error.set(Some(e.to_string())),
100                    }
101                    loading.set(false);
102                });
103            }
104        }
105        #[cfg(not(target_arch = "wasm32"))]
106        {
107            let _ = (status, loading, error, refresh_ms);
108        }
109    });
110
111    let current = status.read().clone();
112    // rsx 不支持格式说明符({:.1}),也不允许在 for 循环体内 let,故预格式化所有展示值。
113    let cpu_pct = current
114        .as_ref()
115        .map(|s| format!("{:.1}%", s.host.cpu_usage))
116        .unwrap_or_default();
117    let load_1 = current
118        .as_ref()
119        .map(|s| format!("{:.2}", s.host.load_avg_1))
120        .unwrap_or_default();
121    // 缓存表预格式化:把每行需要展示的值都算好字符串,避免在 rsx 里做格式化。
122    let cache_rows: Vec<(String, u64, u64, u64, String)> = current
123        .as_ref()
124        .map(|s| {
125            s.caches
126                .iter()
127                .map(|c| {
128                    (
129                        c.name.clone(),
130                        c.entry_count,
131                        c.hits,
132                        c.misses,
133                        format!("{:.1}%", c.hit_rate * 100.0),
134                    )
135                })
136                .collect()
137        })
138        .unwrap_or_default();
139
140    rsx! {
141        div { class: "space-y-6",
142            div { class: "flex items-center justify-between gap-4",
143                LoadingButton {
144                    label: "刷新数据".to_string(),
145                    loading: loading(),
146                    variant: "sm",
147                    onclick: move |_| {
148                        loading.set(true);
149                        #[cfg(target_arch = "wasm32")]
150                        {
151                            spawn(async move {
152                                match get_server_status().await {
153                                    Ok(s) => {
154                                        status.set(Some(s));
155                                        error.set(None);
156                                    }
157                                    Err(e) => error.set(Some(e.to_string())),
158                                }
159                                loading.set(false);
160                            });
161                        }
162                        #[cfg(not(target_arch = "wasm32"))]
163                        {
164                            loading.set(false);
165                        }
166                    },
167                }
168                div { class: "inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-[var(--color-paper-entry)]/60 border border-[var(--color-paper-border)]/60 shadow-2xs",
169                    svg {
170                        class: "w-3.5 h-3.5 text-[var(--color-paper-tertiary)]",
171                        xmlns: "http://www.w3.org/2000/svg",
172                        view_box: "0 0 24 24",
173                        fill: "none",
174                        stroke: "currentColor",
175                        stroke_width: "2",
176                        stroke_linecap: "round",
177                        stroke_linejoin: "round",
178                        circle { cx: "12", cy: "12", r: "10" }
179                        polyline { points: "12 6 12 12 16 14" }
180                    }
181                    span { class: "text-xs font-medium text-[var(--color-paper-secondary)]", "自动刷新" }
182                    FormSelect {
183                        trigger_class: Some(FORM_SELECT_COMPACT_CLASS),
184                        value: refresh_ms(),
185                        options: REFRESH_MS_OPTIONS.to_vec(),
186                        onchange: move |v| refresh_ms.set(v),
187                    }
188                }
189            }
190            if let Some(err) = error.read().clone() {
191                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",
192                    "加载失败:{err}"
193                }
194            } else if let Some(s) = current {
195                // 应用内指标卡片
196                div { class: "grid grid-cols-2 lg:grid-cols-4 gap-4",
197                    div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl p-5 border border-[var(--color-paper-border)]/70 shadow-xs flex flex-col gap-1.5",
198                        div { class: "flex items-center justify-between text-[var(--color-paper-secondary)]",
199                            span { class: "text-xs font-semibold uppercase tracking-wider", "服务运行时间" }
200                            svg {
201                                class: "w-4 h-4 text-[var(--color-paper-tertiary)]",
202                                xmlns: "http://www.w3.org/2000/svg",
203                                view_box: "0 0 24 24",
204                                fill: "none",
205                                stroke: "currentColor",
206                                stroke_width: "2",
207                                stroke_linecap: "round",
208                                stroke_linejoin: "round",
209                                circle { cx: "12", cy: "12", r: "10" }
210                                polyline { points: "12 6 12 12 16 14" }
211                            }
212                        }
213                        p { class: "text-2xl font-extrabold font-mono text-[var(--color-paper-primary)] tracking-tight",
214                            "{format_uptime(s.uptime_secs)}"
215                        }
216                    }
217                    div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl p-5 border border-[var(--color-paper-border)]/70 shadow-xs flex flex-col gap-1.5",
218                        div { class: "flex items-center justify-between text-[var(--color-paper-secondary)]",
219                            span { class: "text-xs font-semibold uppercase tracking-wider", "DB 连接池" }
220                            svg {
221                                class: "w-4 h-4 text-[var(--color-paper-tertiary)]",
222                                xmlns: "http://www.w3.org/2000/svg",
223                                view_box: "0 0 24 24",
224                                fill: "none",
225                                stroke: "currentColor",
226                                stroke_width: "2",
227                                stroke_linecap: "round",
228                                stroke_linejoin: "round",
229                                path { d: "M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" }
230                            }
231                        }
232                        p { class: "text-2xl font-extrabold font-mono text-[var(--color-paper-primary)] tracking-tight",
233                            "{s.pool_size} / {s.pool_max_size}"
234                        }
235                        p { class: "text-xs text-[var(--color-paper-tertiary)] mt-0.5",
236                            "空闲 {s.pool_available} · 等待 {s.pool_waiting}"
237                        }
238                    }
239                    div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl p-5 border border-[var(--color-paper-border)]/70 shadow-xs flex flex-col gap-1.5",
240                        div { class: "flex items-center justify-between text-[var(--color-paper-secondary)]",
241                            span { class: "text-xs font-semibold uppercase tracking-wider", "活跃会话" }
242                            svg {
243                                class: "w-4 h-4 text-[var(--color-paper-tertiary)]",
244                                xmlns: "http://www.w3.org/2000/svg",
245                                view_box: "0 0 24 24",
246                                fill: "none",
247                                stroke: "currentColor",
248                                stroke_width: "2",
249                                stroke_linecap: "round",
250                                stroke_linejoin: "round",
251                                path { d: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" }
252                                circle { cx: "9", cy: "7", r: "4" }
253                            }
254                        }
255                        p { class: "text-2xl font-extrabold font-mono text-[var(--color-paper-primary)] tracking-tight",
256                            "{s.active_sessions}"
257                        }
258                    }
259                    div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl p-5 border border-[var(--color-paper-border)]/70 shadow-xs flex flex-col gap-1.5",
260                        div { class: "flex items-center justify-between text-[var(--color-paper-secondary)]",
261                            span { class: "text-xs font-semibold uppercase tracking-wider", "应用 CPU" }
262                            svg {
263                                class: "w-4 h-4 text-[var(--color-paper-tertiary)]",
264                                xmlns: "http://www.w3.org/2000/svg",
265                                view_box: "0 0 24 24",
266                                fill: "none",
267                                stroke: "currentColor",
268                                stroke_width: "2",
269                                stroke_linecap: "round",
270                                stroke_linejoin: "round",
271                                rect { x: "4", y: "4", width: "16", height: "16", rx: "2" }
272                                rect { x: "9", y: "9", width: "6", height: "6" }
273                            }
274                        }
275                        p { class: "text-2xl font-extrabold font-mono text-[var(--color-paper-accent)] tracking-tight",
276                            "{cpu_pct}"
277                        }
278                    }
279                }
280
281                // 主机层指标卡片
282                div { class: "grid grid-cols-2 lg:grid-cols-4 gap-4",
283                    div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl p-5 border border-[var(--color-paper-border)]/70 shadow-xs flex flex-col gap-1.5",
284                        div { class: "flex items-center justify-between text-[var(--color-paper-secondary)]",
285                            span { class: "text-xs font-semibold uppercase tracking-wider", "内存占用" }
286                        }
287                        p { class: "text-xl font-bold font-mono text-[var(--color-paper-primary)] tracking-tight",
288                            "{format_bytes(s.host.used_memory as i64)} / {format_bytes(s.host.total_memory as i64)}"
289                        }
290                    }
291                    div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl p-5 border border-[var(--color-paper-border)]/70 shadow-xs flex flex-col gap-1.5",
292                        div { class: "flex items-center justify-between text-[var(--color-paper-secondary)]",
293                            span { class: "text-xs font-semibold uppercase tracking-wider", "磁盘空间" }
294                        }
295                        p { class: "text-xl font-bold font-mono text-[var(--color-paper-primary)] tracking-tight",
296                            "{format_bytes((s.host.disk_total - s.host.disk_available) as i64)} / {format_bytes(s.host.disk_total as i64)}"
297                        }
298                    }
299                    div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl p-5 border border-[var(--color-paper-border)]/70 shadow-xs flex flex-col gap-1.5",
300                        div { class: "flex items-center justify-between text-[var(--color-paper-secondary)]",
301                            span { class: "text-xs font-semibold uppercase tracking-wider", "平均负载 (1m)" }
302                        }
303                        p { class: "text-xl font-bold font-mono text-[var(--color-paper-primary)] tracking-tight",
304                            "{load_1}"
305                        }
306                    }
307                    div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl p-5 border border-[var(--color-paper-border)]/70 shadow-xs flex flex-col gap-1.5",
308                        div { class: "flex items-center justify-between text-[var(--color-paper-secondary)]",
309                            span { class: "text-xs font-semibold uppercase tracking-wider", "宿主系统" }
310                        }
311                        p { class: "text-base font-semibold font-mono text-[var(--color-paper-primary)] truncate mt-0.5",
312                            "{s.host.os_name}"
313                        }
314                    }
315                }
316
317                // 缓存命中率表
318                div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl shadow-xs border border-[var(--color-paper-border)]/70 overflow-hidden",
319                    div { class: "px-5 py-4 border-b border-[var(--color-paper-border)]/70 flex items-center gap-2 select-none",
320                        svg {
321                            class: "w-4 h-4 text-[var(--color-paper-accent)]",
322                            xmlns: "http://www.w3.org/2000/svg",
323                            view_box: "0 0 24 24",
324                            fill: "none",
325                            stroke: "currentColor",
326                            stroke_width: "2",
327                            stroke_linecap: "round",
328                            stroke_linejoin: "round",
329                            path { d: "M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" }
330                        }
331                        span { class: "font-semibold text-sm text-[var(--color-paper-primary)]", "内存缓存指标 (Moka Cache)" }
332                    }
333                    div { class: "overflow-x-auto",
334                        table { class: "w-full text-sm",
335                            thead {
336                                tr { class: "bg-[var(--color-paper-entry)]/80 border-b border-[var(--color-paper-border)]/70 text-left text-xs font-semibold uppercase tracking-wider text-[var(--color-paper-secondary)] select-none",
337                                    th { class: "px-5 py-3.5", "缓存名称" }
338                                    th { class: "px-4 py-3.5 text-right whitespace-nowrap", "当前条目" }
339                                    th { class: "px-4 py-3.5 text-right whitespace-nowrap", "命中次数" }
340                                    th { class: "px-4 py-3.5 text-right whitespace-nowrap", "未命中数" }
341                                    th { class: "px-5 py-3.5 text-right whitespace-nowrap", "命中率" }
342                                }
343                            }
344                            tbody {
345                                for (name, entry_count, hits, misses, rate_pct) in cache_rows.iter() {
346                                    tr { class: "border-b border-[var(--color-paper-border)]/60 last:border-0 hover:bg-[var(--color-paper-accent-soft)]/20 transition-colors",
347                                        td { class: "px-5 py-3 font-mono font-medium text-[var(--color-paper-primary)]",
348                                            "{name}"
349                                        }
350                                        td { class: "px-4 py-3 text-right font-mono text-[var(--color-paper-secondary)]",
351                                            "{entry_count}"
352                                        }
353                                        td { class: "px-4 py-3 text-right font-mono text-[var(--color-paper-secondary)]",
354                                            "{hits}"
355                                        }
356                                        td { class: "px-4 py-3 text-right font-mono text-[var(--color-paper-secondary)]",
357                                            "{misses}"
358                                        }
359                                        td { class: "px-5 py-3 text-right font-mono text-[var(--color-paper-primary)] font-semibold",
360                                            "{rate_pct}"
361                                        }
362                                    }
363                                }
364                            }
365                        }
366                    }
367                }
368            } else if loading() {
369                // 首次加载骨架屏:延迟 200ms 显示,避免快速加载闪烁。
370                DelayedSkeleton {
371                    div { class: "space-y-4",
372                        // 应用内指标卡片骨架
373                        div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
374                            for _ in 0..4 {
375                                div { class: "rounded-2xl bg-paper-entry border border-paper-border p-4 space-y-2",
376                                    SkeletonBox { class: "h-3 w-16 rounded" }
377                                    SkeletonBox { class: "h-6 w-24 rounded" }
378                                }
379                            }
380                        }
381                        // 主机层指标卡片骨架
382                        div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
383                            for _ in 0..4 {
384                                div { class: "rounded-2xl bg-paper-entry border border-paper-border p-4 space-y-2",
385                                    SkeletonBox { class: "h-3 w-16 rounded" }
386                                    SkeletonBox { class: "h-6 w-24 rounded" }
387                                }
388                            }
389                        }
390                        // 缓存命中率表骨架
391                        div { class: "rounded-2xl bg-paper-entry border border-paper-border overflow-hidden",
392                            div { class: "px-4 py-3 border-b border-paper-border",
393                                SkeletonBox { class: "h-4 w-24 rounded" }
394                            }
395                            for _ in 0..4 {
396                                div { class: "flex justify-between px-4 py-3 border-b border-paper-border last:border-0",
397                                    SkeletonBox { class: "h-4 w-20 rounded" }
398                                    SkeletonBox { class: "h-4 w-12 rounded" }
399                                }
400                            }
401                        }
402                    }
403                }
404            } else {
405                div { class: "text-paper-secondary py-8", "暂无数据" }
406            }
407        }
408    }
409}