From 35b29f4370f529d9d3cd918f5248da48898f5b21 Mon Sep 17 00:00:00 2001 From: imengying Date: Fri, 4 Sep 2026 18:25:50 +0800 Subject: [PATCH] =?UTF-8?q?=E7=B2=BE=E7=AE=80=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=B9=B6=E6=94=AF=E6=8C=81=E7=A9=BA=E9=97=B4?= =?UTF-8?q?=E5=9B=9E=E6=94=B6=E4=B8=8E=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/release.yml | 7 +- README.md | 4 +- backend/src/backup.rs | 46 ++++++++-- backend/src/config.rs | 54 +++++++++-- backend/src/db/mod.rs | 85 ++++++++++++++++-- backend/src/db/queries.rs | 36 +------- backend/src/main.rs | 25 +++++- backend/src/models.rs | 6 +- backend/src/routes/admin.rs | 118 +++++++++++++++++++------ backend/src/websocket/agent.rs | 6 +- frontend/src/api.ts | 13 ++- frontend/src/components/AdminPanel.tsx | 55 ++++++++---- frontend/src/components/StatsBar.tsx | 4 +- frontend/src/styles/admin.css | 23 +++-- frontend/src/types.ts | 13 ++- scripts/smoke-test.sh | 17 ++++ 16 files changed, 387 insertions(+), 125 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 519dd22..972d42a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -180,7 +180,10 @@ jobs: MONITOR_BASE_URL=http://127.0.0.1:18080 \ MONITOR_ADMIN_USERNAME=sqlite-smoke \ MONITOR_ADMIN_PASSWORD=SmokePassword123 \ + MONITOR_MIGRATION_URL="$POSTGRES_URL" \ + MONITOR_MIGRATION_KIND=postgresql \ sh scripts/smoke-test.sh + grep -F "database_url = \"$POSTGRES_URL\"" "$sqlite_config" kill "$sqlite_pid" wait "$sqlite_pid" || true sqlite_pid="" @@ -191,7 +194,7 @@ jobs: printf '%s\n' \ "database_url = \"$POSTGRES_URL\"" \ 'bind_addr = "127.0.0.1:18081"' \ - 'admin_username = "postgres-smoke"' \ + 'admin_username = "sqlite-smoke"' \ 'admin_password = "SmokePassword123"' \ 'turnstile_site_key = ""' \ 'turnstile_secret_key = ""' \ @@ -202,7 +205,7 @@ jobs: postgres_pid=$! wait_ready http://127.0.0.1:18081 "$postgres_dir/server.log" "$postgres_pid" MONITOR_BASE_URL=http://127.0.0.1:18081 \ - MONITOR_ADMIN_USERNAME=postgres-smoke \ + MONITOR_ADMIN_USERNAME=sqlite-smoke \ MONITOR_ADMIN_PASSWORD=SmokePassword123 \ sh scripts/smoke-test.sh diff --git a/README.md b/README.md index 8732da9..d2c3bc6 100644 --- a/README.md +++ b/README.md @@ -74,14 +74,14 @@ Windows 使用 `install.ps1 -Uninstall`,彻底删除数据时再加 `-Purge` ## 数据库与备份 -管理后台的“数据库”页面可直接导出或恢复 ZIP,SQLite 和 PostgreSQL 都支持。 +管理后台的“数据库”页面可查看占用空间、手动回收空间、导出或恢复 ZIP,SQLite 和 PostgreSQL 都支持。 - 备份包含设置、节点、监控历史、通知、主题、远程任务和安全配置。 - 登录会话等临时数据不会导出;恢复完成后需要重新登录。 - 建议使用相同版本的 NodeFlare 恢复备份。 - `pg_dump` 和 SQLite `.backup` 仍可作为额外的数据库原生备份方式,但不是使用内置 ZIP 功能的前提。 -切换 `database_url` 只会连接或初始化目标数据库,不会自动迁移原数据库的数据。需要迁移时使用后台 ZIP 导出和恢复。 +“数据库迁移”可在 SQLite 和 PostgreSQL 之间复制全部持久数据。迁移会覆盖目标库中已有的 NodeFlare 数据并更新 `database_url`,完成后重启 NodeFlare。 ## 安装 Agent diff --git a/backend/src/backup.rs b/backend/src/backup.rs index e2b6769..6136d60 100644 --- a/backend/src/backup.rs +++ b/backend/src/backup.rs @@ -471,11 +471,8 @@ async fn restore_table( Ok(restored) } -pub async fn restore_archive(db: &Database, archive: &[u8]) -> Result { - if archive.is_empty() || archive.len() > DATABASE_BACKUP_MAX_BYTES { - bail!("数据库备份 ZIP 大小无效"); - } - let mut archive = ZipArchive::new(Cursor::new(archive)).context("文件不是有效的 ZIP")?; +async fn restore(db: &Database, source: R) -> Result { + let mut archive = ZipArchive::new(source).context("文件不是有效的 ZIP")?; let _manifest = validate_archive(&mut archive)?; let schema = database_schema(db).await?; @@ -517,10 +514,49 @@ pub async fn restore_archive(db: &Database, archive: &[u8]) -> Result { Ok(restored) } +pub async fn restore_archive(db: &Database, archive: &[u8]) -> Result { + if archive.is_empty() || archive.len() > DATABASE_BACKUP_MAX_BYTES { + bail!("数据库备份 ZIP 大小无效"); + } + restore(db, Cursor::new(archive)).await +} + +pub async fn copy_database(source: &Database, target: &Database) -> Result { + let archive = export_archive(source).await?; + restore(target, archive.file).await +} + #[cfg(test)] mod tests { use super::*; + #[tokio::test] + async fn copies_database_through_a_streamed_archive() { + let source = crate::db::connect("sqlite::memory:").await.unwrap(); + let target = crate::db::connect("sqlite::memory:").await.unwrap(); + source.migrate().await.unwrap(); + target.migrate().await.unwrap(); + sqlx::query( + "INSERT INTO settings(key, value) VALUES \ + ('site_name', 'Migrated'), \ + ('admin_username', 'admin'), \ + ('admin_password_hash', 'hash'), \ + ('password_client_salt', 'salt'), \ + ('password_scheme', 'argon2-client-pbkdf2-v1')", + ) + .execute(source.pool()) + .await + .unwrap(); + + assert_eq!(copy_database(&source, &target).await.unwrap(), 5); + let site_name = + sqlx::query_scalar::<_, String>("SELECT value FROM settings WHERE key='site_name'") + .fetch_one(target.pool()) + .await + .unwrap(); + assert_eq!(site_name, "Migrated"); + } + #[tokio::test] async fn sqlite_backup_round_trip_restores_data_and_clears_sessions() { let db = crate::db::connect("sqlite::memory:").await.unwrap(); diff --git a/backend/src/config.rs b/backend/src/config.rs index 13a22f1..09fdc2c 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -149,7 +149,7 @@ impl Config { } } -pub fn clear_bootstrap_password(path: &Path) -> Result { +fn replace_assignment(path: &Path, key: &str, replacement: &str) -> Result { let content = std::fs::read_to_string(path) .with_context(|| format!("failed to read configuration {}", path.display()))?; let mut output = String::with_capacity(content.len()); @@ -159,14 +159,14 @@ pub fn clear_bootstrap_password(path: &Path) -> Result { .strip_suffix('\n') .map_or((segment, ""), |line| (line, "\n")); let trimmed = line.trim_start(); - let is_password = !trimmed.starts_with('#') + let matches = !trimmed.starts_with('#') && trimmed - .strip_prefix("admin_password") + .strip_prefix(key) .is_some_and(|rest| rest.trim_start().starts_with('=')); - if is_password { + if matches { let indentation = &line[..line.len() - trimmed.len()]; output.push_str(indentation); - output.push_str("admin_password = \"\""); + output.push_str(replacement); output.push_str(newline); replaced = true; } else { @@ -189,6 +189,22 @@ pub fn clear_bootstrap_password(path: &Path) -> Result { Ok(true) } +pub fn clear_bootstrap_password(path: &Path) -> Result { + replace_assignment(path, "admin_password", "admin_password = \"\"") +} + +pub fn update_database_url(path: &Path, database_url: &str) -> Result<()> { + if database_url.chars().any(char::is_control) { + anyhow::bail!("database URL contains control characters"); + } + let escaped = database_url.replace('\\', "\\\\").replace('"', "\\\""); + let replacement = format!("database_url = \"{escaped}\""); + if !replace_assignment(path, "database_url", &replacement)? { + anyhow::bail!("database_url is missing from configuration"); + } + Ok(()) +} + fn is_example_password(value: &str) -> bool { matches!( value.trim().to_ascii_lowercase().as_str(), @@ -204,7 +220,7 @@ fn resolve_path(base: &Path, value: &Path) -> PathBuf { } } -fn resolve_database_url(base: &Path, value: &str) -> Result { +pub fn resolve_database_url(base: &Path, value: &str) -> Result { let value = value.trim(); if value.starts_with("postgres://") || value.starts_with("postgresql://") { return Ok(value.to_string()); @@ -239,7 +255,7 @@ mod tests { Args, clear_bootstrap_password, default_admin_frontend_dir, default_agent_dir, default_bind_addr, default_config_path, default_data_dir, default_database_url, default_public_frontend_dir, default_share_dir, default_theme_dir, is_example_password, - resolve_database_url, + resolve_database_url, update_database_url, }; use clap::Parser; use std::path::Path; @@ -309,4 +325,28 @@ mod tests { ); assert!(!clear_bootstrap_password(&path).unwrap()); } + + #[test] + fn updates_only_the_database_url() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("config.toml"); + std::fs::write( + &path, + "database_url = \"sqlite://nodeflare.db\"\n# database_url = \"ignored\"\nbind_addr = \"127.0.0.1:8080\"\n", + ) + .unwrap(); + update_database_url( + &path, + "postgres://nodeflare:p%40ss@127.0.0.1:5432/nodeflare?sslmode=prefer", + ) + .unwrap(); + assert!(std::fs::read_to_string(&path).unwrap().contains( + "database_url = \"postgres://nodeflare:p%40ss@127.0.0.1:5432/nodeflare?sslmode=prefer\"" + )); + assert!( + std::fs::read_to_string(&path) + .unwrap() + .contains("# database_url = \"ignored\"") + ); + } } diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 6303a22..42d4943 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -27,6 +27,15 @@ pub enum DatabaseKind { Postgres, } +impl DatabaseKind { + pub fn as_str(self) -> &'static str { + match self { + Self::Sqlite => "sqlite", + Self::Postgres => "postgresql", + } + } +} + #[derive(Clone)] pub struct Database { pool: AnyPool, @@ -58,6 +67,58 @@ impl Database { self.kind == DatabaseKind::Postgres } + pub async fn stats(&self) -> Result { + let (size_bytes, reclaimable_bytes) = match self.kind { + DatabaseKind::Sqlite => { + let page_count = sqlx::query_scalar::<_, i64>("PRAGMA page_count") + .fetch_one(&self.pool) + .await?; + let page_size = sqlx::query_scalar::<_, i64>("PRAGMA page_size") + .fetch_one(&self.pool) + .await?; + let free_pages = sqlx::query_scalar::<_, i64>("PRAGMA freelist_count") + .fetch_one(&self.pool) + .await?; + ( + page_count.saturating_mul(page_size), + Some(free_pages.saturating_mul(page_size)), + ) + } + DatabaseKind::Postgres => ( + sqlx::query_scalar::<_, i64>("SELECT pg_database_size(current_database())::BIGINT") + .fetch_one(&self.pool) + .await?, + None, + ), + }; + Ok(crate::models::DatabaseStats { + kind: self.kind.as_str().to_string(), + size_bytes, + reclaimable_bytes, + }) + } + + pub async fn reclaim_space(&self) -> Result<()> { + match self.kind { + DatabaseKind::Sqlite => { + sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .execute(&self.pool) + .await?; + sqlx::query("VACUUM").execute(&self.pool).await?; + sqlx::query("PRAGMA optimize").execute(&self.pool).await?; + sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .execute(&self.pool) + .await?; + } + DatabaseKind::Postgres => { + sqlx::query("VACUUM (FULL, ANALYZE)") + .execute(&self.pool) + .await?; + } + } + Ok(()) + } + pub async fn optimize(&self) -> Result<()> { if self.kind == DatabaseKind::Sqlite { sqlx::query("PRAGMA optimize").execute(&self.pool).await?; @@ -201,13 +262,7 @@ impl Settings { pub async fn connect(database_url: &str) -> Result { install_default_drivers(); - let kind = if database_url.starts_with("sqlite:") { - DatabaseKind::Sqlite - } else if database_url.starts_with("postgres:") || database_url.starts_with("postgresql:") { - DatabaseKind::Postgres - } else { - anyhow::bail!("unsupported database URL"); - }; + let kind = database_kind(database_url)?; let sqlite_in_memory = kind == DatabaseKind::Sqlite && database_url.trim_start().starts_with("sqlite::memory:"); if kind == DatabaseKind::Sqlite && !sqlite_in_memory { @@ -242,6 +297,16 @@ pub async fn connect(database_url: &str) -> Result { Ok(Database { pool, kind }) } +pub fn database_kind(database_url: &str) -> Result { + if database_url.starts_with("sqlite:") { + Ok(DatabaseKind::Sqlite) + } else if database_url.starts_with("postgres:") || database_url.starts_with("postgresql:") { + Ok(DatabaseKind::Postgres) + } else { + anyhow::bail!("unsupported database URL") + } +} + fn ensure_sqlite_file(database_url: &str) -> Result<()> { let has_mode = database_url .split_once('?') @@ -787,6 +852,12 @@ mod tests { .unwrap(); assert_eq!(journal_mode, "wal"); assert_eq!(auto_vacuum, 2); + + let stats = db.stats().await.unwrap(); + assert_eq!(stats.kind, "sqlite"); + assert!(stats.size_bytes > 0); + assert!(stats.reclaimable_bytes.is_some()); + db.reclaim_space().await.unwrap(); } #[tokio::test] diff --git a/backend/src/db/queries.rs b/backend/src/db/queries.rs index ab8bf6a..73e0de8 100644 --- a/backend/src/db/queries.rs +++ b/backend/src/db/queries.rs @@ -1,9 +1,9 @@ use super::{Database, SECRET_MASK, now}; use crate::auth; use crate::models::{ - AgentLatencyResult, AgentLatencyTask, AgentReport, AlertRuleInput, AlertRuleView, - DatabaseStats, HistoryPoint, LatencySample, LatencyTaskInput, LatencyTaskView, RemoteTaskInfo, - ServerInput, ServerView, TelegramSettingsInput, TelegramSettingsView, ThemeInput, ThemeView, + AgentLatencyResult, AgentLatencyTask, AgentReport, AlertRuleInput, AlertRuleView, HistoryPoint, + LatencySample, LatencyTaskInput, LatencyTaskView, RemoteTaskInfo, ServerInput, ServerView, + TelegramSettingsInput, TelegramSettingsView, ThemeInput, ThemeView, }; use anyhow::{Context, Result}; use sqlx::any::AnyArguments; @@ -848,18 +848,6 @@ pub async fn history(db: &Database, server_id: &str, hours: i64) -> Result Result<()> { - let mut transaction = db.pool().begin().await?; - sqlx::query("DELETE FROM metric_history") - .execute(&mut *transaction) - .await?; - sqlx::query("DELETE FROM latency_results") - .execute(&mut *transaction) - .await?; - transaction.commit().await?; - Ok(()) -} - pub async fn cleanup_database(db: &Database, retention_days: i64) -> Result<()> { let current = now(); let cutoff = current - retention_days.clamp(1, 3650) * 86_400; @@ -897,24 +885,6 @@ pub async fn cleanup_database(db: &Database, retention_days: i64) -> Result<()> Ok(()) } -pub async fn database_stats(db: &Database, offline_threshold: i64) -> Result { - let cutoff = now() - offline_threshold.clamp(30, 3600); - let row = sqlx::query(db.sql( - "SELECT (SELECT COUNT(*) FROM servers) AS server_count, \ - (SELECT COUNT(*) FROM server_latest_state WHERE latest_timestamp>=?) AS online_count, \ - ((SELECT COUNT(*) FROM metric_history) + (SELECT COUNT(*) FROM latency_results)) \ - AS history_rows", - )) - .bind(cutoff) - .fetch_one(db.pool()) - .await?; - Ok(DatabaseStats { - server_count: row.try_get("server_count")?, - online_count: row.try_get("online_count")?, - history_rows: row.try_get("history_rows")?, - }) -} - pub async fn public_server_exists(db: &Database, server_id: &str) -> Result { let count = sqlx::query_scalar::<_, i64>( db.sql("SELECT COUNT(*) FROM servers WHERE id=? AND hidden=0"), diff --git a/backend/src/main.rs b/backend/src/main.rs index b640903..89913c7 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -30,17 +30,26 @@ use tower_http::trace::TraceLayer; pub struct AppState { pub db: db::Database, pub config: config::Config, + pub config_path: std::path::PathBuf, + pub database_overridden: bool, pub http: reqwest::Client, pub agents: RwLock>, pub dashboard_tx: broadcast::Sender, pub database_maintenance: Mutex<()>, - pub database_restoring: AtomicBool, + pub database_maintenance_active: AtomicBool, pub login_attempts: security::AttemptLimiter, pub remote_totp_attempts: security::AttemptLimiter, pub password_verifications: Arc, } impl AppState { + pub async fn disconnect_agents(&self) { + let agents = std::mem::take(&mut *self.agents.write().await); + for connection in agents.into_values() { + let _ = connection.sender.try_send(websocket::AgentCommand::Close); + } + } + pub async fn push_agent_config(&self, server_id: &str) { let Some(connection) = self.agents.read().await.get(server_id).cloned() else { return; @@ -112,6 +121,7 @@ async fn main() -> Result<()> { let args = config::Args::parse(); let mut config = config::Config::load(&args.config)?; + let database_overridden = args.database.is_some(); if let Some(database) = args.database { config.database_url = database; } @@ -155,11 +165,13 @@ async fn main() -> Result<()> { let state = Arc::new(AppState { db: database, config, + config_path: args.config, + database_overridden, http, agents: RwLock::new(HashMap::new()), dashboard_tx, database_maintenance: Mutex::new(()), - database_restoring: AtomicBool::new(false), + database_maintenance_active: AtomicBool::new(false), login_attempts: security::AttemptLimiter::new( 5, std::time::Duration::from_secs(5 * 60), @@ -259,6 +271,14 @@ async fn main() -> Result<()> { post(routes::admin::exchange_refresh), ) .route("/api/admin/database", get(routes::admin::database_stats)) + .route( + "/api/admin/database/reclaim", + post(routes::admin::database_reclaim), + ) + .route( + "/api/admin/database/migrate", + post(routes::admin::database_migrate), + ) .route( "/api/admin/database/backup", get(routes::admin::database_backup), @@ -267,7 +287,6 @@ async fn main() -> Result<()> { "/api/admin/database/restore", post(routes::admin::database_restore), ) - .route("/api/admin/history", delete(routes::admin::history_delete)) .route("/api/admin/remote/task", post(routes::remote::create_task)) .route("/api/admin/remote/task/{id}", get(routes::remote::get_task)) .layer(axum_middleware::from_fn_with_state( diff --git a/backend/src/models.rs b/backend/src/models.rs index 86b05b0..14c4632 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -487,9 +487,9 @@ pub struct RemoteTaskInfo { #[derive(Debug, Clone, Serialize)] pub struct DatabaseStats { - pub server_count: i64, - pub online_count: i64, - pub history_rows: i64, + pub kind: String, + pub size_bytes: i64, + pub reclaimable_bytes: Option, } #[derive(Debug, Clone, Serialize)] diff --git a/backend/src/routes/admin.rs b/backend/src/routes/admin.rs index f90c4a6..ed21bb4 100644 --- a/backend/src/routes/admin.rs +++ b/backend/src/routes/admin.rs @@ -28,9 +28,15 @@ pub struct DatabaseRestoreInput { filename: String, } -struct DatabaseRestoreFlag<'a>(&'a std::sync::atomic::AtomicBool); +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DatabaseMigrationInput { + database_url: String, +} -impl Drop for DatabaseRestoreFlag<'_> { +struct DatabaseOperationFlag<'a>(&'a std::sync::atomic::AtomicBool); + +impl Drop for DatabaseOperationFlag<'_> { fn drop(&mut self) { self.0.store(false, Ordering::Release); } @@ -612,15 +618,84 @@ pub async fn exchange_refresh(State(state): State>) -> Result>) -> Result { - let threshold = crate::db::load_settings(&state.db) + Ok(Json(state.db.stats().await.map_err(ApiResponse::internal)?).into_response()) +} + +pub async fn database_reclaim(State(state): State>) -> Result { + let _maintenance = state.database_maintenance.lock().await; + state + .database_maintenance_active + .store(true, Ordering::Release); + let _operation = DatabaseOperationFlag(&state.database_maintenance_active); + state.disconnect_agents().await; + + let before = state.db.stats().await.map_err(ApiResponse::internal)?; + state + .db + .reclaim_space() .await - .map_err(ApiResponse::internal)? - .offline_threshold_seconds; - Ok(Json( - crate::db::queries::database_stats(&state.db, threshold) - .await - .map_err(ApiResponse::internal)?, - ) + .map_err(ApiResponse::internal)?; + let database = state.db.stats().await.map_err(ApiResponse::internal)?; + let reclaimed_bytes = before.size_bytes.saturating_sub(database.size_bytes); + Ok(Json(serde_json::json!({ + "database": database, + "reclaimed_bytes": reclaimed_bytes, + })) + .into_response()) +} + +pub async fn database_migrate( + State(state): State>, + Json(input): Json, +) -> Result { + if state.database_overridden { + return Err(ApiResponse::bad_request( + "使用 --database 启动时无法自动切换配置", + )); + } + let value = input.database_url.trim(); + if value.is_empty() || value.len() > 2048 || value.chars().any(char::is_control) { + return Err(ApiResponse::bad_request("请输入有效的目标数据库 URL")); + } + let base = state + .config_path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")); + let target_url = crate::config::resolve_database_url(base, value) + .map_err(|_| ApiResponse::bad_request("目标数据库 URL 格式无效"))?; + let target_kind = crate::db::database_kind(&target_url) + .map_err(|_| ApiResponse::bad_request("目标数据库 URL 格式无效"))?; + if target_kind == state.db.kind() { + return Err(ApiResponse::bad_request("目标必须使用另一种数据库类型")); + } + + let _maintenance = state.database_maintenance.lock().await; + state + .database_maintenance_active + .store(true, Ordering::Release); + let _operation = DatabaseOperationFlag(&state.database_maintenance_active); + state.disconnect_agents().await; + + let target = crate::db::connect(&target_url) + .await + .map_err(|_| ApiResponse::unprocessable("无法连接目标数据库,请检查地址、账号和网络"))?; + target + .migrate() + .await + .map_err(|_| ApiResponse::unprocessable("无法初始化目标数据库,请检查账号权限"))?; + let migrated_rows = crate::backup::copy_database(&state.db, &target) + .await + .map_err(|error| ApiResponse::unprocessable(format!("迁移失败:{error}")))?; + let database = target.stats().await.map_err(ApiResponse::internal)?; + crate::config::update_database_url(&state.config_path, &target_url) + .map_err(ApiResponse::internal)?; + + Ok(Json(serde_json::json!({ + "migrated_rows": migrated_rows, + "target_kind": target_kind.as_str(), + "size_bytes": database.size_bytes, + "restart_required": true, + })) .into_response()) } @@ -683,30 +758,17 @@ pub async fn database_restore( })?; let _maintenance = state.database_maintenance.lock().await; - if state.database_restoring.swap(true, Ordering::AcqRel) { - return Err(ApiResponse::conflict("数据库正在恢复")); - } - let _restore_flag = DatabaseRestoreFlag(&state.database_restoring); - let agents = std::mem::take(&mut *state.agents.write().await); - for connection in agents.into_values() { - let _ = connection - .sender - .try_send(crate::websocket::AgentCommand::Close); - } + state + .database_maintenance_active + .store(true, Ordering::Release); + let _operation = DatabaseOperationFlag(&state.database_maintenance_active); + state.disconnect_agents().await; let restored_rows = crate::backup::restore_archive(&state.db, &archive) .await .map_err(|error| ApiResponse::unprocessable(format!("恢复失败:{error}")))?; Ok(Json(serde_json::json!({"restored_rows": restored_rows})).into_response()) } -pub async fn history_delete(State(state): State>) -> Result { - let _maintenance = state.database_maintenance.lock().await; - crate::db::queries::clear_history(&state.db) - .await - .map_err(ApiResponse::internal)?; - Ok(StatusCode::NO_CONTENT.into_response()) -} - fn validate_id(id: &str) -> Result<(), ApiResponse> { if id.is_empty() || id.len() > 80 || id.contains('/') { Err(ApiResponse::bad_request("ID 无效")) diff --git a/backend/src/websocket/agent.rs b/backend/src/websocket/agent.rs index c3cd0cb..f64d3fa 100644 --- a/backend/src/websocket/agent.rs +++ b/backend/src/websocket/agent.rs @@ -57,10 +57,10 @@ pub async fn handle( ConnectInfo(peer): ConnectInfo, headers: HeaderMap, ) -> Response { - if state.database_restoring.load(Ordering::Acquire) { + if state.database_maintenance_active.load(Ordering::Acquire) { return ApiResponse::error( axum::http::StatusCode::SERVICE_UNAVAILABLE, - "数据库正在恢复,请稍后重试", + "数据库正在维护,请稍后重试", ) .into_response(); } @@ -209,7 +209,7 @@ async fn handle_text( outbound: &mpsc::Sender, text: &str, ) { - if state.database_restoring.load(Ordering::Acquire) { + if state.database_maintenance_active.load(Ordering::Acquire) { send_persistence_error(outbound, 0); return; } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index deae784..405a908 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -1,4 +1,4 @@ -import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, Config, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, LoginSession, RemoteTask, RemoteTaskCreated, RemoteTaskInput, Server, ServerInput, Settings, TelegramSettings, TelegramSettingsInput, Theme, ThemeSettingsSchema, TotpSetup, TotpStatus } from "./types"; +import type { AdminServer, AlertRule, AlertRuleInput, Bootstrap, Config, DatabaseMigrationResult, DatabaseStats, ExchangeRates, HistoryPoint, LatencySample, LatencyTask, LatencyTaskInput, LatencyTestPoint, LoginSession, RemoteTask, RemoteTaskCreated, RemoteTaskInput, Server, ServerInput, Settings, TelegramSettings, TelegramSettingsInput, Theme, ThemeSettingsSchema, TotpSetup, TotpStatus } from "./types"; const TOKEN_KEY = "nodeflare-admin-token"; export const ADMIN_UNAUTHORIZED_EVENT = "nodeflare:admin-unauthorized"; @@ -145,6 +145,16 @@ export const api = { true, ), databaseStats: () => request("/api/admin/database", {}, true), + reclaimDatabase: () => request<{ database: DatabaseStats; reclaimed_bytes: number }>( + "/api/admin/database/reclaim", + { method: "POST" }, + true, + ), + migrateDatabase: (databaseUrl: string) => request( + "/api/admin/database/migrate", + { method: "POST", body: JSON.stringify({ database_url: databaseUrl }) }, + true, + ), databaseBackup: async () => { const response = await requestResponse("/api/admin/database/backup", {}, true); const disposition = response.headers.get("Content-Disposition") ?? ""; @@ -155,7 +165,6 @@ export const api = { const query = new URLSearchParams({ filename: file.name }); return request<{ restored_rows: number }>(`/api/admin/database/restore?${query}`, { method: "POST", body: file }, true); }, - clearHistory: () => request("/api/admin/history", { method: "DELETE" }, true), createRemoteTask: (input: RemoteTaskInput) => request<{ tasks: RemoteTaskCreated[] }>("/api/admin/remote/task", { method: "POST", body: JSON.stringify(input), diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 3f10303..511c6fc 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -2,6 +2,7 @@ import { AlertTriangle, Info, ArrowLeft, + ArrowRightLeft, ChevronDown, ChevronUp, CircleAlert, @@ -36,7 +37,7 @@ import { import { ChangeEvent, DragEvent, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ADMIN_UNAUTHORIZED_EVENT, api, ApiError, getToken, setToken } from "../api"; import { adminTabFromPath, adminTabPaths, canonicalAdminPath, type AdminTab } from "../adminRoutes"; -import { formatByteSize, isOnline, parseByteSize } from "../format"; +import { formatBytes, formatByteSize, isOnline, parseByteSize } from "../format"; import { derivePassword } from "../password"; import { hasActiveRemoteTasks, isRemoteTaskActive, REMOTE_TASK_POLL_INTERVAL_MS } from "../refresh"; import { ASSET_CURRENCIES, type AdminServer, type Config, type DatabaseStats, type ExchangeRates, type LoginSession, type RemoteTask, type ServerInput, type Settings, type Theme, type ThemeSettingField, type ThemeSettingsSchema, type ThemeSettingValue, type TotpSetup, type TotpStatus } from "../types"; @@ -68,7 +69,7 @@ const adminPages: Record = { themeSettings: { title: "主题设置", description: "调整当前前端主题提供的显示选项" }, alerts: { title: "通知", description: "配置 Telegram 通知和资源告警阈值" }, security: { title: "登录与安全", description: "管理管理员账号、登录设备和安全验证" }, - data: { title: "数据库", description: "查看状态、备份恢复并维护历史数据" }, + data: { title: "数据库", description: "查看空间、备份恢复和迁移数据库" }, remote: { title: "远程执行", description: "输入命令并执行" }, about: { title: "关于", description: "版本信息与项目地址" }, }; @@ -254,6 +255,7 @@ export function AdminPanel({ const [draggingId, setDraggingId] = useState(""); const [settings, setSettings] = useState(null); const [database, setDatabase] = useState(null); + const [databaseMigrationUrl, setDatabaseMigrationUrl] = useState(""); const [exchangeRates, setExchangeRates] = useState(null); const [exchangeRatesExpanded, setExchangeRatesExpanded] = useState(false); const [themes, setThemes] = useState([]); @@ -586,6 +588,33 @@ export function AdminPanel({ finally { setBusy(false); } } + async function reclaimDatabase() { + if (!window.confirm("回收空间期间数据库会短暂不可用,确认继续?")) return; + setBusy(true); setError(""); setNotice(""); + try { + const result = await api.reclaimDatabase(); + setDatabase(result.database); + setNotice(result.reclaimed_bytes > 0 ? `已回收 ${formatBytes(result.reclaimed_bytes)}` : "数据库已整理,当前没有可释放空间"); + } catch (reason) { setError(reason instanceof Error ? reason.message : "回收数据库空间失败"); } + finally { setBusy(false); } + } + + async function migrateDatabase() { + const databaseUrl = databaseMigrationUrl.trim(); + if (!databaseUrl) return; + const source = database?.kind === "postgresql" ? "PostgreSQL" : "SQLite"; + const target = database?.kind === "postgresql" ? "SQLite" : "PostgreSQL"; + if (!window.confirm(`将 ${source} 迁移到 ${target}?目标库中已有的 NodeFlare 数据会被覆盖。`)) return; + setBusy(true); setError(""); setNotice(""); + try { + const result = await api.migrateDatabase(databaseUrl); + setDatabaseMigrationUrl(""); + const targetName = result.target_kind === "postgresql" ? "PostgreSQL" : "SQLite"; + window.alert(`数据库已迁移到 ${targetName},共 ${result.migrated_rows.toLocaleString()} 行(${formatBytes(result.size_bytes)}),并已更新配置。请重启 NodeFlare 后重新登录。`); + } catch (reason) { setError(reason instanceof Error ? reason.message : "数据库迁移失败"); } + finally { setBusy(false); } + } + async function refreshExchangeRates() { setBusy(true); setError(""); setNotice(""); try { @@ -703,14 +732,6 @@ export function AdminPanel({ finally { setBusy(false); } } - async function clearHistory() { - if (!window.confirm("确认清空全部历史指标?节点配置和最新状态不会删除。")) return; - setBusy(true); setError(""); - try { await api.clearHistory(); setNotice("历史指标已清空"); await loadDatabase(); } - catch (reason) { setError(reason instanceof Error ? reason.message : "清理历史失败"); } - finally { setBusy(false); } - } - async function loadLoginSessions() { try { const result = await api.loginSessions(); @@ -1077,7 +1098,7 @@ export function AdminPanel({ ) : settings && (tab === "appearance" || tab === "themeSettings" || tab === "alerts" || tab === "security" || tab === "data") ? ( -
+ { event.preventDefault(); void migrateDatabase(); } : saveSite}> {tab === "appearance" ? <>
外观与展示
@@ -1129,13 +1150,15 @@ export function AdminPanel({ {tab === "data" ? <>

数据库维护

备份包含节点、设置、历史、任务及安全配置,请妥善保管。
{ const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} />
-
{database ? <> :

正在读取数据库统计...

}
+
数据库大小{database ? formatBytes(database.size_bytes) : "读取中..."}{database ? {database.kind === "postgresql" ? "PostgreSQL" : "SQLite"}{database.reclaimable_bytes ? ` · 可回收 ${formatBytes(database.reclaimable_bytes)}` : ""} : null}
+
+
数据库迁移

将当前 {database?.kind === "postgresql" ? "PostgreSQL" : "SQLite"} 数据复制到 {database?.kind === "postgresql" ? "SQLite" : "PostgreSQL"},完成后自动更新配置,重启服务后生效。

+
+
每日汇率

{exchangeRates ? `${exchangeRates.source} · ${exchangeRates.date || "等待首次更新"}${exchangeRates.stale ? " · 数据待更新" : ""}` : "正在读取汇率快照"}

{exchangeRatesExpanded ? : null}
{exchangeRatesExpanded ? exchangeRates ?
{ASSET_CURRENCIES.filter((currency) => currency !== "CNY").map((currency) => )}
币种1 CNY 可兑换
{currency}{exchangeRates.rates[currency]?.toLocaleString(undefined, { maximumFractionDigits: 6 }) ?? "--"}
:
尚未读取
: null}
-
-

清空历史不会删除节点、密钥或最新状态。

: null} {tab !== "data" ?
: null} @@ -1227,7 +1250,3 @@ function ThemeOption({ field, value, onChange }: { field: ThemeSettingField; val } return ; } - -function DataStat({ label, value }: { label: string; value: number | string }) { - return
{label}{value}
; -} diff --git a/frontend/src/components/StatsBar.tsx b/frontend/src/components/StatsBar.tsx index 6643466..d679f27 100644 --- a/frontend/src/components/StatsBar.tsx +++ b/frontend/src/components/StatsBar.tsx @@ -1,5 +1,5 @@ import { ArrowDown, ArrowUp, Database, ServerIcon, WalletCards } from "lucide-react"; -import { formatBytes, formatCurrency, formatSpeed, formatSpeedParts, isOnline, number, remainingAssetValue } from "../format"; +import { formatBytes, formatCurrency, formatSpeedParts, isOnline, number, remainingAssetValue } from "../format"; import { assetCurrency, themeToggle } from "../theme"; import type { Config, ExchangeRates, Server } from "../types"; import { ui } from "../locale"; @@ -30,7 +30,7 @@ export function StatsBar({ servers, config, exchangeRates }: { servers: Server[] const peakDownload = onlineServers.reduce((peak, server) => !peak || number(server.net_in) > number(peak.net_in) ? server : peak, null); const peakDetail = (server: Server | null, field: "net_out" | "net_in") => { if (!server || number(server[field]) <= 0) return {ui(locale, "暂无实时流量", "No live traffic")}; - return {ui(locale, `峰值 ${server.name} · ${formatSpeed(server[field])}`, `Peak ${server.name} · ${formatSpeed(server[field])}`)}; + return {ui(locale, `峰值 ${server.name}`, `Peak ${server.name}`)}; }; const paid = servers.filter((server) => server.price > 0); const toDisplayCurrency = (value: number, currency: string) => { diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 699b0b7..9971d12 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -116,7 +116,7 @@ .section-head h3 { margin: 0; color: var(--admin-text); font-size: 17px; } .section-head span { color: var(--admin-muted); font-size: 12px; } .row-actions { display: flex; align-items: center; gap: 3px; } -.section-actions, .data-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; } +.section-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; } .batch-toolbar { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; justify-content: space-between; min-height: 44px; border-block: 1px solid var(--admin-border); color: var(--admin-muted); font-size: 13px; } .select-all { display: inline-flex; align-items: center; flex-direction: row; gap: 7px; color: var(--admin-text); font-size: 13px; } @@ -176,10 +176,17 @@ .login-device-copy > span, .login-device-copy > small { overflow: hidden; color: var(--admin-muted); font-size: 11px; line-height: 1.5; text-overflow: ellipsis; white-space: nowrap; } .login-device-revoke { white-space: nowrap; } -.data-stat-grid { display: grid; gap: 8px; grid-template-columns: repeat(3, 1fr); } -.data-stat { display: flex; min-height: 82px; justify-content: center; flex-direction: column; gap: 8px; border: 1px solid var(--admin-border); border-radius: 7px; padding: 12px; background: color-mix(in srgb, var(--foreground) 2%, transparent); } -.data-stat span { color: var(--admin-muted); font-size: 12px; } -.data-stat strong { font-size: 21px; } +.database-storage { display: flex; min-height: 96px; align-items: center; justify-content: space-between; gap: 18px; border: 1px solid var(--admin-border); border-radius: 7px; padding: 15px 16px; background: var(--admin-bg); } +.database-storage > div { display: flex; min-width: 0; flex-direction: column; gap: 4px; } +.database-storage span, .database-storage small { color: var(--admin-muted); font-size: 12px; } +.database-storage strong { color: var(--admin-text); font-size: 24px; font-variant-numeric: tabular-nums; } +.database-storage button { flex: 0 0 auto; } +.database-migration { display: grid; gap: 12px; border-top: 1px solid var(--admin-border); padding-top: 14px; } +.database-migration .section-title { margin-bottom: 3px; } +.database-migration-form { display: grid; align-items: end; gap: 9px; grid-template-columns: minmax(0, 1fr) auto; } +.database-migration-form label { min-width: 0; } +.database-migration-form input { width: 100%; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; } +.database-migration-form button { min-width: 128px; } .usage-section { display: flex; flex-direction: column; gap: 10px; border-top: 1px solid var(--admin-border); padding-top: 14px; } .usage-head { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; } .usage-head .section-title { margin-bottom: 3px; } @@ -193,8 +200,6 @@ .usage-table tbody th { font-weight: 650; } .usage-table tbody tr:last-child > * { border-bottom: 0; } .usage-empty { display: flex; min-height: 48px; align-items: center; justify-content: center; border: 1px dashed var(--admin-border); border-radius: 7px; color: var(--admin-muted); font-size: 13px; } -.data-actions { border-top: 1px solid var(--admin-border); padding-top: 14px; } - .theme-store-page { display: grid; } .theme-list { border-top: 1px solid var(--admin-border); } .theme-row { display: grid; min-height: 64px; align-items: center; gap: 12px; border-bottom: 1px solid var(--admin-border); padding: 11px 2px; grid-template-columns: minmax(0, 1fr) auto; } @@ -422,6 +427,10 @@ .remote-submit { width: 100%; } .database-section-head .section-actions, .usage-head-actions { width: 100%; } .database-section-head .section-actions > button, .usage-head-actions > button { flex: 1; } + .database-storage { align-items: stretch; flex-direction: column; } + .database-storage button { width: 100%; } + .database-migration-form { grid-template-columns: 1fr; } + .database-migration-form button { width: 100%; } .login-device-row { grid-template-columns: auto minmax(0, 1fr); align-items: start; } .login-device-revoke { grid-column: 2; justify-self: start; } .remote-2fa-required { grid-template-columns: auto minmax(0, 1fr); } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index dacd3c0..c311dcf 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -310,9 +310,16 @@ export interface AlertRuleInput extends Omit { } export interface DatabaseStats { - server_count: number; - online_count: number; - history_rows: number; + kind: "sqlite" | "postgresql"; + size_bytes: number; + reclaimable_bytes: number | null; +} + +export interface DatabaseMigrationResult { + migrated_rows: number; + target_kind: DatabaseStats["kind"]; + size_bytes: number; + restart_required: boolean; } export interface LoginSession { diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 92545a1..29064ef 100644 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -135,6 +135,14 @@ request -H "Authorization: Bearer $admin_token" \ "$MONITOR_BASE_URL/api/admin/themes" | \ jq -e '.themes | any(.builtin == true and .id == "builtin-nodeflare-glass" and .name == "NodeFlare Glass" and .active == true)' >/dev/null +step "database size and reclaim" +request -H "Authorization: Bearer $admin_token" \ + "$MONITOR_BASE_URL/api/admin/database" | \ + jq -e '(.kind == "sqlite" or .kind == "postgresql") and .size_bytes > 0' >/dev/null +request -H "Authorization: Bearer $admin_token" -X POST \ + "$MONITOR_BASE_URL/api/admin/database/reclaim" | \ + jq -e '.database.size_bytes > 0 and .reclaimed_bytes >= 0' >/dev/null + step "database backup and restore" backup_file=$(mktemp /tmp/nodeflare-backup.XXXXXX.zip) request -H "Authorization: Bearer $admin_token" \ @@ -232,4 +240,13 @@ hidden_history_status=$(monitor_curl --silent --output /dev/null --write-out '%{ "$MONITOR_BASE_URL/api/history/$server_id?hours=1") [ "$hidden_history_status" = "404" ] +if [ -n "${MONITOR_MIGRATION_URL:-}" ]; then + step "database migration" + migration_payload=$(jq -nc --arg database_url "$MONITOR_MIGRATION_URL" '{database_url:$database_url}') + request -H "Authorization: Bearer $admin_token" -H 'Content-Type: application/json' \ + --data "$migration_payload" "$MONITOR_BASE_URL/api/admin/database/migrate" | \ + jq -e --arg kind "${MONITOR_MIGRATION_KIND:-postgresql}" \ + '.migrated_rows > 0 and .target_kind == $kind and .size_bytes > 0 and .restart_required == true' >/dev/null +fi + echo "Smoke test passed"