From 7553dcf40595f5da87c3efd791ffe2b2135206cf Mon Sep 17 00:00:00 2001 From: xfy Date: Mon, 29 Jun 2026 18:36:08 +0800 Subject: [PATCH] feat(admin): add database status tab (tables/connections/migration version) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_db_status server function 查 pg_catalog 聚合:数据库总大小/连接数/ 表清单(行数估算+大小+死元组)/索引占用 Top10/活跃连接(过滤自身)/迁移版本。 前端 DbStatusTab:概览卡片 + 表/索引/连接表格 + 刷新按钮 + 自动刷新开关 (1s/2s/5s/30s/手动,默认手动;wasm 端用 web_sys setTimeout 轮询,无新依赖)。 --- src/api/database/mod.rs | 10 ++ src/api/database/status.rs | 207 ++++++++++++++++++++++++ src/api/mod.rs | 2 + src/pages/admin/system.rs | 319 ++++++++++++++++++++++++++++++++++++- 4 files changed, 532 insertions(+), 6 deletions(-) create mode 100644 src/api/database/mod.rs create mode 100644 src/api/database/status.rs diff --git a/src/api/database/mod.rs b/src/api/database/mod.rs new file mode 100644 index 0000000..7b87c98 --- /dev/null +++ b/src/api/database/mod.rs @@ -0,0 +1,10 @@ +//! 数据库管理 server functions 与 Axum 处理器。 +//! +//! 按功能拆分子模块: +//! - [`status`]:数据库运行状态聚合(表/连接/死元组/迁移版本)。 +//! +//! 后续 task 会新增:`system_status`(服务器状态)、`sql_console`(SQL 执行+护栏)、 +//! `schema`(SQL 补全数据)、`export`(流式导出)、`backup`/`tasks`(备份恢复+进度)。 + +/// 数据库运行状态聚合查询。 +pub mod status; diff --git a/src/api/database/status.rs b/src/api/database/status.rs new file mode 100644 index 0000000..52886e2 --- /dev/null +++ b/src/api/database/status.rs @@ -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, + /// 最新迁移的应用时间。 + pub migration_applied_at: Option>, + /// 用户表清单(按总大小降序)。 + pub tables: Vec, + /// 索引占用 Top N。 + pub top_indexes: Vec, + /// 活跃连接列表(已过滤掉自身这条查询)。 + pub active_connections: Vec, +} + +/// 单张表的统计信息。 +#[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>, + pub last_analyze: Option>, + 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, + pub query: Option, + /// 当前查询已运行秒数(无查询时为 None)。 + pub query_duration_secs: Option, +} + +/// 获取数据库运行状态(只读,管理员)。 +#[server(GetDbStatus, "/api")] +pub async fn get_db_status() -> Result { + 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>(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()) + } +} diff --git a/src/api/mod.rs b/src/api/mod.rs index 8959294..e44eebd 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -10,6 +10,8 @@ pub mod auth; pub mod comments; /// CSRF 防护中间件。 pub mod csrf; +/// 数据库管理接口(运行状态 / SQL 控制台 / 导出 / 备份恢复)。 +pub mod database; /// 应用错误类型与转换。 pub mod error; /// 健康检查端点(liveness / readiness)。 diff --git a/src/pages/admin/system.rs b/src/pages/admin/system.rs index b80da54..3ce175e 100644 --- a/src/pages/admin/system.rs +++ b/src/pages/admin/system.rs @@ -56,16 +56,323 @@ pub fn System() -> Element { } } - // tab 内容(后续 task 填充) + // tab 内容 div { match active_tab() { - SystemTab::DbStatus => rsx! { div { "数据库状态(待实现)" } }, - SystemTab::ServerStatus => rsx! { div { "服务器状态(待实现)" } }, - SystemTab::SqlConsole => rsx! { div { "SQL 控制台(待实现)" } }, - SystemTab::Export => rsx! { div { "数据导出(待实现)" } }, - SystemTab::Backup => rsx! { div { "备份恢复(待实现)" } }, + SystemTab::DbStatus => rsx! { DbStatusTab {} }, + SystemTab::ServerStatus => rsx! { div { class: "text-paper-secondary py-8", "服务器状态(待实现)" } }, + SystemTab::SqlConsole => rsx! { div { class: "text-paper-secondary py-8", "SQL 控制台(待实现)" } }, + SystemTab::Export => rsx! { div { class: "text-paper-secondary py-8", "数据导出(待实现)" } }, + 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::::None); + let mut loading = use_signal(|| true); + let error = use_signal(|| Option::::None); + // 自动刷新间隔(秒);None = 手动。DB 查询有成本,最低 1s。 + let mut refresh_interval: Signal> = 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 非 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", "暂无数据" } + } + } + } +}