feat(admin): add database status tab (tables/connections/migration version)

get_db_status server function 查 pg_catalog 聚合:数据库总大小/连接数/
表清单(行数估算+大小+死元组)/索引占用 Top10/活跃连接(过滤自身)/迁移版本。
前端 DbStatusTab:概览卡片 + 表/索引/连接表格 + 刷新按钮 + 自动刷新开关
(1s/2s/5s/30s/手动,默认手动;wasm 端用 web_sys setTimeout 轮询,无新依赖)。
This commit is contained in:
xfy 2026-06-29 18:36:08 +08:00
parent 81a4196e43
commit 7553dcf405
4 changed files with 532 additions and 6 deletions

10
src/api/database/mod.rs Normal file
View File

@ -0,0 +1,10 @@
//! 数据库管理 server functions 与 Axum 处理器。
//!
//! 按功能拆分子模块:
//! - [`status`]:数据库运行状态聚合(表/连接/死元组/迁移版本)。
//!
//! 后续 task 会新增:`system_status`(服务器状态)、`sql_console`SQL 执行+护栏)、
//! `schema`SQL 补全数据)、`export`(流式导出)、`backup`/`tasks`(备份恢复+进度)。
/// 数据库运行状态聚合查询。
pub mod status;

207
src/api/database/status.rs Normal file
View File

@ -0,0 +1,207 @@
#![allow(clippy::unused_unit, deprecated)]
//! 数据库运行状态聚合查询(只读)。
//!
//! 全部查询走 `pg_catalog` / `pg_stat_*` / `schema_migrations`,零写、零风险。
//! [`get_db_status`] 在一次 server function 调用里聚合多组数据返回。
use chrono::{DateTime, Utc};
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
use crate::api::auth::get_current_admin_user;
use crate::api::error::AppError;
use crate::db::pool::get_conn;
/// 数据库状态聚合数据。
#[derive(Serialize, Deserialize, Debug, Default, Clone)]
pub struct DbStatus {
/// 当前数据库总大小(字节)。
pub db_size_bytes: i64,
/// 当前数据库的活跃连接数。
pub total_connections: i32,
/// PG 配置的最大连接数(`max_connections`)。
pub max_connections: i32,
/// 已应用的最新迁移版本(`schema_migrations.version`)。
pub migration_version: Option<String>,
/// 最新迁移的应用时间。
pub migration_applied_at: Option<DateTime<Utc>>,
/// 用户表清单(按总大小降序)。
pub tables: Vec<TableInfo>,
/// 索引占用 Top N。
pub top_indexes: Vec<IndexInfo>,
/// 活跃连接列表(已过滤掉自身这条查询)。
pub active_connections: Vec<ConnInfo>,
}
/// 单张表的统计信息。
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct TableInfo {
pub name: String,
/// 行数估算(`pg_class.reltuples`,非 COUNT(*)UI 标注"~估算")。
pub row_estimate: i64,
pub table_size_bytes: i64,
pub index_size_bytes: i64,
pub total_size_bytes: i64,
pub last_vacuum: Option<DateTime<Utc>>,
pub last_analyze: Option<DateTime<Utc>>,
pub dead_tuples: i64,
pub live_tuples: i64,
}
/// 索引占用信息。
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct IndexInfo {
pub name: String,
pub table_name: String,
pub size_bytes: i64,
}
/// 单条活跃连接信息。
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ConnInfo {
pub pid: i32,
pub user: String,
pub state: Option<String>,
pub query: Option<String>,
/// 当前查询已运行秒数(无查询时为 None
pub query_duration_secs: Option<f64>,
}
/// 获取数据库运行状态(只读,管理员)。
#[server(GetDbStatus, "/api")]
pub async fn get_db_status() -> Result<DbStatus, ServerFnError> {
let _user = get_current_admin_user().await?;
#[cfg(feature = "server")]
{
let client = get_conn().await.map_err(AppError::db_conn)?;
// 数据库总大小
let db_size: i64 = client
.query_one("SELECT pg_database_size(current_database())", &[])
.await
.map_err(AppError::query)?
.get(0);
// 当前库连接数 + 全局最大连接数
let conn_row = client
.query_one(
"SELECT count(*), \
(SELECT setting::int FROM pg_settings WHERE name = 'max_connections') \
FROM pg_stat_activity WHERE datname = current_database()",
&[],
)
.await
.map_err(AppError::query)?;
let total_conn: i32 = conn_row.get(0);
let max_conn: i32 = conn_row.get(1);
// 最新迁移版本schema_migrations 由 migrate.rs 创建)
let migration = client
.query_opt(
"SELECT version, applied_at FROM schema_migrations \
ORDER BY applied_at DESC LIMIT 1",
&[],
)
.await
.map_err(AppError::query)?;
let (migration_version, migration_applied_at) = match migration {
Some(row) => (Some(row.get(0)), Some(row.get(1))),
None => (None, None),
};
// 表清单:行数用 reltuples 估算,避免大表 COUNT(*) 拖垮库
let table_rows = client
.query(
"SELECT c.relname, c.reltuples::bigint, pg_relation_size(c.oid), \
pg_total_relation_size(c.oid) - pg_relation_size(c.oid), \
pg_total_relation_size(c.oid), s.last_vacuum, s.last_analyze, \
s.n_dead_tup, s.n_live_tup \
FROM pg_class c \
JOIN pg_namespace n ON n.oid = c.relnamespace \
LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid \
WHERE c.relkind = 'r' AND n.nspname = 'public' \
ORDER BY pg_total_relation_size(c.oid) DESC",
&[],
)
.await
.map_err(AppError::query)?;
let tables = table_rows
.into_iter()
.map(|r| TableInfo {
name: r.get(0),
row_estimate: r.get(1),
table_size_bytes: r.get(2),
index_size_bytes: r.get(3),
total_size_bytes: r.get(4),
last_vacuum: r.get(5),
last_analyze: r.get(6),
dead_tuples: r.get(7),
live_tuples: r.get(8),
})
.collect();
// 索引占用 Top 10
let index_rows = client
.query(
"SELECT c.relname AS index_name, cl.relname AS table_name, \
pg_relation_size(c.oid) \
FROM pg_class c \
JOIN pg_index i ON i.indexrelid = c.oid \
JOIN pg_class cl ON cl.oid = i.indrelid \
JOIN pg_namespace n ON n.oid = cl.relnamespace \
WHERE n.nspname = 'public' \
ORDER BY pg_relation_size(c.oid) DESC LIMIT 10",
&[],
)
.await
.map_err(AppError::query)?;
let top_indexes = index_rows
.into_iter()
.map(|r| IndexInfo {
name: r.get(0),
table_name: r.get(1),
size_bytes: r.get(2),
})
.collect();
// 活跃连接(过滤自身 pid避免循环显示
let conn_rows = client
.query(
"SELECT pid, usename, state, query, \
extract(epoch FROM now() - query_start) \
FROM pg_stat_activity \
WHERE datname = current_database() AND pid <> pg_backend_pid() \
ORDER BY query_start DESC NULLS LAST LIMIT 50",
&[],
)
.await
.map_err(AppError::query)?;
let active_connections = conn_rows
.into_iter()
.map(|r| ConnInfo {
pid: r.get(0),
user: r.get::<_, Option<String>>(1).unwrap_or_default(),
state: r.get(2),
query: r.get(3),
query_duration_secs: r.get(4),
})
.collect();
Ok(DbStatus {
db_size_bytes: db_size,
total_connections: total_conn,
max_connections: max_conn,
migration_version,
migration_applied_at,
tables,
top_indexes,
active_connections,
})
}
#[cfg(not(feature = "server"))]
{
Ok(DbStatus::default())
}
}

View File

@ -10,6 +10,8 @@ pub mod auth;
pub mod comments; pub mod comments;
/// CSRF 防护中间件。 /// CSRF 防护中间件。
pub mod csrf; pub mod csrf;
/// 数据库管理接口(运行状态 / SQL 控制台 / 导出 / 备份恢复)。
pub mod database;
/// 应用错误类型与转换。 /// 应用错误类型与转换。
pub mod error; pub mod error;
/// 健康检查端点liveness / readiness /// 健康检查端点liveness / readiness

View File

@ -56,16 +56,323 @@ pub fn System() -> Element {
} }
} }
// tab 内容(后续 task 填充) // tab 内容
div { div {
match active_tab() { match active_tab() {
SystemTab::DbStatus => rsx! { div { "数据库状态(待实现)" } }, SystemTab::DbStatus => rsx! { DbStatusTab {} },
SystemTab::ServerStatus => rsx! { div { "服务器状态(待实现)" } }, SystemTab::ServerStatus => rsx! { div { class: "text-paper-secondary py-8", "服务器状态(待实现)" } },
SystemTab::SqlConsole => rsx! { div { "SQL 控制台(待实现)" } }, SystemTab::SqlConsole => rsx! { div { class: "text-paper-secondary py-8", "SQL 控制台(待实现)" } },
SystemTab::Export => rsx! { div { "数据导出(待实现)" } }, SystemTab::Export => rsx! { div { class: "text-paper-secondary py-8", "数据导出(待实现)" } },
SystemTab::Backup => rsx! { div { "备份恢复(待实现)" } }, SystemTab::Backup => rsx! { div { class: "text-paper-secondary py-8", "备份恢复(待实现)" } },
} }
} }
} }
} }
} }
/// 字节数 → 人类可读(如 1.2 MB
fn format_bytes(bytes: i64) -> String {
const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
let mut size = bytes as f64;
let mut unit = 0;
while size.abs() >= 1024.0 && unit < UNITS.len() - 1 {
size /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{} {}", bytes, UNITS[0])
} else {
format!("{:.2} {}", size, UNITS[unit])
}
}
/// WASM 端异步 sleep用 web_sys setTimeout 包成 JsFuture避免引入新依赖
/// 非 wasm32 平台立即返回(自动刷新只在 WASM 前端用)。
#[cfg(target_arch = "wasm32")]
async fn wasm_sleep(ms: u32) {
use wasm_bindgen::JsCast;
use wasm_bindgen_futures::JsFuture;
let promise = js_sys::Promise::new(&mut |resolve, _| {
web_sys::window()
.expect("no window")
.set_timeout_with_callback_and_timeout_and_arguments_0(
&resolve.unchecked_into(),
ms,
)
.expect("set_timeout failed");
});
let _ = JsFuture::from(promise).await;
}
/// 数据库状态 tab概览卡片 + 表清单 + 索引 Top + 活跃连接。
/// 手动刷新按钮 + 自动刷新开关1s/2s/5s/30s/手动,默认手动)。
#[allow(non_snake_case)]
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 status = use_signal(|| Option::<DbStatus>::None);
let mut loading = use_signal(|| true);
let 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")]
{
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);
}
};
// 首次加载
use_effect(move || {
load_once();
});
// 自动刷新interval 变化时 use_future 重新执行,内部周期 sleep 后重新 load。
// wasm32 才有意义SSR 不轮询sleep 用 web_sys 的 setTimeout 包成 JsFuture
// 避免引入新依赖。interval 在 use_future 依赖里读取,变化即重建 future。
use_future(move || {
// 在依赖闭包里读 interval使 use_future 在它变化时重新执行。
let interval_secs = refresh_interval();
let status_f = status;
let loading_f = loading;
let error_f = error;
async move {
#[cfg(target_arch = "wasm32")]
{
let secs = interval_secs.unwrap_or(0);
if secs == 0 {
return;
}
loop {
wasm_sleep(secs * 1000).await;
loading_f.set(true);
spawn(async move {
match get_db_status().await {
Ok(s) => {
status_f.set(Some(s));
error_f.set(None);
}
Err(e) => error_f.set(Some(e.to_string())),
}
loading_f.set(false);
});
}
}
#[cfg(not(target_arch = "wasm32"))]
{
let _ = (interval_secs, status_f, loading_f, error_f);
}
}
});
// Option<DbStatus> 非 Copy读出来克隆一份供 rsx 消费。
let current = status.read().clone();
rsx! {
div { class: "space-y-6",
// 工具栏:刷新按钮 + 自动刷新开关
div { class: "flex items-center justify-between",
button {
class: "px-3 py-1.5 text-sm bg-paper-accent text-paper-theme rounded hover:brightness-110 transition disabled:opacity-50",
disabled: loading(),
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);
}
},
if loading() { "加载中..." } else { "刷新" }
}
div { class: "flex items-center gap-2",
span { class: "text-sm text-paper-secondary", "自动刷新" }
select {
class: "text-sm border border-paper-border rounded px-2 py-1 bg-paper-theme text-paper-primary",
value: "{refresh_interval().map(|s| s.to_string()).unwrap_or_default()}",
onchange: move |e| {
let v = e.value();
refresh_interval.set(match v.as_str() {
"1" => Some(1),
"2" => Some(2),
"5" => Some(5),
"30" => Some(30),
_ => None,
});
},
option { value: "", "手动" }
option { value: "1", "1s" }
option { value: "2", "2s" }
option { value: "5", "5s" }
option { value: "30", "30s" }
}
}
}
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", "{t.row_estimate}" }
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() {
div { class: "text-paper-secondary py-8", "加载中..." }
} else {
div { class: "text-paper-secondary py-8", "暂无数据" }
}
}
}
}