From 1f36a8df4670fcef1b746bd2777ecf7f271e012a Mon Sep 17 00:00:00 2001 From: imengying Date: Fri, 4 Sep 2026 19:02:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=90=8E=E5=9C=A8=E7=BA=BF=E9=87=8D=E5=90=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db/mod.rs | 1 + backend/src/main.rs | 32 +++++++++++--- backend/src/models.rs | 1 + backend/src/routes/admin.rs | 23 +++++++++- frontend/src/api.ts | 5 +++ frontend/src/components/AdminPanel.tsx | 58 +++++++++++++++++++++++--- frontend/src/styles/admin.css | 8 ++++ frontend/src/types.ts | 1 + 8 files changed, 117 insertions(+), 12 deletions(-) diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 42d4943..b70ea90 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -95,6 +95,7 @@ impl Database { kind: self.kind.as_str().to_string(), size_bytes, reclaimable_bytes, + restart_required: false, }) } diff --git a/backend/src/main.rs b/backend/src/main.rs index 89913c7..d8dd438 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -24,7 +24,7 @@ use std::collections::HashMap; use std::net::SocketAddr; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use tokio::sync::{Mutex, RwLock, Semaphore, broadcast}; +use tokio::sync::{Mutex, RwLock, Semaphore, broadcast, watch}; use tower_http::trace::TraceLayer; pub struct AppState { @@ -37,6 +37,8 @@ pub struct AppState { pub dashboard_tx: broadcast::Sender, pub database_maintenance: Mutex<()>, pub database_maintenance_active: AtomicBool, + pub database_restart_required: AtomicBool, + pub restart_tx: watch::Sender, pub login_attempts: security::AttemptLimiter, pub remote_totp_attempts: security::AttemptLimiter, pub password_verifications: Arc, @@ -162,6 +164,7 @@ async fn main() -> Result<()> { .connect_timeout(std::time::Duration::from_secs(5)) .build()?; let (dashboard_tx, _) = broadcast::channel(2048); + let (restart_tx, restart_rx) = watch::channel(false); let state = Arc::new(AppState { db: database, config, @@ -172,6 +175,8 @@ async fn main() -> Result<()> { dashboard_tx, database_maintenance: Mutex::new(()), database_maintenance_active: AtomicBool::new(false), + database_restart_required: AtomicBool::new(false), + restart_tx, login_attempts: security::AttemptLimiter::new( 5, std::time::Duration::from_secs(5 * 60), @@ -279,6 +284,10 @@ async fn main() -> Result<()> { "/api/admin/database/migrate", post(routes::admin::database_migrate), ) + .route( + "/api/admin/database/restart", + post(routes::admin::database_restart), + ) .route( "/api/admin/database/backup", get(routes::admin::database_backup), @@ -323,8 +332,12 @@ async fn main() -> Result<()> { listener, app.into_make_service_with_connect_info::(), ) - .with_graceful_shutdown(shutdown_signal()) + .with_graceful_shutdown(shutdown_signal(restart_rx.clone())) .await?; + if *restart_rx.borrow() { + tracing::info!("restarting NodeFlare"); + std::process::exit(75); + } Ok(()) } @@ -372,8 +385,17 @@ fn spawn_maintenance(state: Arc) { }); } -async fn shutdown_signal() { - if tokio::signal::ctrl_c().await.is_ok() { - tracing::info!("shutdown signal received"); +async fn shutdown_signal(mut restart_rx: watch::Receiver) { + tokio::select! { + result = tokio::signal::ctrl_c() => { + if result.is_ok() { + tracing::info!("shutdown signal received"); + } + } + result = restart_rx.wait_for(|requested| *requested) => { + if result.is_ok() { + tracing::info!("restart requested"); + } + } } } diff --git a/backend/src/models.rs b/backend/src/models.rs index 14c4632..f586b9f 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -490,6 +490,7 @@ pub struct DatabaseStats { pub kind: String, pub size_bytes: i64, pub reclaimable_bytes: Option, + pub restart_required: bool, } #[derive(Debug, Clone, Serialize)] diff --git a/backend/src/routes/admin.rs b/backend/src/routes/admin.rs index ed21bb4..726a7ca 100644 --- a/backend/src/routes/admin.rs +++ b/backend/src/routes/admin.rs @@ -618,7 +618,9 @@ pub async fn exchange_refresh(State(state): State>) -> Result>) -> Result { - Ok(Json(state.db.stats().await.map_err(ApiResponse::internal)?).into_response()) + let mut database = state.db.stats().await.map_err(ApiResponse::internal)?; + database.restart_required = state.database_restart_required.load(Ordering::Acquire); + Ok(Json(database).into_response()) } pub async fn database_reclaim(State(state): State>) -> Result { @@ -635,7 +637,8 @@ pub async fn database_reclaim(State(state): State>) -> Result>) -> Result { + if !state.database_restart_required.load(Ordering::Acquire) { + return Err(ApiResponse::bad_request("当前没有等待生效的数据库迁移")); + } + state.disconnect_agents().await; + state.restart_tx.send(true).map_err(ApiResponse::internal)?; + Ok(( + StatusCode::ACCEPTED, + Json(serde_json::json!({"restarting": true})), + ) + .into_response()) +} + pub async fn database_backup(State(state): State>) -> Result { let _maintenance = state.database_maintenance.lock().await; let archive = crate::backup::export_archive(&state.db) diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 405a908..24b3866 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -155,6 +155,11 @@ export const api = { { method: "POST", body: JSON.stringify({ database_url: databaseUrl }) }, true, ), + restartAfterDatabaseMigration: () => request<{ restarting: boolean }>( + "/api/admin/database/restart", + { method: "POST" }, + true, + ), databaseBackup: async () => { const response = await requestResponse("/api/admin/database/backup", {}, true); const disposition = response.headers.get("Content-Disposition") ?? ""; diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 511c6fc..18b3f32 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -20,6 +20,7 @@ import { Palette, Pencil, Plus, + Power, RadioTower, RotateCw, Save, @@ -40,7 +41,7 @@ import { adminTabFromPath, adminTabPaths, canonicalAdminPath, type AdminTab } fr 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"; +import { ASSET_CURRENCIES, type AdminServer, type Config, type DatabaseMigrationResult, 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"; import { Checkbox } from "./Checkbox"; import { TurnstileWidget } from "./TurnstileWidget"; import { useDialog } from "./useDialog"; @@ -228,6 +229,26 @@ async function copyText(value: string) { if (!copied) throw new Error("copy failed"); } +async function waitForDatabaseSwitch(targetKind: DatabaseStats["kind"], token: string) { + const deadline = Date.now() + 120_000; + while (Date.now() < deadline) { + await new Promise((resolve) => window.setTimeout(resolve, 750)); + try { + const response = await fetch(`/api/admin/database?restart=${Date.now()}`, { + cache: "no-store", + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + signal: AbortSignal.timeout(2_500), + }); + if (response.status === 401) return; + if (response.ok) { + const database = await response.json() as DatabaseStats; + if (database.kind === targetKind && !database.restart_required) return; + } + } catch {} + } + throw new Error("NodeFlare 未自动恢复,请在主机上检查服务状态"); +} + export function AdminPanel({ config, dark, @@ -256,6 +277,8 @@ export function AdminPanel({ const [settings, setSettings] = useState(null); const [database, setDatabase] = useState(null); const [databaseMigrationUrl, setDatabaseMigrationUrl] = useState(""); + const [databaseMigrationResult, setDatabaseMigrationResult] = useState(null); + const [restarting, setRestarting] = useState(false); const [exchangeRates, setExchangeRates] = useState(null); const [exchangeRatesExpanded, setExchangeRatesExpanded] = useState(false); const [themes, setThemes] = useState([]); @@ -582,6 +605,7 @@ export function AdminPanel({ try { const [stats, rates] = await Promise.all([api.databaseStats(), api.exchangeRates()]); setDatabase(stats); + if (!stats.restart_required) setDatabaseMigrationResult(null); setExchangeRates(rates); } catch (reason) { setError(reason instanceof Error ? reason.message : "读取数据库统计失败"); } @@ -609,12 +633,36 @@ export function AdminPanel({ try { const result = await api.migrateDatabase(databaseUrl); setDatabaseMigrationUrl(""); + setDatabaseMigrationResult(result); + setDatabase((current) => current ? { ...current, restart_required: result.restart_required } : current); const targetName = result.target_kind === "postgresql" ? "PostgreSQL" : "SQLite"; - window.alert(`数据库已迁移到 ${targetName},共 ${result.migrated_rows.toLocaleString()} 行(${formatBytes(result.size_bytes)}),并已更新配置。请重启 NodeFlare 后重新登录。`); + setNotice(`数据库已迁移到 ${targetName},共 ${result.migrated_rows.toLocaleString()} 行(${formatBytes(result.size_bytes)})`); } catch (reason) { setError(reason instanceof Error ? reason.message : "数据库迁移失败"); } finally { setBusy(false); } } + async function restartAfterDatabaseMigration() { + if (!database?.restart_required || !window.confirm("立即重启 NodeFlare 并切换到新数据库?管理界面和 Agent 会短暂断开。")) return; + const targetKind = databaseMigrationResult?.target_kind === "postgresql" + ? "postgresql" + : databaseMigrationResult?.target_kind === "sqlite" + ? "sqlite" + : database.kind === "postgresql" ? "sqlite" : "postgresql"; + const token = getToken(); + setBusy(true); setRestarting(true); setError(""); setNotice("NodeFlare 正在重启"); + try { + await api.restartAfterDatabaseMigration(); + await waitForDatabaseSwitch(targetKind, token); + setToken(""); + window.location.replace("/admin/login"); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "重启 NodeFlare 失败"); + } finally { + setBusy(false); + setRestarting(false); + } + } + async function refreshExchangeRates() { setBusy(true); setError(""); setNotice(""); try { @@ -1149,11 +1197,11 @@ export function AdminPanel({ : null} {tab === "data" ? <> -

数据库维护

备份包含节点、设置、历史、任务及安全配置,请妥善保管。
{ const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} />
-
数据库大小{database ? formatBytes(database.size_bytes) : "读取中..."}{database ? {database.kind === "postgresql" ? "PostgreSQL" : "SQLite"}{database.reclaimable_bytes ? ` · 可回收 ${formatBytes(database.reclaimable_bytes)}` : ""} : null}
+

数据库维护

备份包含节点、设置、历史、任务及安全配置,请妥善保管。
{ const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} />
+
数据库大小{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"},完成后自动更新配置,重启服务后生效。

-
+ {database?.restart_required ?
迁移完成,等待重启{databaseMigrationResult ? `已复制 ${databaseMigrationResult.migrated_rows.toLocaleString()} 行,新数据库大小 ${formatBytes(databaseMigrationResult.size_bytes)}` : "配置已更新,重启后切换到新数据库"}
:
}
每日汇率

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

{exchangeRatesExpanded ? : null}
diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index 9971d12..164568f 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -187,6 +187,12 @@ .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; } +.database-migration-ready { display: grid; align-items: center; gap: 11px; border: 1px solid color-mix(in srgb, var(--admin-success) 34%, var(--admin-border)); border-radius: 7px; padding: 12px; background: var(--admin-success-bg); grid-template-columns: auto minmax(0, 1fr) auto; } +.database-migration-ready > svg { color: var(--admin-success); } +.database-migration-ready > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; } +.database-migration-ready strong { color: var(--admin-text); font-size: 13px; } +.database-migration-ready span { color: var(--admin-muted); font-size: 12px; } +.database-migration-ready .spin { animation: remote-refresh-spin 1.2s linear infinite; } .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; } @@ -431,6 +437,8 @@ .database-storage button { width: 100%; } .database-migration-form { grid-template-columns: 1fr; } .database-migration-form button { width: 100%; } + .database-migration-ready { grid-template-columns: auto minmax(0, 1fr); } + .database-migration-ready .primary-btn { grid-column: 1 / -1; 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 c311dcf..0e0b560 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -313,6 +313,7 @@ export interface DatabaseStats { kind: "sqlite" | "postgresql"; size_bytes: number; reclaimable_bytes: number | null; + restart_required: boolean; } export interface DatabaseMigrationResult {