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;
|
||||
/// CSRF 防护中间件。
|
||||
pub mod csrf;
|
||||
/// 数据库管理接口(运行状态 / SQL 控制台 / 导出 / 备份恢复)。
|
||||
pub mod database;
|
||||
/// 应用错误类型与转换。
|
||||
pub mod error;
|
||||
/// 健康检查端点(liveness / readiness)。
|
||||
|
||||
@ -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::<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