yggdrasil/src/pages/admin/system/db_status.rs
xfy e6f7704367 feat(admin): 全项目原生 <select> 统一替换为 FormSelect
将剩余 4 处原生下拉(write 编辑器状态胶囊、db_status/server_status
自动刷新、export 导出格式)全部迁移到主题化的 FormSelect:

- FormSelect 新增 trigger_class 可选 prop(缺省全宽表单款),并导出
  FORM_SELECT_COMPACT_CLASS 供工具栏内联小下拉复用;write.rs 状态选择器
  以胶囊 trigger_class 复刻原外观(面板贴底栏自动上翻,替代原
  「隐形 select 覆盖 span」hack)
- 面板宽度改为 max(触发器, 最长选项)(w-max min-w-full + 100vw 上限),
  紧凑触发器(如「手动」)下长选项(如「500ms」「SQL (INSERT)」)
  不再被截断
- db_status/server_status 的字符串 match 反查收编为类型安全选项表
  (Option<u32> 直进直出)
- 因 system/write 页面 SSR 真实引用 FormSelect,移除 FORM_SELECT_ID /
  wrap_index 上不再需要的 #[allow(dead_code)]

src 下已无任何原生 <select> 元素。浏览器实测:紧凑面板 93.8px 宽于
触发器 73.8px、胶囊面板上翻贴顶 6px 间隙、选中 check 与高亮行正确。
2026-07-28 14:38:12 +08:00

366 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 数据库状态 tab。
use dioxus::prelude::*;
use crate::components::forms::{FORM_SELECT_COMPACT_CLASS, FormSelect};
use crate::components::skeletons::atoms::SkeletonBox;
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
use crate::components::ui::LoadingButton;
use super::format_bytes;
/// 自动刷新间隔可选项None = 手动)。
const REFRESH_INTERVAL_OPTIONS: &[(Option<u32>, &str)] = &[
(None, "手动"),
(Some(1), "1s"),
(Some(2), "2s"),
(Some(5), "5s"),
(Some(30), "30s"),
];
/// 数据库状态 tab概览卡片 + 表清单 + 索引 Top + 活跃连接。
/// 手动刷新按钮 + 自动刷新开关1s/2s/5s/30s/手动,默认手动)。
#[allow(non_snake_case)]
// status/error/loading 在 spawn/onclick 闭包里 .set(),仅 WASM 前端真正用到;
// server 构建里这些 set 调用都在被剥离的 #[cfg(wasm32)] 块内,故 allow unused_mut。
#[cfg_attr(not(target_arch = "wasm32"), allow(unused_mut, unused_variables))]
pub(super) fn DbStatusTab() -> Element {
use crate::api::database::status::DbStatus;
// get_db_status 只在 WASM 前端调用server 构建时该 server function 的客户端桩不需要导入。
#[cfg(target_arch = "wasm32")]
use crate::api::database::status::get_db_status;
use crate::components::ui::{ADMIN_CARD_CLASS, ADMIN_TABLE_CLASS};
// Signal 是 Copy可在多个 spawn/effect 中捕获同一副本set 走内部可变(&self
let mut status = use_signal(|| Option::<DbStatus>::None);
let mut loading = use_signal(|| true);
let mut error = use_signal(|| Option::<String>::None);
// 自动刷新间隔None = 手动。DB 查询有成本,最低 1s。
let mut refresh_interval: Signal<Option<u32>> = use_signal(|| None);
// 数据加载WASM 前端 spawn 请求SSR 直接结束加载。
// 因 Signal 是 Copy每次 spawn 各自捕获副本即可,无需共享闭包。
let mut load_once = move || {
loading.set(true);
#[cfg(target_arch = "wasm32")]
{
let t0 = crate::utils::time::now_millis();
web_sys::console::log_1(&format!("[DEBUG-db] fetch start at {t0}").into());
spawn(async move {
match get_db_status().await {
Ok(s) => {
let t1 = crate::utils::time::now_millis();
web_sys::console::log_1(
&format!("[DEBUG-db] fetch done at {t1} (Δ={}ms)", t1 - t0).into(),
);
status.set(Some(s));
error.set(None);
}
Err(e) => error.set(Some(e.to_string())),
}
loading.set(false);
});
}
#[cfg(not(target_arch = "wasm32"))]
{
loading.set(false);
}
};
// 首次加载
use_effect(move || {
load_once();
});
// 自动刷新:使用官方推荐模式——一个永不重建的长生命周期 loop在每次循环
// 内部读取 refresh_interval 的当前值,自然响应间隔切换。
// 旧做法在闭包同步体内读 status/loading/error signal导致这些 signal 每次
// .set() 后都触发 use_future 重建,产生多个并发 loop请求爆炸
use_future(move || async move {
#[cfg(target_arch = "wasm32")]
{
loop {
// 每次循环读最新 intervalsignal 的 Copy 语义,直接调用即可)。
let secs = refresh_interval().unwrap_or(0);
if secs == 0 {
// 手动模式:短暂 yield让事件循环呼吸避免忙等
// 用户切换到自动模式后最多等 200ms 即响应。
crate::utils::time::sleep_ms(200).await;
continue;
}
crate::utils::time::sleep_ms(secs * 1000).await;
// 二次检查sleep 期间用户可能切回手动。
if refresh_interval().is_none() {
continue;
}
loading.set(true);
spawn(async move {
match get_db_status().await {
Ok(s) => {
status.set(Some(s));
error.set(None);
}
Err(e) => error.set(Some(e.to_string())),
}
loading.set(false);
});
}
}
#[cfg(not(target_arch = "wasm32"))]
{
let _ = (status, loading, error, refresh_interval);
}
});
// Option<DbStatus> 非 Copy读出来克隆一份供 rsx 消费。
let current = status.read().clone();
rsx! {
div { class: "space-y-6",
// 工具栏:刷新按钮 + 自动刷新开关
div { class: "flex items-center justify-between",
LoadingButton {
label: "刷新".to_string(),
loading: loading(),
variant: "sm",
onclick: move |_| {
loading.set(true);
#[cfg(target_arch = "wasm32")]
{
spawn(async move {
match get_db_status().await {
Ok(s) => {
status.set(Some(s));
error.set(None);
}
Err(e) => error.set(Some(e.to_string())),
}
loading.set(false);
});
}
#[cfg(not(target_arch = "wasm32"))]
{
loading.set(false);
}
},
}
div { class: "flex items-center gap-2",
span { class: "text-sm text-paper-secondary", "自动刷新" }
FormSelect {
trigger_class: Some(FORM_SELECT_COMPACT_CLASS),
value: refresh_interval(),
options: REFRESH_INTERVAL_OPTIONS.to_vec(),
onchange: move |v| refresh_interval.set(v),
}
}
}
if let Some(err) = error.read().clone() {
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",
"加载失败:{err}"
}
} else if let Some(s) = current {
// 概览卡片
div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
div { class: "{ADMIN_CARD_CLASS} p-4",
p { class: "text-xs text-paper-secondary", "数据库总大小" }
p { class: "mt-1 text-lg font-semibold text-paper-primary",
"{format_bytes(s.db_size_bytes)}"
}
}
div { class: "{ADMIN_CARD_CLASS} p-4",
p { class: "text-xs text-paper-secondary", "连接数" }
p { class: "mt-1 text-lg font-semibold text-paper-primary",
"{s.total_connections} / {s.max_connections}"
}
}
div { class: "{ADMIN_CARD_CLASS} p-4",
p { class: "text-xs text-paper-secondary", "表数量" }
p { class: "mt-1 text-lg font-semibold text-paper-primary",
"{s.tables.len()}"
}
}
div { class: "{ADMIN_CARD_CLASS} p-4",
p { class: "text-xs text-paper-secondary", "迁移版本" }
p { class: "mt-1 text-lg font-semibold text-paper-primary truncate",
{s.migration_version.clone().unwrap_or_else(|| "".to_string())}
}
}
}
// 表清单(小表显示真实行数;大表回退估算,行数前标 ~
div { class: "{ADMIN_TABLE_CLASS}",
div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary",
"表清单(行数:小表为真实值,大表标 ~ 为估算)"
}
div { class: "overflow-x-auto",
table { class: "w-full text-sm",
thead {
tr { class: "border-b border-paper-border text-left text-paper-secondary",
th { class: "px-4 py-2 font-medium", "表名" }
th { class: "px-4 py-2 font-medium text-right",
"行数"
}
th { class: "px-4 py-2 font-medium text-right",
"表大小"
}
th { class: "px-4 py-2 font-medium text-right",
"索引大小"
}
th { class: "px-4 py-2 font-medium text-right",
"总大小"
}
th { class: "px-4 py-2 font-medium text-right",
"死元组"
}
}
}
tbody {
for t in s.tables.iter() {
tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
td { class: "px-4 py-2 font-mono text-paper-primary",
"{t.name}"
}
td { class: "px-4 py-2 text-right text-paper-secondary",
if t.row_count_estimated {
"~{t.row_count}"
} else {
"{t.row_count}"
}
}
td { class: "px-4 py-2 text-right text-paper-secondary",
"{format_bytes(t.table_size_bytes)}"
}
td { class: "px-4 py-2 text-right text-paper-secondary",
"{format_bytes(t.index_size_bytes)}"
}
td { class: "px-4 py-2 text-right text-paper-primary font-medium",
"{format_bytes(t.total_size_bytes)}"
}
td { class: "px-4 py-2 text-right text-paper-secondary",
"{t.dead_tuples}"
}
}
}
}
}
}
}
// 索引占用 Top
if !s.top_indexes.is_empty() {
div { class: "{ADMIN_TABLE_CLASS}",
div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary",
"索引占用 Top 10"
}
div { class: "overflow-x-auto",
table { class: "w-full text-sm",
thead {
tr { class: "border-b border-paper-border text-left text-paper-secondary",
th { class: "px-4 py-2 font-medium", "索引名" }
th { class: "px-4 py-2 font-medium", "所属表" }
th { class: "px-4 py-2 font-medium text-right",
"大小"
}
}
}
tbody {
for i in s.top_indexes.iter() {
tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
td { class: "px-4 py-2 font-mono text-paper-primary",
"{i.name}"
}
td { class: "px-4 py-2 font-mono text-paper-secondary",
"{i.table_name}"
}
td { class: "px-4 py-2 text-right text-paper-secondary",
"{format_bytes(i.size_bytes)}"
}
}
}
}
}
}
}
}
// 活跃连接
div { class: "{ADMIN_TABLE_CLASS}",
div { class: "px-4 py-3 border-b border-paper-border text-sm font-medium text-paper-primary",
"活跃连接({s.active_connections.len()}"
}
div { class: "overflow-x-auto",
table { class: "w-full text-sm",
thead {
tr { class: "border-b border-paper-border text-left text-paper-secondary",
th { class: "px-4 py-2 font-medium", "PID" }
th { class: "px-4 py-2 font-medium", "用户" }
th { class: "px-4 py-2 font-medium", "状态" }
th { class: "px-4 py-2 font-medium text-right",
"时长(秒)"
}
th { class: "px-4 py-2 font-medium", "查询" }
}
}
tbody {
for c in s.active_connections.iter() {
tr { class: "border-b border-paper-border last:border-0 hover:bg-paper-entry transition-colors",
td { class: "px-4 py-2 text-paper-secondary",
"{c.pid}"
}
td { class: "px-4 py-2 text-paper-secondary",
"{c.user}"
}
td { class: "px-4 py-2 text-paper-secondary",
{c.state.clone().unwrap_or_else(|| "".to_string())}
}
td { class: "px-4 py-2 text-right text-paper-secondary",
{
c.query_duration_secs
.map(|d| format!("{:.1}", d))
.unwrap_or_else(|| "".to_string())
}
}
td { class: "px-4 py-2 font-mono text-xs text-paper-secondary max-w-md truncate",
{c.query.clone().unwrap_or_else(|| "".to_string())}
}
}
}
}
}
}
}
} else if loading() {
// 首次加载骨架屏:延迟 200ms 显示,避免快速加载闪烁。
DelayedSkeleton {
div { class: "space-y-4",
// 概览卡片骨架
div { class: "grid grid-cols-2 md:grid-cols-4 gap-4",
for _ in 0..4 {
div { class: "rounded-2xl bg-paper-entry border border-paper-border p-4 space-y-2",
SkeletonBox { class: "h-3 w-16 rounded" }
SkeletonBox { class: "h-6 w-24 rounded" }
}
}
}
// 表清单骨架
div { class: "rounded-2xl bg-paper-entry border border-paper-border overflow-hidden",
div { class: "px-4 py-3 border-b border-paper-border",
SkeletonBox { class: "h-4 w-40 rounded" }
}
for _ in 0..5 {
div { class: "flex justify-between px-4 py-3 border-b border-paper-border last:border-0",
SkeletonBox { class: "h-4 w-24 rounded" }
SkeletonBox { class: "h-4 w-16 rounded" }
}
}
}
}
}
} else {
div { class: "text-paper-secondary py-8", "暂无数据" }
}
}
}
}