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 crate::utils::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
33    // Signal 是 Copy,可在多个 spawn/effect 中捕获同一副本;set 走内部可变(&self)。
34    let mut status = use_signal(|| Option::<DbStatus>::None);
35    let mut loading = use_signal(|| true);
36    let mut error = use_signal(|| Option::<String>::None);
37    // 自动刷新间隔(秒);None = 手动。DB 查询有成本,最低 1s。
38    let mut refresh_interval: Signal<Option<u32>> = use_signal(|| None);
39
40    // 数据加载:WASM 前端 spawn 请求,SSR 直接结束加载。
41    // 因 Signal 是 Copy,每次 spawn 各自捕获副本即可,无需共享闭包。
42    let mut load_once = move || {
43        loading.set(true);
44        #[cfg(target_arch = "wasm32")]
45        {
46            spawn(async move {
47                match get_db_status().await {
48                    Ok(s) => {
49                        status.set(Some(s));
50                        error.set(None);
51                    }
52                    Err(e) => error.set(Some(e.to_string())),
53                }
54                loading.set(false);
55            });
56        }
57        #[cfg(not(target_arch = "wasm32"))]
58        {
59            loading.set(false);
60        }
61    };
62
63    // 首次加载
64    use_effect(move || {
65        load_once();
66    });
67
68    // 自动刷新:使用官方推荐模式——一个永不重建的长生命周期 loop,在每次循环
69    // 内部读取 refresh_interval 的当前值,自然响应间隔切换。
70    // 旧做法在闭包同步体内读 status/loading/error signal,导致这些 signal 每次
71    // .set() 后都触发 use_future 重建,产生多个并发 loop(请求爆炸)。
72    use_future(move || async move {
73        #[cfg(target_arch = "wasm32")]
74        {
75            loop {
76                // 每次循环读最新 interval(signal 的 Copy 语义,直接调用即可)。
77                let secs = refresh_interval().unwrap_or(0);
78                if secs == 0 {
79                    // 手动模式:短暂 yield,让事件循环呼吸,避免忙等;
80                    // 用户切换到自动模式后最多等 200ms 即响应。
81                    crate::utils::time::sleep_ms(200).await;
82                    continue;
83                }
84                crate::utils::time::sleep_ms(secs * 1000).await;
85                // 二次检查:sleep 期间用户可能切回手动。
86                if refresh_interval().is_none() {
87                    continue;
88                }
89                loading.set(true);
90                spawn(async move {
91                    match get_db_status().await {
92                        Ok(s) => {
93                            status.set(Some(s));
94                            error.set(None);
95                        }
96                        Err(e) => error.set(Some(e.to_string())),
97                    }
98                    loading.set(false);
99                });
100            }
101        }
102        #[cfg(not(target_arch = "wasm32"))]
103        {
104            let _ = (status, loading, error, refresh_interval);
105        }
106    });
107
108    // Option<DbStatus> 非 Copy,读出来克隆一份供 rsx 消费。
109    let current = status.read().clone();
110
111    rsx! {
112        div { class: "space-y-6",
113            // 工具栏:刷新按钮 + 自动刷新开关
114            div { class: "flex items-center justify-between gap-4",
115                LoadingButton {
116                    label: "刷新数据".to_string(),
117                    loading: loading(),
118                    variant: "sm",
119                    onclick: move |_| {
120                        loading.set(true);
121                        #[cfg(target_arch = "wasm32")]
122                        {
123                            spawn(async move {
124                                match get_db_status().await {
125                                    Ok(s) => {
126                                        status.set(Some(s));
127                                        error.set(None);
128                                    }
129                                    Err(e) => error.set(Some(e.to_string())),
130                                }
131                                loading.set(false);
132                            });
133                        }
134                        #[cfg(not(target_arch = "wasm32"))]
135                        {
136                            loading.set(false);
137                        }
138                    },
139                }
140                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",
141                    svg {
142                        class: "w-3.5 h-3.5 text-[var(--color-paper-tertiary)]",
143                        xmlns: "http://www.w3.org/2000/svg",
144                        view_box: "0 0 24 24",
145                        fill: "none",
146                        stroke: "currentColor",
147                        stroke_width: "2",
148                        stroke_linecap: "round",
149                        stroke_linejoin: "round",
150                        circle { cx: "12", cy: "12", r: "10" }
151                        polyline { points: "12 6 12 12 16 14" }
152                    }
153                    span { class: "text-xs font-medium text-[var(--color-paper-secondary)]", "自动刷新" }
154                    FormSelect {
155                        trigger_class: Some(FORM_SELECT_COMPACT_CLASS),
156                        value: refresh_interval(),
157                        options: REFRESH_INTERVAL_OPTIONS.to_vec(),
158                        onchange: move |v| refresh_interval.set(v),
159                    }
160                }
161            }
162            if let Some(err) = error.read().clone() {
163                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",
164                    "加载失败:{err}"
165                }
166            } else if let Some(s) = current {
167                // 概览卡片网格
168                div { class: "grid grid-cols-2 lg:grid-cols-4 gap-4",
169                    // 卡片 1:数据库总大小
170                    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",
171                        div { class: "flex items-center justify-between text-[var(--color-paper-secondary)]",
172                            span { class: "text-xs font-semibold uppercase tracking-wider", "数据库大小" }
173                            svg {
174                                class: "w-4 h-4 text-[var(--color-paper-tertiary)]",
175                                xmlns: "http://www.w3.org/2000/svg",
176                                view_box: "0 0 24 24",
177                                fill: "none",
178                                stroke: "currentColor",
179                                stroke_width: "2",
180                                stroke_linecap: "round",
181                                stroke_linejoin: "round",
182                                ellipse { cx: "12", cy: "5", rx: "9", ry: "3" }
183                                path { d: "M21 12c0 1.66-4 3-9 3s-9-1.34-9-3" }
184                                path { d: "M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5" }
185                            }
186                        }
187                        p { class: "text-2xl font-extrabold font-mono text-[var(--color-paper-primary)] tracking-tight",
188                            "{format_bytes(s.db_size_bytes)}"
189                        }
190                    }
191                    // 卡片 2:连接数
192                    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",
193                        div { class: "flex items-center justify-between text-[var(--color-paper-secondary)]",
194                            span { class: "text-xs font-semibold uppercase tracking-wider", "活跃连接数" }
195                            svg {
196                                class: "w-4 h-4 text-[var(--color-paper-tertiary)]",
197                                xmlns: "http://www.w3.org/2000/svg",
198                                view_box: "0 0 24 24",
199                                fill: "none",
200                                stroke: "currentColor",
201                                stroke_width: "2",
202                                stroke_linecap: "round",
203                                stroke_linejoin: "round",
204                                path { d: "M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" }
205                            }
206                        }
207                        p { class: "text-2xl font-extrabold font-mono text-[var(--color-paper-primary)] tracking-tight",
208                            "{s.total_connections} / {s.max_connections}"
209                        }
210                    }
211                    // 卡片 3:表数量
212                    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",
213                        div { class: "flex items-center justify-between text-[var(--color-paper-secondary)]",
214                            span { class: "text-xs font-semibold uppercase tracking-wider", "数据表数量" }
215                            svg {
216                                class: "w-4 h-4 text-[var(--color-paper-tertiary)]",
217                                xmlns: "http://www.w3.org/2000/svg",
218                                view_box: "0 0 24 24",
219                                fill: "none",
220                                stroke: "currentColor",
221                                stroke_width: "2",
222                                stroke_linecap: "round",
223                                stroke_linejoin: "round",
224                                rect { x: "3", y: "3", width: "18", height: "18", rx: "2" }
225                                line { x1: "3", y1: "9", x2: "21", y2: "9" }
226                                line { x1: "9", y1: "21", x2: "9", y2: "9" }
227                            }
228                        }
229                        p { class: "text-2xl font-extrabold font-mono text-[var(--color-paper-primary)] tracking-tight",
230                            "{s.tables.len()}"
231                        }
232                    }
233                    // 卡片 4:迁移版本
234                    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",
235                        div { class: "flex items-center justify-between text-[var(--color-paper-secondary)]",
236                            span { class: "text-xs font-semibold uppercase tracking-wider", "当前迁移版本" }
237                            svg {
238                                class: "w-4 h-4 text-[var(--color-paper-tertiary)]",
239                                xmlns: "http://www.w3.org/2000/svg",
240                                view_box: "0 0 24 24",
241                                fill: "none",
242                                stroke: "currentColor",
243                                stroke_width: "2",
244                                stroke_linecap: "round",
245                                stroke_linejoin: "round",
246                                circle { cx: "12", cy: "12", r: "4" }
247                                line { x1: "1.05", y1: "12", x2: "7", y2: "12" }
248                                line { x1: "17.01", y1: "12", x2: "22.96", y2: "12" }
249                            }
250                        }
251                        p { class: "text-2xl font-extrabold font-mono text-[var(--color-paper-accent)] tracking-tight truncate",
252                            {s.migration_version.clone().map(|v| format!("v{v}")).unwrap_or_else(|| "—".to_string())}
253                        }
254                    }
255                }
256
257                // 表清单
258                div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl shadow-xs border border-[var(--color-paper-border)]/70 overflow-hidden",
259                    div { class: "px-5 py-4 border-b border-[var(--color-paper-border)]/70 flex flex-col sm:flex-row sm:items-center justify-between gap-1 select-none",
260                        div { class: "flex items-center gap-2 font-semibold text-sm text-[var(--color-paper-primary)]",
261                            svg {
262                                class: "w-4 h-4 text-[var(--color-paper-accent)]",
263                                xmlns: "http://www.w3.org/2000/svg",
264                                view_box: "0 0 24 24",
265                                fill: "none",
266                                stroke: "currentColor",
267                                stroke_width: "2",
268                                stroke_linecap: "round",
269                                stroke_linejoin: "round",
270                                path { d: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" }
271                                polyline { points: "14 2 14 8 20 8" }
272                            }
273                            "数据表空间与记录清单"
274                        }
275                        span { class: "text-xs text-[var(--color-paper-tertiary)]", "小表为真实行数,大表标 ~ 为统计估算" }
276                    }
277                    div { class: "overflow-x-auto",
278                        table { class: "w-full text-sm",
279                            thead {
280                                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",
281                                    th { class: "px-5 py-3.5", "表名" }
282                                    th { class: "px-4 py-3.5 text-right whitespace-nowrap", "行数" }
283                                    th { class: "px-4 py-3.5 text-right whitespace-nowrap", "数据大小" }
284                                    th { class: "px-4 py-3.5 text-right whitespace-nowrap", "索引大小" }
285                                    th { class: "px-4 py-3.5 text-right whitespace-nowrap", "总占用" }
286                                    th { class: "px-5 py-3.5 text-right whitespace-nowrap", "死元组" }
287                                }
288                            }
289                            tbody {
290                                for t in s.tables.iter() {
291                                    tr { class: "border-b border-[var(--color-paper-border)]/60 last:border-0 hover:bg-[var(--color-paper-accent-soft)]/20 transition-colors",
292                                        td { class: "px-5 py-3 font-mono font-medium text-[var(--color-paper-primary)]",
293                                            "{t.name}"
294                                        }
295                                        td { class: "px-4 py-3 text-right font-mono text-[var(--color-paper-secondary)]",
296                                            if t.row_count_estimated {
297                                                "~{t.row_count}"
298                                            } else {
299                                                "{t.row_count}"
300                                            }
301                                        }
302                                        td { class: "px-4 py-3 text-right font-mono text-[var(--color-paper-secondary)]",
303                                            "{format_bytes(t.table_size_bytes)}"
304                                        }
305                                        td { class: "px-4 py-3 text-right font-mono text-[var(--color-paper-secondary)]",
306                                            "{format_bytes(t.index_size_bytes)}"
307                                        }
308                                        td { class: "px-4 py-3 text-right font-mono text-[var(--color-paper-primary)] font-semibold",
309                                            "{format_bytes(t.total_size_bytes)}"
310                                        }
311                                        td { class: "px-5 py-3 text-right font-mono text-[var(--color-paper-tertiary)]",
312                                            "{t.dead_tuples}"
313                                        }
314                                    }
315                                }
316                            }
317                        }
318                    }
319                }
320
321                // 索引占用 Top 10
322                if !s.top_indexes.is_empty() {
323                    div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl shadow-xs border border-[var(--color-paper-border)]/70 overflow-hidden",
324                        div { class: "px-5 py-4 border-b border-[var(--color-paper-border)]/70 flex items-center gap-2 select-none",
325                            svg {
326                                class: "w-4 h-4 text-[var(--color-paper-accent)]",
327                                xmlns: "http://www.w3.org/2000/svg",
328                                view_box: "0 0 24 24",
329                                fill: "none",
330                                stroke: "currentColor",
331                                stroke_width: "2",
332                                stroke_linecap: "round",
333                                stroke_linejoin: "round",
334                                line { x1: "18", y1: "20", x2: "18", y2: "10" }
335                                line { x1: "12", y1: "20", x2: "12", y2: "4" }
336                                line { x1: "6", y1: "20", x2: "6", y2: "14" }
337                            }
338                            span { class: "font-semibold text-sm text-[var(--color-paper-primary)]", "索引占用 Top 10" }
339                        }
340                        div { class: "overflow-x-auto",
341                            table { class: "w-full text-sm",
342                                thead {
343                                    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",
344                                        th { class: "px-5 py-3.5", "索引名" }
345                                        th { class: "px-4 py-3.5", "所属表" }
346                                        th { class: "px-5 py-3.5 text-right whitespace-nowrap", "占用空间" }
347                                    }
348                                }
349                                tbody {
350                                    for i in s.top_indexes.iter() {
351                                        tr { class: "border-b border-[var(--color-paper-border)]/60 last:border-0 hover:bg-[var(--color-paper-accent-soft)]/20 transition-colors",
352                                            td { class: "px-5 py-3 font-mono text-xs font-medium text-[var(--color-paper-primary)]",
353                                                "{i.name}"
354                                            }
355                                            td { class: "px-4 py-3 font-mono text-xs text-[var(--color-paper-secondary)]",
356                                                "{i.table_name}"
357                                            }
358                                            td { class: "px-5 py-3 text-right font-mono text-xs text-[var(--color-paper-primary)] font-semibold",
359                                                "{format_bytes(i.size_bytes)}"
360                                            }
361                                        }
362                                    }
363                                }
364                            }
365                        }
366                    }
367                }
368
369                // 活跃连接
370                div { class: "bg-[var(--color-paper-entry)]/40 rounded-2xl shadow-xs border border-[var(--color-paper-border)]/70 overflow-hidden",
371                    div { class: "px-5 py-4 border-b border-[var(--color-paper-border)]/70 flex items-center gap-2 select-none",
372                        svg {
373                            class: "w-4 h-4 text-[var(--color-paper-accent)]",
374                            xmlns: "http://www.w3.org/2000/svg",
375                            view_box: "0 0 24 24",
376                            fill: "none",
377                            stroke: "currentColor",
378                            stroke_width: "2",
379                            stroke_linecap: "round",
380                            stroke_linejoin: "round",
381                            path { d: "M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" }
382                        }
383                        span { class: "font-semibold text-sm text-[var(--color-paper-primary)]",
384                            "当前活跃连接 ({s.active_connections.len()})"
385                        }
386                    }
387                    div { class: "overflow-x-auto",
388                        table { class: "w-full text-sm",
389                            thead {
390                                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",
391                                    th { class: "px-5 py-3.5 w-20", "PID" }
392                                    th { class: "px-4 py-3.5 w-28", "用户" }
393                                    th { class: "px-4 py-3.5 w-24", "状态" }
394                                    th { class: "px-4 py-3.5 w-24 text-right whitespace-nowrap", "执行时长" }
395                                    th { class: "px-5 py-3.5", "SQL 查询语句" }
396                                }
397                            }
398                            tbody {
399                                for c in s.active_connections.iter() {
400                                    tr { class: "border-b border-[var(--color-paper-border)]/60 last:border-0 hover:bg-[var(--color-paper-accent-soft)]/20 transition-colors",
401                                        td { class: "px-5 py-3 font-mono text-xs text-[var(--color-paper-secondary)]",
402                                            "{c.pid}"
403                                        }
404                                        td { class: "px-4 py-3 font-mono text-xs text-[var(--color-paper-secondary)]",
405                                            "{c.user}"
406                                        }
407                                        td { class: "px-4 py-3 text-xs text-[var(--color-paper-secondary)]",
408                                            {c.state.clone().unwrap_or_else(|| "—".to_string())}
409                                        }
410                                        td { class: "px-4 py-3 text-right font-mono text-xs text-[var(--color-paper-secondary)]",
411                                            {
412                                                c.query_duration_secs
413                                                    .map(|d| format!("{:.1}s", d))
414                                                    .unwrap_or_else(|| "—".to_string())
415                                            }
416                                        }
417                                        td { class: "px-5 py-3 font-mono text-xs text-[var(--color-paper-secondary)] max-w-lg truncate",
418                                            {c.query.clone().unwrap_or_else(|| "—".to_string())}
419                                        }
420                                    }
421                                }
422                            }
423                        }
424                    }
425                }
426            } else if loading() {
427                // 首次加载骨架屏:延迟 200ms 显示,避免快速加载闪烁。
428                DelayedSkeleton {
429                    div { class: "space-y-4",
430                        // 概览卡片骨架
431                        div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
432                            for _ in 0..4 {
433                                div { class: "rounded-2xl bg-paper-entry border border-paper-border p-4 space-y-2",
434                                    SkeletonBox { class: "h-3 w-16 rounded" }
435                                    SkeletonBox { class: "h-6 w-24 rounded" }
436                                }
437                            }
438                        }
439                        // 表清单骨架
440                        div { class: "rounded-2xl bg-paper-entry border border-paper-border overflow-hidden",
441                            div { class: "px-4 py-3 border-b border-paper-border",
442                                SkeletonBox { class: "h-4 w-40 rounded" }
443                            }
444                            for _ in 0..5 {
445                                div { class: "flex justify-between px-4 py-3 border-b border-paper-border last:border-0",
446                                    SkeletonBox { class: "h-4 w-24 rounded" }
447                                    SkeletonBox { class: "h-4 w-16 rounded" }
448                                }
449                            }
450                        }
451                    }
452                }
453            } else {
454                div { class: "text-paper-secondary py-8", "暂无数据" }
455            }
456        }
457    }
458}