Skip to main content

yggdrasil/pages/admin/system/
server_status.rs

1//! 服务器 tab:应用与主机指标卡片(运行时间、CPU、内存、磁盘、负载、连接池、会话、
2//! 宿主系统)与内存缓存命中率表。主机层由后台 500ms 采样,前端轮询只读快照,
3//! 自动刷新可选 1s / 2s / 5s / 10s。
4//!
5//! 指标卡片与占用条的文本、宽度直接绑定到状态信号:刷新时节点原地更新,占用条的
6//! `width` 过渡动画得以保留。缓存表按整行内容做 key,数值变化时重建该行。
7use leptos::prelude::*;
8
9use super::{use_polled_status, Meter, Metric, PolledStatus, RefreshControl};
10use crate::api::database::system_status::{get_server_status, ServerStatus};
11use crate::components::skeletons::atoms::SkeletonBox;
12use crate::components::ui::LoadError;
13use crate::utils::format_bytes;
14
15/// 秒数 → 「6 天 4 小时」「4 小时 3 分」「3 分」「42 秒」。
16fn format_uptime(secs: u64) -> String {
17    let d = secs / 86400;
18    let h = (secs % 86400) / 3600;
19    let m = (secs % 3600) / 60;
20    if d > 0 {
21        format!("{d} 天 {h} 小时")
22    } else if h > 0 {
23        format!("{h} 小时 {m} 分")
24    } else if m > 0 {
25        format!("{m} 分")
26    } else {
27        format!("{secs} 秒")
28    }
29}
30
31/// 百分比,分母为 0 时为 0。
32fn percent(part: f64, whole: f64) -> f64 {
33    if whole <= 0.0 {
34        0.0
35    } else {
36        (part / whole * 100.0).clamp(0.0, 100.0)
37    }
38}
39
40fn mem_pct(s: &ServerStatus) -> f64 {
41    percent(s.host.used_memory as f64, s.host.total_memory as f64)
42}
43
44fn disk_pct(s: &ServerStatus) -> f64 {
45    let used = s.host.disk_total.saturating_sub(s.host.disk_available);
46    percent(used as f64, s.host.disk_total as f64)
47}
48
49fn pool_pct(s: &ServerStatus) -> f64 {
50    percent(s.pool_size as f64, s.pool_max_size as f64)
51}
52
53/// 服务器 tab。
54#[component]
55pub(super) fn ServerStatusTab() -> impl IntoView {
56    let PolledStatus {
57        status,
58        loading,
59        failed,
60        refresh_ms,
61        reload,
62    } = use_polled_status(get_server_status, Some(10_000));
63    let ready = Memo::new(move |_| status.with(Option::is_some));
64    let show_error = Memo::new(move |_| failed.get() && !ready.get());
65
66    view! {
67        <RefreshControl refresh_ms loading on_refresh=move |_| reload.run(())/>
68        {move || {
69            if show_error.get() {
70                view! {
71                    <LoadError title="服务器状态加载失败" on_retry=move |_| reload.run(())/>
72                }
73                    .into_any()
74            } else if ready.get() {
75                view! { <ServerStatusBody status/> }.into_any()
76            } else {
77                view! {
78                    <div class="sys-metrics" aria-hidden="true">
79                        {(0..8)
80                            .map(|_| {
81                                view! {
82                                    <div class="admin-panel sys-metric">
83                                        <SkeletonBox class="h-3 w-16 rounded"/>
84                                        <SkeletonBox class="h-6 w-24 rounded mt-2"/>
85                                    </div>
86                                }
87                            })
88                            .collect_view()}
89                    </div>
90                }
91                    .into_any()
92            }
93        }}
94    }
95}
96
97/// 指标卡片与缓存表:只在已有数据后渲染,字段绑定到 `status`。
98#[component]
99fn ServerStatusBody(status: RwSignal<Option<ServerStatus>>) -> impl IntoView {
100    let text = move |f: fn(&ServerStatus) -> String| {
101        Signal::derive(move || status.with(|s| s.as_ref().map(f).unwrap_or_default()))
102    };
103    let pct = move |f: fn(&ServerStatus) -> f64| {
104        Signal::derive(move || status.with(|s| s.as_ref().map(f).unwrap_or_default()))
105    };
106    let caches = move || status.with(|s| s.as_ref().map(|s| s.caches.clone()).unwrap_or_default());
107
108    view! {
109        <div class="sys-metrics">
110            <Metric
111                label="运行时间"
112                value=text(|s| format_uptime(s.uptime_secs))
113                meta=text(|s| format!("主机已运行 {}", format_uptime(s.host.uptime_secs)))
114            />
115            <Metric label="应用 CPU" value=text(|s| format!("{:.1}%", s.host.cpu_usage))>
116                <Meter pct=pct(|s| s.host.cpu_usage as f64)/>
117            </Metric>
118            <Metric
119                label="内存"
120                value=text(|s| format_bytes(s.host.used_memory as i64))
121                meta=text(|s| format!("共 {}", format_bytes(s.host.total_memory as i64)))
122            >
123                <Meter pct=pct(mem_pct)/>
124            </Metric>
125            <Metric
126                label="磁盘"
127                value=text(|s| format!("{:.0}%", disk_pct(s)))
128                meta=text(|s| format!("剩余 {}", format_bytes(s.host.disk_available as i64)))
129            >
130                <Meter pct=pct(disk_pct)/>
131            </Metric>
132            <Metric
133                label="平均负载"
134                value=text(|s| format!("{:.2}", s.host.load_avg_1))
135                meta="1 分钟"
136            />
137            <Metric
138                label="数据库连接池"
139                value=text(|s| format!("{} / {}", s.pool_size, s.pool_max_size))
140                meta=text(|s| format!("空闲 {} · 等待 {}", s.pool_available, s.pool_waiting))
141            >
142                <Meter pct=pct(pool_pct)/>
143            </Metric>
144            <Metric
145                label="活跃会话"
146                value=text(|s| s.active_sessions.to_string())
147                meta="未过期的登录"
148            />
149            <Metric
150                label="宿主系统"
151                value=text(|s| s.host.os_name.clone())
152                meta=text(|s| s.host.kernel_version.clone())
153                small=true
154            />
155        </div>
156        <section class="admin-panel sys-section" aria-labelledby="sys-cache-h">
157            <div class="sys-section-head">
158                <h2 id="sys-cache-h">"内存缓存"</h2>
159            </div>
160            <div class="sys-table-scroll">
161                <table class="admin-table">
162                    <thead>
163                        <tr>
164                            <th>"缓存"</th>
165                            <th class="admin-table-num">"条目"</th>
166                            <th class="admin-table-num">"命中"</th>
167                            <th class="admin-table-num">"未命中"</th>
168                            <th>"命中率"</th>
169                        </tr>
170                    </thead>
171                    <tbody>
172                        <For
173                            each=caches
174                            key=|c| (c.name.clone(), c.entry_count, c.hits, c.misses, c.hit_rate.to_bits())
175                            children=|c| {
176                                view! {
177                                    <tr>
178                                        <td class="sys-mono">{c.name.clone()}</td>
179                                        <td class="admin-table-num">{c.entry_count}</td>
180                                        <td class="admin-table-num">{c.hits}</td>
181                                        <td class="admin-table-num">{c.misses}</td>
182                                        <td>
183                                            <div class="sys-rate">
184                                                <Meter pct=c.hit_rate * 100.0 calm=true/>
185                                                <span>{format!("{:.0}%", c.hit_rate * 100.0)}</span>
186                                            </div>
187                                        </td>
188                                    </tr>
189                                }
190                            }
191                        />
192                    </tbody>
193                </table>
194            </div>
195        </section>
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn uptime_reads_in_largest_two_units() {
205        assert_eq!(format_uptime(42), "42 秒");
206        assert_eq!(format_uptime(180), "3 分");
207        assert_eq!(format_uptime(3 * 3600 + 120), "3 小时 2 分");
208        assert_eq!(format_uptime(6 * 86400 + 4 * 3600), "6 天 4 小时");
209    }
210
211    #[test]
212    fn percent_handles_zero_total() {
213        assert_eq!(percent(1.0, 0.0), 0.0);
214        assert_eq!(percent(1.0, 4.0), 25.0);
215        assert_eq!(percent(9.0, 4.0), 100.0);
216    }
217}