xfy ecf3c73715 fix(database): make src/api/database compile under WASM (web-only) build
The /admin/system feature was never verified against the WASM frontend
target (dx serve builds src/main.rs with --features web and no server).
The entire src/api/database/ tree unconditionally pulled in server-only
crates (axum, sqlparser, dashmap, tokio, tokio_postgres, futures, regex)
and server-gated helpers (get_current_admin_user, parse_session_token,
get_user_by_token), so the frontend build failed with 43 errors.

Root cause + fixes (mirror the established settings.rs pattern):

- Gate server-only  imports behind #[cfg(feature = server)] in
  status/system_status/sql_console/schema/tasks/backup. The #[server] macro
  strips call sites but NOT use statements, so unresolved imports remained.
- Ungate  in main.rs: SystemSnapshot is a shared serde
  type referenced by ServerStatus.host on both targets; only the sampler
  task/SNAPSHOT static are internally server-gated.
- Gate  (pure Axum handler, zero WASM consumers) and the
  download_backup Axum handler in backup.rs.
- Move restore_backup's validation preamble (regex/backup_path/fs) inside
  the #[cfg(feature = server)] block; gate std::path/chrono::Utc imports.
- Add [[bin]] required-features=[server] for generate_highlight_css so the
  syntect-dependent build tool is skipped in web-only builds.

Genuine WASM bugs surfaced once the server bodies stopped masking them:

- system.rs: Closure import path (dioxus::prelude::wasm_bindgen ->
  wasm_bindgen, matching write.rs); setTimeout expects i32 not u32;
  editor_handle() -> editor_handle.read() (Signal is not Fn in 0.7).
- codemirror_bridge.rs: set_schema extern took &SqlSchema but SqlSchema is
  a serde type with no IntoWasmAbi. Changed both set_schema signatures to
  &JsValue; call site now serializes via serde-wasm-bindgen::to_value.
- system.rs: signals .set() inside spawn/use_future/onclick closures need
   bindings (incl. the *_f rebinding copies); added cfg_attr
  allow(unused_mut) on the 4 tab components, matching write.rs convention.

Verified: cargo check (default/server) clean; cargo check --features web
--target wasm32-unknown-unknown clean (0 errors); dx check clean; 411
cargo tests pass.
2026-06-30 13:41:23 +08:00

213 lines
7.4 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#![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};
// 仅 server 构建用到admin 鉴权 + DB 查询。WASM 侧的 server-function 客户端桩
// 不解析这些符号,必须 gate 以避免在非 server 构建里找不到 server-only 符号。
#[cfg(feature = "server")]
use crate::api::auth::get_current_admin_user;
#[cfg(feature = "server")]
use crate::api::error::AppError;
#[cfg(feature = "server")]
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())
}
}