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:
parent
81a4196e43
commit
7553dcf405
10
src/api/database/mod.rs
Normal file
10
src/api/database/mod.rs
Normal 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
207
src/api/database/status.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)。
|
||||||
|
|||||||
@ -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", "暂无数据" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user