Skip to main content

yggdrasil/pages/admin/system/
db_status.rs

1//! 数据库 tab:概览卡片(大小、连接、表数、迁移版本)、数据表占用、索引占用前十与
2//! 活跃连接。DB 查询有成本,自动刷新最快 1 秒、默认手动。
3//!
4//! 概览卡片与占用条直接绑定状态信号,刷新时原地更新;三张表按整行内容做 key,
5//! 内容变化时重建该行(滚动容器保持不动)。
6use leptos::prelude::*;
7
8use super::{use_polled_status, Meter, Metric, PolledStatus, RefreshControl};
9use crate::api::database::status::{get_db_status, ConnInfo, DbStatus, IndexInfo, TableInfo};
10use crate::components::skeletons::atoms::SkeletonBox;
11use crate::components::ui::LoadError;
12use crate::utils::format_bytes;
13
14/// 数据库 tab。
15#[component]
16pub(super) fn DbStatusTab() -> impl IntoView {
17    let PolledStatus {
18        status,
19        loading,
20        failed,
21        refresh_ms,
22        reload,
23    } = use_polled_status(get_db_status, None);
24    let ready = Memo::new(move |_| status.with(Option::is_some));
25    let show_error = Memo::new(move |_| failed.get() && !ready.get());
26
27    view! {
28        <RefreshControl refresh_ms loading on_refresh=move |_| reload.run(())/>
29        {move || {
30            if show_error.get() {
31                view! {
32                    <LoadError title="数据库状态加载失败" on_retry=move |_| reload.run(())/>
33                }
34                    .into_any()
35            } else if ready.get() {
36                view! { <DbStatusBody status/> }.into_any()
37            } else {
38                view! {
39                    <div class="sys-metrics" aria-hidden="true">
40                        {(0..4)
41                            .map(|_| {
42                                view! {
43                                    <div class="admin-panel sys-metric">
44                                        <SkeletonBox class="h-3 w-16 rounded"/>
45                                        <SkeletonBox class="h-6 w-24 rounded mt-2"/>
46                                    </div>
47                                }
48                            })
49                            .collect_view()}
50                    </div>
51                }
52                    .into_any()
53            }
54        }}
55    }
56}
57
58/// 表占用行:与最大表的占比随行内容一起固定在行内(`largest` 变化时行内容也变)。
59#[derive(Clone)]
60struct TableRow {
61    info: TableInfo,
62    largest: i64,
63}
64
65/// 概览卡片与四张明细:只在已有数据后渲染,字段绑定到 `status`。
66#[component]
67fn DbStatusBody(status: RwSignal<Option<DbStatus>>) -> impl IntoView {
68    let text = move |f: fn(&DbStatus) -> String| {
69        Signal::derive(move || status.with(|s| s.as_ref().map(f).unwrap_or_default()))
70    };
71    let conn_pct = Signal::derive(move || {
72        status.with(|s| {
73            s.as_ref()
74                .map(|s| {
75                    f64::from(s.total_connections) / f64::from(s.max_connections.max(1)) * 100.0
76                })
77                .unwrap_or_default()
78        })
79    });
80    let table_count = Memo::new(move |_| status.with(|s| s.as_ref().map_or(0, |s| s.tables.len())));
81    let conn_count =
82        Memo::new(move |_| status.with(|s| s.as_ref().map_or(0, |s| s.active_connections.len())));
83    let tables = move || {
84        status.with(|s| {
85            s.as_ref()
86                .map(|s| {
87                    let largest = s
88                        .tables
89                        .iter()
90                        .map(|t| t.total_size_bytes)
91                        .max()
92                        .unwrap_or(0)
93                        .max(1);
94                    s.tables
95                        .iter()
96                        .map(|info| TableRow {
97                            info: info.clone(),
98                            largest,
99                        })
100                        .collect::<Vec<_>>()
101                })
102                .unwrap_or_default()
103        })
104    };
105    let indexes = move || {
106        status.with(|s| {
107            s.as_ref()
108                .map(|s| s.top_indexes.clone())
109                .unwrap_or_default()
110        })
111    };
112    let has_indexes =
113        Memo::new(move |_| status.with(|s| s.as_ref().is_some_and(|s| !s.top_indexes.is_empty())));
114    let connections = move || {
115        status.with(|s| {
116            s.as_ref()
117                .map(|s| s.active_connections.clone())
118                .unwrap_or_default()
119        })
120    };
121
122    view! {
123        <div class="sys-metrics">
124            <Metric label="数据库大小" value=text(|s| format_bytes(s.db_size_bytes))/>
125            <Metric
126                label="连接"
127                value=text(|s| format!("{} / {}", s.total_connections, s.max_connections))
128                meta=text(|s| format!("活跃 {}", s.active_connections.len()))
129            >
130                <Meter pct=conn_pct/>
131            </Metric>
132            <Metric label="数据表" value=Signal::derive(move || table_count.get().to_string())/>
133            <Metric
134                label="迁移版本"
135                value=text(|s| s.migration_version.clone().unwrap_or_else(|| "—".into()))
136                mono=true
137            />
138        </div>
139        <section class="admin-panel sys-section" aria-labelledby="sys-tables-h">
140            <div class="sys-section-head">
141                <h2 id="sys-tables-h">"数据表"</h2>
142                <span>"按总占用排序 · 大表行数为估算"</span>
143            </div>
144            <div class="sys-table-scroll">
145                <table class="admin-table">
146                    <thead>
147                        <tr>
148                            <th>"表"</th>
149                            <th class="admin-table-num">"行数"</th>
150                            <th class="admin-table-num">"数据"</th>
151                            <th class="admin-table-num">"索引"</th>
152                            <th class="admin-table-num">"总占用"</th>
153                            <th class="admin-table-num">"死元组"</th>
154                            <th class="sys-col-share">"占比"</th>
155                        </tr>
156                    </thead>
157                    <tbody>
158                        <For
159                            each=tables
160                            key=|row| {
161                                let t = &row.info;
162                                (
163                                    t.name.clone(),
164                                    t.row_count,
165                                    t.row_count_estimated,
166                                    t.table_size_bytes,
167                                    t.index_size_bytes,
168                                    t.total_size_bytes,
169                                    t.dead_tuples,
170                                    row.largest,
171                                )
172                            }
173                            children=|row| {
174                                let t = row.info;
175                                let rows = if t.row_count_estimated {
176                                    format!("~{}", t.row_count)
177                                } else {
178                                    t.row_count.to_string()
179                                };
180                                view! {
181                                    <tr>
182                                        <td class="sys-mono">{t.name.clone()}</td>
183                                        <td class="admin-table-num">{rows}</td>
184                                        <td class="admin-table-num">{format_bytes(t.table_size_bytes)}</td>
185                                        <td class="admin-table-num">{format_bytes(t.index_size_bytes)}</td>
186                                        <td class="admin-table-num">{format_bytes(t.total_size_bytes)}</td>
187                                        <td
188                                            class="admin-table-num"
189                                            data-warn=(t.dead_tuples > 1000).then_some("true")
190                                        >
191                                            {t.dead_tuples}
192                                        </td>
193                                        <td class="sys-col-share">
194                                            <Meter
195                                                pct=t.total_size_bytes as f64 / row.largest as f64 * 100.0
196                                                calm=true
197                                            />
198                                        </td>
199                                    </tr>
200                                }
201                            }
202                        />
203                    </tbody>
204                </table>
205            </div>
206        </section>
207        <Show when=move || has_indexes.get()>
208            <section class="admin-panel sys-section" aria-labelledby="sys-index-h">
209                <div class="sys-section-head">
210                    <h2 id="sys-index-h">"索引占用前十"</h2>
211                </div>
212                <div class="sys-table-scroll">
213                    <table class="admin-table">
214                        <thead>
215                            <tr>
216                                <th>"索引"</th>
217                                <th>"表"</th>
218                                <th class="admin-table-num">"大小"</th>
219                            </tr>
220                        </thead>
221                        <tbody>
222                            <For
223                                each=indexes
224                                key=|i| (i.name.clone(), i.table_name.clone(), i.size_bytes)
225                                children=|i: IndexInfo| {
226                                    view! {
227                                        <tr>
228                                            <td class="sys-mono">{i.name.clone()}</td>
229                                            <td class="sys-mono admin-table-muted">{i.table_name.clone()}</td>
230                                            <td class="admin-table-num">{format_bytes(i.size_bytes)}</td>
231                                        </tr>
232                                    }
233                                }
234                            />
235                        </tbody>
236                    </table>
237                </div>
238            </section>
239        </Show>
240        <section class="admin-panel sys-section" aria-labelledby="sys-conn-h">
241            <div class="sys-section-head">
242                <h2 id="sys-conn-h">"活跃连接"</h2>
243                <span>{move || format!("{} 个", conn_count.get())}</span>
244            </div>
245            <div class="sys-table-scroll">
246                <table class="admin-table">
247                    <thead>
248                        <tr>
249                            <th class="admin-table-num">"PID"</th>
250                            <th>"用户"</th>
251                            <th>"状态"</th>
252                            <th class="admin-table-num">"耗时"</th>
253                            <th>"查询"</th>
254                        </tr>
255                    </thead>
256                    <tbody>
257                        <For
258                            each=connections
259                            key=|c| {
260                                (
261                                    c.pid,
262                                    c.user.clone(),
263                                    c.state.clone(),
264                                    c.query.clone(),
265                                    c.query_duration_secs.map(f64::to_bits),
266                                )
267                            }
268                            children=|c: ConnInfo| {
269                                view! {
270                                    <tr>
271                                        <td class="admin-table-num">{c.pid}</td>
272                                        <td>{c.user.clone()}</td>
273                                        <td class="admin-table-muted">
274                                            {c.state.clone().unwrap_or_else(|| "—".into())}
275                                        </td>
276                                        <td class="admin-table-num">
277                                            {c
278                                                .query_duration_secs
279                                                .map(|d| format!("{d:.1} 秒"))
280                                                .unwrap_or_else(|| "—".into())}
281                                        </td>
282                                        <td
283                                            class="sys-mono sys-query"
284                                            title=c.query.clone().unwrap_or_default()
285                                        >
286                                            {c.query.clone().unwrap_or_else(|| "—".into())}
287                                        </td>
288                                    </tr>
289                                }
290                            }
291                        />
292                    </tbody>
293                </table>
294            </div>
295        </section>
296    }
297}