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 super::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    use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS};
46
47    let mut status = use_signal(|| Option::<ServerStatus>::None);
48    let mut loading = use_signal(|| true);
49    let mut error = use_signal(|| Option::<String>::None);
50    // 自动刷新间隔(毫秒);None = 手动。主机层后台采样,前端可高频轮询。
51    let mut refresh_ms: Signal<Option<u32>> = use_signal(|| None);
52
53    let mut load_once = move || {
54        loading.set(true);
55        #[cfg(target_arch = "wasm32")]
56        {
57            spawn(async move {
58                match get_server_status().await {
59                    Ok(s) => {
60                        status.set(Some(s));
61                        error.set(None);
62                    }
63                    Err(e) => error.set(Some(e.to_string())),
64                }
65                loading.set(false);
66            });
67        }
68        #[cfg(not(target_arch = "wasm32"))]
69        {
70            loading.set(false);
71        }
72    };
73
74    use_effect(move || {
75        load_once();
76    });
77
78    // 自动刷新:同 DbStatusTab,采用官方推荐的单一长生命周期 loop 模式。
79    // 闭包体内不读任何 signal(避免隐式依赖追踪导致重建),loop 内部实时读取。
80    use_future(move || async move {
81        #[cfg(target_arch = "wasm32")]
82        {
83            loop {
84                let ms = refresh_ms().unwrap_or(0);
85                if ms == 0 {
86                    crate::utils::time::sleep_ms(200).await;
87                    continue;
88                }
89                crate::utils::time::sleep_ms(ms).await;
90                if refresh_ms().is_none() {
91                    continue;
92                }
93                loading.set(true);
94                spawn(async move {
95                    match get_server_status().await {
96                        Ok(s) => {
97                            status.set(Some(s));
98                            error.set(None);
99                        }
100                        Err(e) => error.set(Some(e.to_string())),
101                    }
102                    loading.set(false);
103                });
104            }
105        }
106        #[cfg(not(target_arch = "wasm32"))]
107        {
108            let _ = (status, loading, error, refresh_ms);
109        }
110    });
111
112    let current = status.read().clone();
113    // rsx 不支持格式说明符({:.1}),也不允许在 for 循环体内 let,故预格式化所有展示值。
114    let cpu_pct = current
115        .as_ref()
116        .map(|s| format!("{:.1}%", s.host.cpu_usage))
117        .unwrap_or_default();
118    let load_1 = current
119        .as_ref()
120        .map(|s| format!("{:.2}", s.host.load_avg_1))
121        .unwrap_or_default();
122    // 缓存表预格式化:把每行需要展示的值都算好字符串,避免在 rsx 里做格式化。
123    let cache_rows: Vec<(String, u64, u64, u64, String)> = current
124        .as_ref()
125        .map(|s| {
126            s.caches
127                .iter()
128                .map(|c| {
129                    (
130                        c.name.clone(),
131                        c.entry_count,
132                        c.hits,
133                        c.misses,
134                        format!("{:.1}%", c.hit_rate * 100.0),
135                    )
136                })
137                .collect()
138        })
139        .unwrap_or_default();
140
141    rsx! {
142        div { class: "space-y-6",
143            div { class: "flex items-center justify-between",
144                LoadingButton {
145                    label: "刷新".to_string(),
146                    loading: loading(),
147                    variant: "sm",
148                    onclick: move |_| {
149                        loading.set(true);
150                        #[cfg(target_arch = "wasm32")]
151                        {
152                            spawn(async move {
153                                match get_server_status().await {
154                                    Ok(s) => {
155                                        status.set(Some(s));
156                                        error.set(None);
157                                    }
158                                    Err(e) => error.set(Some(e.to_string())),
159                                }
160                                loading.set(false);
161                            });
162                        }
163                        #[cfg(not(target_arch = "wasm32"))]
164                        {
165                            loading.set(false);
166                        }
167                    },
168                }
169                div { class: "flex items-center gap-2",
170                    span { class: "text-sm text-paper-secondary", "自动刷新" }
171                    FormSelect {
172                        trigger_class: Some(FORM_SELECT_COMPACT_CLASS),
173                        value: refresh_ms(),
174                        options: REFRESH_MS_OPTIONS.to_vec(),
175                        onchange: move |v| refresh_ms.set(v),
176                    }
177                }
178            }
179
180            if let Some(err) = error.read().clone() {
181                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",
182                    "加载失败:{err}"
183                }
184            } else if let Some(s) = current {
185                // 应用内指标卡片
186                div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
187                    div { class: "{ADMIN_CARD_CLASS} p-4",
188                        p { class: "text-xs text-paper-secondary", "运行时间" }
189                        p { class: "mt-1 text-lg font-semibold text-paper-primary",
190                            "{format_uptime(s.uptime_secs)}"
191                        }
192                    }
193                    div { class: "{ADMIN_CARD_CLASS} p-4",
194                        p { class: "text-xs text-paper-secondary", "DB 连接池" }
195                        p { class: "mt-1 text-lg font-semibold text-paper-primary",
196                            "{s.pool_size} / {s.pool_max_size}"
197                        }
198                        p { class: "text-xs text-paper-secondary",
199                            "空闲 {s.pool_available} · 等待 {s.pool_waiting}"
200                        }
201                    }
202                    div { class: "{ADMIN_CARD_CLASS} p-4",
203                        p { class: "text-xs text-paper-secondary", "活跃会话" }
204                        p { class: "mt-1 text-lg font-semibold text-paper-primary",
205                            "{s.active_sessions}"
206                        }
207                    }
208                    div { class: "{ADMIN_CARD_CLASS} p-4",
209                        p { class: "text-xs text-paper-secondary", "CPU" }
210                        p { class: "mt-1 text-lg font-semibold text-paper-primary",
211                            "{cpu_pct}"
212                        }
213                    }
214                }
215
216                // 主机层指标卡片
217                div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
218                    div { class: "{ADMIN_CARD_CLASS} p-4",
219                        p { class: "text-xs text-paper-secondary", "内存" }
220                        p { class: "mt-1 text-lg font-semibold text-paper-primary",
221                            "{format_bytes(s.host.used_memory as i64)} / {format_bytes(s.host.total_memory as i64)}"
222                        }
223                    }
224                    div { class: "{ADMIN_CARD_CLASS} p-4",
225                        p { class: "text-xs text-paper-secondary", "磁盘" }
226                        p { class: "mt-1 text-lg font-semibold text-paper-primary",
227                            "{format_bytes((s.host.disk_total - s.host.disk_available) as i64)} / {format_bytes(s.host.disk_total as i64)}"
228                        }
229                    }
230                    div { class: "{ADMIN_CARD_CLASS} p-4",
231                        p { class: "text-xs text-paper-secondary", "Load (1m)" }
232                        p { class: "mt-1 text-lg font-semibold text-paper-primary",
233                            "{load_1}"
234                        }
235                    }
236                    div { class: "{ADMIN_CARD_CLASS} p-4",
237                        p { class: "text-xs text-paper-secondary", "系统" }
238                        p { class: "mt-1 text-sm font-medium text-paper-primary truncate",
239                            "{s.host.os_name}"
240                        }
241                    }
242                }
243
244                // 缓存命中率表
245                div { class: "{ADMIN_TABLE_CLASS}",
246                    div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary",
247                        "缓存命中率"
248                    }
249                    div { class: "overflow-x-auto",
250                        table { class: "w-full text-sm",
251                            thead {
252                                tr { class: "border-b border-paper-border text-left text-paper-secondary",
253                                    th { class: "px-4 py-2 font-medium", "缓存" }
254                                    th { class: "px-4 py-2 font-medium text-right",
255                                        "条目"
256                                    }
257                                    th { class: "px-4 py-2 font-medium text-right",
258                                        "命中"
259                                    }
260                                    th { class: "px-4 py-2 font-medium text-right",
261                                        "未命中"
262                                    }
263                                    th { class: "px-4 py-2 font-medium text-right",
264                                        "命中率"
265                                    }
266                                }
267                            }
268                            tbody {
269                                for (name, entry_count, hits, misses, rate_pct) in cache_rows.iter() {
270                                    tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
271                                        td { class: "px-4 py-2 text-paper-primary",
272                                            "{name}"
273                                        }
274                                        td { class: "px-4 py-2 text-right text-paper-secondary",
275                                            "{entry_count}"
276                                        }
277                                        td { class: "px-4 py-2 text-right text-paper-secondary",
278                                            "{hits}"
279                                        }
280                                        td { class: "px-4 py-2 text-right text-paper-secondary",
281                                            "{misses}"
282                                        }
283                                        td { class: "px-4 py-2 text-right text-paper-primary font-medium",
284                                            "{rate_pct}"
285                                        }
286                                    }
287                                }
288                            }
289                        }
290                    }
291                }
292            } else if loading() {
293                // 首次加载骨架屏:延迟 200ms 显示,避免快速加载闪烁。
294                DelayedSkeleton {
295                    div { class: "space-y-4",
296                        // 应用内指标卡片骨架
297                        div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
298                            for _ in 0..4 {
299                                div { class: "rounded-2xl bg-paper-entry border border-paper-border p-4 space-y-2",
300                                    SkeletonBox { class: "h-3 w-16 rounded" }
301                                    SkeletonBox { class: "h-6 w-24 rounded" }
302                                }
303                            }
304                        }
305                        // 主机层指标卡片骨架
306                        div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
307                            for _ in 0..4 {
308                                div { class: "rounded-2xl bg-paper-entry border border-paper-border p-4 space-y-2",
309                                    SkeletonBox { class: "h-3 w-16 rounded" }
310                                    SkeletonBox { class: "h-6 w-24 rounded" }
311                                }
312                            }
313                        }
314                        // 缓存命中率表骨架
315                        div { class: "rounded-2xl bg-paper-entry border border-paper-border overflow-hidden",
316                            div { class: "px-4 py-3 border-b border-paper-border",
317                                SkeletonBox { class: "h-4 w-24 rounded" }
318                            }
319                            for _ in 0..4 {
320                                div { class: "flex justify-between px-4 py-3 border-b border-paper-border last:border-0",
321                                    SkeletonBox { class: "h-4 w-20 rounded" }
322                                    SkeletonBox { class: "h-4 w-12 rounded" }
323                                }
324                            }
325                        }
326                    }
327                }
328            } else {
329                div { class: "text-paper-secondary py-8", "暂无数据" }
330            }
331        }
332    }
333}