Skip to main content

yggdrasil/pages/admin/system/
db_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/// 自动刷新间隔可选项(秒;None = 手动)。
13const REFRESH_INTERVAL_OPTIONS: &[(Option<u32>, &str)] = &[
14    (None, "手动"),
15    (Some(1), "1s"),
16    (Some(2), "2s"),
17    (Some(5), "5s"),
18    (Some(30), "30s"),
19];
20
21/// 数据库状态 tab:概览卡片 + 表清单 + 索引 Top + 活跃连接。
22/// 手动刷新按钮 + 自动刷新开关(1s/2s/5s/30s/手动,默认手动)。
23#[allow(non_snake_case)]
24// status/error/loading 在 spawn/onclick 闭包里 .set(),仅 WASM 前端真正用到;
25// server 构建里这些 set 调用都在被剥离的 #[cfg(wasm32)] 块内,故 allow unused_mut。
26#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
27pub(super) fn DbStatusTab() -> Element {
28    use crate::api::database::status::DbStatus;
29    // get_db_status 只在 WASM 前端调用,server 构建时该 server function 的客户端桩不需要导入。
30    #[cfg(target_arch = "wasm32")]
31    use crate::api::database::status::get_db_status;
32    use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS};
33
34    // Signal 是 Copy,可在多个 spawn/effect 中捕获同一副本;set 走内部可变(&self)。
35    let mut status = use_signal(|| Option::<DbStatus>::None);
36    let mut loading = use_signal(|| true);
37    let mut error = use_signal(|| Option::<String>::None);
38    // 自动刷新间隔(秒);None = 手动。DB 查询有成本,最低 1s。
39    let mut refresh_interval: Signal<Option<u32>> = use_signal(|| None);
40
41    // 数据加载:WASM 前端 spawn 请求,SSR 直接结束加载。
42    // 因 Signal 是 Copy,每次 spawn 各自捕获副本即可,无需共享闭包。
43    let mut load_once = move || {
44        loading.set(true);
45        #[cfg(target_arch = "wasm32")]
46        {
47            spawn(async move {
48                match get_db_status().await {
49                    Ok(s) => {
50                        status.set(Some(s));
51                        error.set(None);
52                    }
53                    Err(e) => error.set(Some(e.to_string())),
54                }
55                loading.set(false);
56            });
57        }
58        #[cfg(not(target_arch = "wasm32"))]
59        {
60            loading.set(false);
61        }
62    };
63
64    // 首次加载
65    use_effect(move || {
66        load_once();
67    });
68
69    // 自动刷新:使用官方推荐模式——一个永不重建的长生命周期 loop,在每次循环
70    // 内部读取 refresh_interval 的当前值,自然响应间隔切换。
71    // 旧做法在闭包同步体内读 status/loading/error signal,导致这些 signal 每次
72    // .set() 后都触发 use_future 重建,产生多个并发 loop(请求爆炸)。
73    use_future(move || async move {
74        #[cfg(target_arch = "wasm32")]
75        {
76            loop {
77                // 每次循环读最新 interval(signal 的 Copy 语义,直接调用即可)。
78                let secs = refresh_interval().unwrap_or(0);
79                if secs == 0 {
80                    // 手动模式:短暂 yield,让事件循环呼吸,避免忙等;
81                    // 用户切换到自动模式后最多等 200ms 即响应。
82                    crate::utils::time::sleep_ms(200).await;
83                    continue;
84                }
85                crate::utils::time::sleep_ms(secs * 1000).await;
86                // 二次检查:sleep 期间用户可能切回手动。
87                if refresh_interval().is_none() {
88                    continue;
89                }
90                loading.set(true);
91                spawn(async move {
92                    match get_db_status().await {
93                        Ok(s) => {
94                            status.set(Some(s));
95                            error.set(None);
96                        }
97                        Err(e) => error.set(Some(e.to_string())),
98                    }
99                    loading.set(false);
100                });
101            }
102        }
103        #[cfg(not(target_arch = "wasm32"))]
104        {
105            let _ = (status, loading, error, refresh_interval);
106        }
107    });
108
109    // Option<DbStatus> 非 Copy,读出来克隆一份供 rsx 消费。
110    let current = status.read().clone();
111
112    rsx! {
113        div { class: "space-y-6",
114            // 工具栏:刷新按钮 + 自动刷新开关
115            div { class: "flex items-center justify-between",
116                LoadingButton {
117                    label: "刷新".to_string(),
118                    loading: loading(),
119                    variant: "sm",
120                    onclick: move |_| {
121                        loading.set(true);
122                        #[cfg(target_arch = "wasm32")]
123                        {
124                            spawn(async move {
125                                match get_db_status().await {
126                                    Ok(s) => {
127                                        status.set(Some(s));
128                                        error.set(None);
129                                    }
130                                    Err(e) => error.set(Some(e.to_string())),
131                                }
132                                loading.set(false);
133                            });
134                        }
135                        #[cfg(not(target_arch = "wasm32"))]
136                        {
137                            loading.set(false);
138                        }
139                    },
140                }
141                div { class: "flex items-center gap-2",
142                    span { class: "text-sm text-paper-secondary", "自动刷新" }
143                    FormSelect {
144                        trigger_class: Some(FORM_SELECT_COMPACT_CLASS),
145                        value: refresh_interval(),
146                        options: REFRESH_INTERVAL_OPTIONS.to_vec(),
147                        onchange: move |v| refresh_interval.set(v),
148                    }
149                }
150            }
151
152            if let Some(err) = error.read().clone() {
153                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",
154                    "加载失败:{err}"
155                }
156            } else if let Some(s) = current {
157                // 概览卡片
158                div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
159                    div { class: "{ADMIN_CARD_CLASS} p-4",
160                        p { class: "text-xs text-paper-secondary", "数据库总大小" }
161                        p { class: "mt-1 text-lg font-semibold text-paper-primary",
162                            "{format_bytes(s.db_size_bytes)}"
163                        }
164                    }
165                    div { class: "{ADMIN_CARD_CLASS} p-4",
166                        p { class: "text-xs text-paper-secondary", "连接数" }
167                        p { class: "mt-1 text-lg font-semibold text-paper-primary",
168                            "{s.total_connections} / {s.max_connections}"
169                        }
170                    }
171                    div { class: "{ADMIN_CARD_CLASS} p-4",
172                        p { class: "text-xs text-paper-secondary", "表数量" }
173                        p { class: "mt-1 text-lg font-semibold text-paper-primary",
174                            "{s.tables.len()}"
175                        }
176                    }
177                    div { class: "{ADMIN_CARD_CLASS} p-4",
178                        p { class: "text-xs text-paper-secondary", "迁移版本" }
179                        p { class: "mt-1 text-lg font-semibold text-paper-primary truncate",
180                            {s.migration_version.clone().unwrap_or_else(|| "—".to_string())}
181                        }
182                    }
183                }
184
185                // 表清单(小表显示真实行数;大表回退估算,行数前标 ~)
186                div { class: "{ADMIN_TABLE_CLASS}",
187                    div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary",
188                        "表清单(行数:小表为真实值,大表标 ~ 为估算)"
189                    }
190                    div { class: "overflow-x-auto",
191                        table { class: "w-full text-sm",
192                            thead {
193                                tr { class: "border-b border-paper-border text-left text-paper-secondary",
194                                    th { class: "px-4 py-2 font-medium", "表名" }
195                                    th { class: "px-4 py-2 font-medium text-right",
196                                        "行数"
197                                    }
198                                    th { class: "px-4 py-2 font-medium text-right",
199                                        "表大小"
200                                    }
201                                    th { class: "px-4 py-2 font-medium text-right",
202                                        "索引大小"
203                                    }
204                                    th { class: "px-4 py-2 font-medium text-right",
205                                        "总大小"
206                                    }
207                                    th { class: "px-4 py-2 font-medium text-right",
208                                        "死元组"
209                                    }
210                                }
211                            }
212                            tbody {
213                                for t in s.tables.iter() {
214                                    tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
215                                        td { class: "px-4 py-2 font-mono text-paper-primary",
216                                            "{t.name}"
217                                        }
218                                        td { class: "px-4 py-2 text-right text-paper-secondary",
219                                            if t.row_count_estimated {
220                                                "~{t.row_count}"
221                                            } else {
222                                                "{t.row_count}"
223                                            }
224                                        }
225                                        td { class: "px-4 py-2 text-right text-paper-secondary",
226                                            "{format_bytes(t.table_size_bytes)}"
227                                        }
228                                        td { class: "px-4 py-2 text-right text-paper-secondary",
229                                            "{format_bytes(t.index_size_bytes)}"
230                                        }
231                                        td { class: "px-4 py-2 text-right text-paper-primary font-medium",
232                                            "{format_bytes(t.total_size_bytes)}"
233                                        }
234                                        td { class: "px-4 py-2 text-right text-paper-secondary",
235                                            "{t.dead_tuples}"
236                                        }
237                                    }
238                                }
239                            }
240                        }
241                    }
242                }
243
244                // 索引占用 Top
245                if !s.top_indexes.is_empty() {
246                    div { class: "{ADMIN_TABLE_CLASS}",
247                        div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary",
248                            "索引占用 Top 10"
249                        }
250                        div { class: "overflow-x-auto",
251                            table { class: "w-full text-sm",
252                                thead {
253                                    tr { class: "border-b border-paper-border text-left text-paper-secondary",
254                                        th { class: "px-4 py-2 font-medium", "索引名" }
255                                        th { class: "px-4 py-2 font-medium", "所属表" }
256                                        th { class: "px-4 py-2 font-medium text-right",
257                                            "大小"
258                                        }
259                                    }
260                                }
261                                tbody {
262                                    for i in s.top_indexes.iter() {
263                                        tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
264                                            td { class: "px-4 py-2 font-mono text-paper-primary",
265                                                "{i.name}"
266                                            }
267                                            td { class: "px-4 py-2 font-mono text-paper-secondary",
268                                                "{i.table_name}"
269                                            }
270                                            td { class: "px-4 py-2 text-right text-paper-secondary",
271                                                "{format_bytes(i.size_bytes)}"
272                                            }
273                                        }
274                                    }
275                                }
276                            }
277                        }
278                    }
279                }
280
281                // 活跃连接
282                div { class: "{ADMIN_TABLE_CLASS}",
283                    div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary",
284                        "活跃连接({s.active_connections.len()})"
285                    }
286                    div { class: "overflow-x-auto",
287                        table { class: "w-full text-sm",
288                            thead {
289                                tr { class: "border-b border-paper-border text-left text-paper-secondary",
290                                    th { class: "px-4 py-2 font-medium", "PID" }
291                                    th { class: "px-4 py-2 font-medium", "用户" }
292                                    th { class: "px-4 py-2 font-medium", "状态" }
293                                    th { class: "px-4 py-2 font-medium text-right",
294                                        "时长(秒)"
295                                    }
296                                    th { class: "px-4 py-2 font-medium", "查询" }
297                                }
298                            }
299                            tbody {
300                                for c in s.active_connections.iter() {
301                                    tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
302                                        td { class: "px-4 py-2 text-paper-secondary",
303                                            "{c.pid}"
304                                        }
305                                        td { class: "px-4 py-2 text-paper-secondary",
306                                            "{c.user}"
307                                        }
308                                        td { class: "px-4 py-2 text-paper-secondary",
309                                            {c.state.clone().unwrap_or_else(|| "—".to_string())}
310                                        }
311                                        td { class: "px-4 py-2 text-right text-paper-secondary",
312                                            {
313                                                c.query_duration_secs
314                                                    .map(|d| format!("{:.1}", d))
315                                                    .unwrap_or_else(|| "—".to_string())
316                                            }
317                                        }
318                                        td { class: "px-4 py-2 font-mono text-xs text-paper-secondary max-w-md truncate",
319                                            {c.query.clone().unwrap_or_else(|| "—".to_string())}
320                                        }
321                                    }
322                                }
323                            }
324                        }
325                    }
326                }
327            } else if loading() {
328                // 首次加载骨架屏:延迟 200ms 显示,避免快速加载闪烁。
329                DelayedSkeleton {
330                    div { class: "space-y-4",
331                        // 概览卡片骨架
332                        div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
333                            for _ in 0..4 {
334                                div { class: "rounded-2xl bg-paper-entry border border-paper-border p-4 space-y-2",
335                                    SkeletonBox { class: "h-3 w-16 rounded" }
336                                    SkeletonBox { class: "h-6 w-24 rounded" }
337                                }
338                            }
339                        }
340                        // 表清单骨架
341                        div { class: "rounded-2xl bg-paper-entry border border-paper-border overflow-hidden",
342                            div { class: "px-4 py-3 border-b border-paper-border",
343                                SkeletonBox { class: "h-4 w-40 rounded" }
344                            }
345                            for _ in 0..5 {
346                                div { class: "flex justify-between px-4 py-3 border-b border-paper-border last:border-0",
347                                    SkeletonBox { class: "h-4 w-24 rounded" }
348                                    SkeletonBox { class: "h-4 w-16 rounded" }
349                                }
350                            }
351                        }
352                    }
353                }
354            } else {
355                div { class: "text-paper-secondary py-8", "暂无数据" }
356            }
357        }
358    }
359}