Skip to main content

yggdrasil/api/database/
status.rs

1#![allow(clippy::unused_unit, deprecated)]
2
3//! 数据库运行状态聚合查询(只读)。
4//!
5//! 全部查询走 `pg_catalog` / `pg_stat_*` / `schema_migrations`,零写、零风险。
6//! [`get_db_status`][crate::api::database::status::get_db_status] 在一次 server function 调用里聚合多组数据返回。
7
8use chrono::{DateTime, Utc};
9use dioxus::prelude::*;
10use serde::{Deserialize, Serialize};
11
12// 仅 server 构建用到:admin 鉴权 + DB 查询。WASM 侧的 server-function 客户端桩
13// 不解析这些符号,必须 gate 以避免在非 server 构建里找不到 server-only 符号。
14#[cfg(feature = "server")]
15use crate::api::auth::get_current_admin_user;
16#[cfg(feature = "server")]
17use crate::api::error::AppError;
18#[cfg(feature = "server")]
19use crate::db::pool::get_conn;
20
21/// 数据库状态聚合数据。
22#[derive(Serialize, Deserialize, Debug, Default, Clone)]
23pub struct DbStatus {
24    /// 当前数据库总大小(字节)。
25    pub db_size_bytes: i64,
26    /// 当前数据库的活跃连接数。
27    pub total_connections: i32,
28    /// PG 配置的最大连接数(`max_connections`)。
29    pub max_connections: i32,
30    /// 已应用的最新迁移版本(`schema_migrations.version`)。
31    pub migration_version: Option<String>,
32    /// 最新迁移的应用时间。
33    pub migration_applied_at: Option<DateTime<Utc>>,
34    /// 用户表清单(按总大小降序)。
35    pub tables: Vec<TableInfo>,
36    /// 索引占用 Top N。
37    pub top_indexes: Vec<IndexInfo>,
38    /// 活跃连接列表(已过滤掉自身这条查询)。
39    pub active_connections: Vec<ConnInfo>,
40}
41
42/// 单张表的统计信息。
43#[derive(Serialize, Deserialize, Debug, Clone)]
44pub struct TableInfo {
45    pub name: String,
46    /// 行数:total_size 小于阈值时为真实 COUNT(*),否则回退 reltuples 估算(见 row_count_estimated)。
47    pub row_count: i64,
48    /// true 表示 row_count 是 reltuples 估算值(大表回退,未 ANALYZE 时 reltuples=-1 会显示为估算)。
49    pub row_count_estimated: bool,
50    pub table_size_bytes: i64,
51    pub index_size_bytes: i64,
52    pub total_size_bytes: i64,
53    pub last_vacuum: Option<DateTime<Utc>>,
54    pub last_analyze: Option<DateTime<Utc>>,
55    pub dead_tuples: i64,
56    pub live_tuples: i64,
57}
58
59/// 索引占用信息。
60#[derive(Serialize, Deserialize, Debug, Clone)]
61pub struct IndexInfo {
62    pub name: String,
63    pub table_name: String,
64    pub size_bytes: i64,
65}
66
67/// 单条活跃连接信息。
68#[derive(Serialize, Deserialize, Debug, Clone)]
69pub struct ConnInfo {
70    pub pid: i32,
71    pub user: String,
72    pub state: Option<String>,
73    pub query: Option<String>,
74    /// 当前查询已运行秒数(无查询时为 None)。
75    pub query_duration_secs: Option<f64>,
76}
77
78/// 获取数据库运行状态(只读,管理员)。
79#[server(GetDbStatus, "/api")]
80pub async fn get_db_status() -> Result<DbStatus, ServerFnError> {
81    let _user = get_current_admin_user().await?;
82
83    #[cfg(feature = "server")]
84    {
85        let client = get_conn().await.map_err(AppError::db_conn)?;
86
87        // 数据库总大小
88        let db_size: i64 = client
89            .query_one("SELECT pg_database_size(current_database())", &[])
90            .await
91            .map_err(AppError::query)?
92            .get(0);
93
94        // 当前库连接数 + 全局最大连接数。
95        // count(*) 原生返回 bigint(int8),与下方 setting::int 一并显式转 int4,
96        // 以匹配 total_connections/max_connections 的 i32 类型(否则 FromSql 反序列化失败)。
97        let conn_row = client
98            .query_one(
99                "SELECT count(*)::int, \
100                 (SELECT setting::int FROM pg_settings WHERE name = 'max_connections') \
101                 FROM pg_stat_activity WHERE datname = current_database()",
102                &[],
103            )
104            .await
105            .map_err(AppError::query)?;
106        let total_conn: i32 = conn_row.get(0);
107        let max_conn: i32 = conn_row.get(1);
108
109        // 最新迁移版本(schema_migrations 由 migrate.rs 创建)
110        let migration = client
111            .query_opt(
112                "SELECT version, applied_at FROM schema_migrations \
113                 ORDER BY applied_at DESC LIMIT 1",
114                &[],
115            )
116            .await
117            .map_err(AppError::query)?;
118        let (migration_version, migration_applied_at) = match migration {
119            Some(row) => (Some(row.get(0)), Some(row.get(1))),
120            None => (None, None),
121        };
122
123        // 表清单:大小/统计走 catalog;行数对小表用真实 COUNT(*),大表回退 reltuples 估算。
124        // 阈值 100MB:超过则 COUNT(*) 成本过高(全表扫描),降级为估算值并标注。
125        const COUNT_SIZE_THRESHOLD: i64 = 100 * 1024 * 1024;
126        let table_rows = client
127            .query(
128                "SELECT c.relname, c.reltuples::bigint, pg_relation_size(c.oid), \
129                 pg_total_relation_size(c.oid) - pg_relation_size(c.oid), \
130                 pg_total_relation_size(c.oid), s.last_vacuum, s.last_analyze, \
131                 s.n_dead_tup, s.n_live_tup \
132                 FROM pg_class c \
133                 JOIN pg_namespace n ON n.oid = c.relnamespace \
134                 LEFT JOIN pg_stat_user_tables s ON s.relid = c.oid \
135                 WHERE c.relkind = 'r' AND n.nspname = 'public' \
136                 ORDER BY pg_total_relation_size(c.oid) DESC",
137                &[],
138            )
139            .await
140            .map_err(AppError::query)?;
141        let tables = {
142            let mut out = Vec::with_capacity(table_rows.len());
143            for r in table_rows {
144                let name: String = r.get(0);
145                let reltuples: i64 = r.get(1);
146                let total_size: i64 = r.get(4);
147                // 小表:真实 COUNT(*);大表:回退 reltuples 估算。
148                // relname 来自 pg_class 可信,但表名可能含大写/特殊字符,需 PG 标识符转义(双引号包裹,内部双引号双写)。
149                let (row_count, estimated) = if total_size < COUNT_SIZE_THRESHOLD {
150                    let ident = format!("\"{}\"", name.replace('"', "\"\""));
151                    let cnt: i64 = client
152                        .query_one(&format!("SELECT count(*)::bigint FROM {}", ident), &[])
153                        .await
154                        .map_err(AppError::query)?
155                        .get(0);
156                    (cnt, false)
157                } else {
158                    (reltuples, true)
159                };
160                out.push(TableInfo {
161                    name,
162                    row_count,
163                    row_count_estimated: estimated,
164                    table_size_bytes: r.get(2),
165                    index_size_bytes: r.get(3),
166                    total_size_bytes: total_size,
167                    last_vacuum: r.get(5),
168                    last_analyze: r.get(6),
169                    dead_tuples: r.get(7),
170                    live_tuples: r.get(8),
171                });
172            }
173            out
174        };
175
176        // 索引占用 Top 10
177        let index_rows = client
178            .query(
179                "SELECT c.relname AS index_name, cl.relname AS table_name, \
180                 pg_relation_size(c.oid) \
181                 FROM pg_class c \
182                 JOIN pg_index i ON i.indexrelid = c.oid \
183                 JOIN pg_class cl ON cl.oid = i.indrelid \
184                 JOIN pg_namespace n ON n.oid = cl.relnamespace \
185                 WHERE n.nspname = 'public' \
186                 ORDER BY pg_relation_size(c.oid) DESC LIMIT 10",
187                &[],
188            )
189            .await
190            .map_err(AppError::query)?;
191        let top_indexes = index_rows
192            .into_iter()
193            .map(|r| IndexInfo {
194                name: r.get(0),
195                table_name: r.get(1),
196                size_bytes: r.get(2),
197            })
198            .collect();
199
200        // 活跃连接(过滤自身 pid,避免循环显示)。
201        // extract(epoch FROM ...) 原生返回 numeric(decimal),tokio-postgres 无 FromSql<f64>
202        // 实现该类型;显式 ::double precision 转 float8 以匹配 query_duration_secs 的 f64。
203        let conn_rows = client
204            .query(
205                "SELECT pid, usename, state, query, \
206                 extract(epoch FROM now() - query_start)::double precision \
207                 FROM pg_stat_activity \
208                 WHERE datname = current_database() AND pid <> pg_backend_pid() \
209                 ORDER BY query_start DESC NULLS LAST LIMIT 50",
210                &[],
211            )
212            .await
213            .map_err(AppError::query)?;
214        let active_connections = conn_rows
215            .into_iter()
216            .map(|r| ConnInfo {
217                pid: r.get(0),
218                user: r.get::<_, Option<String>>(1).unwrap_or_default(),
219                state: r.get(2),
220                query: r.get(3),
221                query_duration_secs: r.get(4),
222            })
223            .collect();
224
225        Ok(DbStatus {
226            db_size_bytes: db_size,
227            total_connections: total_conn,
228            max_connections: max_conn,
229            migration_version,
230            migration_applied_at,
231            tables,
232            top_indexes,
233            active_connections,
234        })
235    }
236    #[cfg(not(feature = "server"))]
237    {
238        Ok(DbStatus::default())
239    }
240}