yggdrasil/pages/admin/system/
db_status.rs1use 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#[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#[derive(Clone)]
60struct TableRow {
61 info: TableInfo,
62 largest: i64,
63}
64
65#[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}