支持数据库迁移后在线重启

This commit is contained in:
imengying committed 2026-09-04 19:02:22 +08:00
1 parent 35b29f4370
commit 1f36a8df46
8 files changed
+117 -12

No files matched your search

+1
View File
@@ -95,6 +95,7 @@ impl Database {
kind: self.kind.as_str().to_string(),
size_bytes,
reclaimable_bytes,
restart_required: false,
})
}
+27 -5
View File
@@ -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<websocket::DashboardEvent>,
pub database_maintenance: Mutex<()>,
pub database_maintenance_active: AtomicBool,
pub database_restart_required: AtomicBool,
pub restart_tx: watch::Sender<bool>,
pub login_attempts: security::AttemptLimiter,
pub remote_totp_attempts: security::AttemptLimiter,
pub password_verifications: Arc<Semaphore>,
@@ -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::<SocketAddr>(),
)
.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<AppState>) {
});
}
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<bool>) {
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");
}
}
}
}
+1
View File
@@ -490,6 +490,7 @@ pub struct DatabaseStats {
pub kind: String,
pub size_bytes: i64,
pub reclaimable_bytes: Option<i64>,
pub restart_required: bool,
}
#[derive(Debug, Clone, Serialize)]
+21 -2
View File
@@ -618,7 +618,9 @@ pub async fn exchange_refresh(State(state): State<Arc<AppState>>) -> Result<Resp
}
pub async fn database_stats(State(state): State<Arc<AppState>>) -> Result<Response, ApiResponse> {
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<Arc<AppState>>) -> Result<Response, ApiResponse> {
@@ -635,7 +637,8 @@ pub async fn database_reclaim(State(state): State<Arc<AppState>>) -> Result<Resp
.reclaim_space()
.await
.map_err(ApiResponse::internal)?;
let database = state.db.stats().await.map_err(ApiResponse::internal)?;
let mut database = state.db.stats().await.map_err(ApiResponse::internal)?;
database.restart_required = state.database_restart_required.load(Ordering::Acquire);
let reclaimed_bytes = before.size_bytes.saturating_sub(database.size_bytes);
Ok(Json(serde_json::json!({
"database": database,
@@ -689,6 +692,9 @@ pub async fn database_migrate(
let database = target.stats().await.map_err(ApiResponse::internal)?;
crate::config::update_database_url(&state.config_path, &target_url)
.map_err(ApiResponse::internal)?;
state
.database_restart_required
.store(true, Ordering::Release);
Ok(Json(serde_json::json!({
"migrated_rows": migrated_rows,
@@ -699,6 +705,19 @@ pub async fn database_migrate(
.into_response())
}
pub async fn database_restart(State(state): State<Arc<AppState>>) -> Result<Response, ApiResponse> {
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<Arc<AppState>>) -> Result<Response, ApiResponse> {
let _maintenance = state.database_maintenance.lock().await;
let archive = crate::backup::export_archive(&state.db)
+5
View File
@@ -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") ?? "";
+53 -5
View File
@@ -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<Settings | null>(null);
const [database, setDatabase] = useState<DatabaseStats | null>(null);
const [databaseMigrationUrl, setDatabaseMigrationUrl] = useState("");
const [databaseMigrationResult, setDatabaseMigrationResult] = useState<DatabaseMigrationResult | null>(null);
const [restarting, setRestarting] = useState(false);
const [exchangeRates, setExchangeRates] = useState<ExchangeRates | null>(null);
const [exchangeRatesExpanded, setExchangeRatesExpanded] = useState(false);
const [themes, setThemes] = useState<Theme[]>([]);
@@ -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" ? <>
<div className="section-head database-section-head"><div><h3>数据库维护</h3><span>备份包含节点、设置、历史、任务及安全配置,请妥善保管。</span></div><div className="section-actions"><button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void exportDatabaseBackup()}><Download size={15} />导出备份</button><button type="button" className="secondary-btn compact" disabled={busy} onClick={() => databaseRestoreInputRef.current?.click()}><Upload size={15} />恢复备份</button><input ref={databaseRestoreInputRef} hidden type="file" accept=".zip,application/zip" onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} /></div></div>
<div className="database-storage"><div><span>数据库大小</span><strong>{database ? formatBytes(database.size_bytes) : "读取中..."}</strong>{database ? <small>{database.kind === "postgresql" ? "PostgreSQL" : "SQLite"}{database.reclaimable_bytes ? ` · 可回收 ${formatBytes(database.reclaimable_bytes)}` : ""}</small> : null}</div><button type="button" className="secondary-btn" disabled={busy || !database} onClick={() => void reclaimDatabase()}><RotateCw size={15} />回收空间</button></div>
<div className="section-head database-section-head"><div><h3>数据库维护</h3><span>备份包含节点、设置、历史、任务及安全配置,请妥善保管。</span></div><div className="section-actions"><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => void exportDatabaseBackup()}><Download size={15} />导出备份</button><button type="button" className="secondary-btn compact" disabled={busy || database?.restart_required} onClick={() => databaseRestoreInputRef.current?.click()}><Upload size={15} />恢复备份</button><input ref={databaseRestoreInputRef} hidden type="file" accept=".zip,application/zip" onChange={(event) => { const file = event.currentTarget.files?.[0]; event.currentTarget.value = ""; if (file) void restoreDatabaseBackup(file); }} /></div></div>
<div className="database-storage"><div><span>数据库大小</span><strong>{database ? formatBytes(database.size_bytes) : "读取中..."}</strong>{database ? <small>{database.kind === "postgresql" ? "PostgreSQL" : "SQLite"}{database.reclaimable_bytes ? ` · 可回收 ${formatBytes(database.reclaimable_bytes)}` : ""}</small> : null}</div><button type="button" className="secondary-btn" disabled={busy || !database || database.restart_required} onClick={() => void reclaimDatabase()}><RotateCw size={15} />回收空间</button></div>
<div className="database-migration">
<div><div className="section-title"><ArrowRightLeft size={15} />数据库迁移</div><p className="settings-hint">将当前 {database?.kind === "postgresql" ? "PostgreSQL" : "SQLite"} 数据复制到 {database?.kind === "postgresql" ? "SQLite" : "PostgreSQL"},完成后自动更新配置,重启服务后生效。</p></div>
<div className="database-migration-form"><label><span>目标数据库 URL</span><input required type="password" autoComplete="off" maxLength={2048} spellCheck={false} value={databaseMigrationUrl} onChange={(event) => setDatabaseMigrationUrl(event.target.value)} placeholder={database?.kind === "postgresql" ? "sqlite://nodeflare-migrated.db" : "postgres://user:password@127.0.0.1:5432/nodeflare?sslmode=prefer"} /></label><button className="primary-btn" disabled={busy || !databaseMigrationUrl.trim()}><ArrowRightLeft size={15} />{busy ? "迁移中" : "开始迁移"}</button></div>
{database?.restart_required ? <div className="database-migration-ready" role="status" aria-live="polite"><CircleCheck size={20} /><div><strong>迁移完成,等待重启</strong><span>{databaseMigrationResult ? `已复制 ${databaseMigrationResult.migrated_rows.toLocaleString()} 行,新数据库大小 ${formatBytes(databaseMigrationResult.size_bytes)}` : "配置已更新,重启后切换到新数据库"}</span></div><button type="button" className="primary-btn" disabled={busy} onClick={() => void restartAfterDatabaseMigration()}>{restarting ? <RotateCw className="spin" size={15} /> : <Power size={15} />}{restarting ? "正在重启" : "立即重启"}</button></div> : <div className="database-migration-form"><label><span>目标数据库 URL</span><input required type="password" autoComplete="off" maxLength={2048} spellCheck={false} value={databaseMigrationUrl} onChange={(event) => setDatabaseMigrationUrl(event.target.value)} placeholder={database?.kind === "postgresql" ? "sqlite://nodeflare-migrated.db" : "postgres://user:password@127.0.0.1:5432/nodeflare?sslmode=prefer"} /></label><button className="primary-btn" disabled={busy || !databaseMigrationUrl.trim()}><ArrowRightLeft size={15} />{busy ? "迁移中" : "开始迁移"}</button></div>}
</div>
<div className="usage-section">
<div className="usage-head"><div><div className="section-title"><Coins size={15} />每日汇率</div><p className="settings-hint">{exchangeRates ? `${exchangeRates.source} · ${exchangeRates.date || "等待首次更新"}${exchangeRates.stale ? " · 数据待更新" : ""}` : "正在读取汇率快照"}</p></div><div className="usage-head-actions">{exchangeRatesExpanded ? <button type="button" className="secondary-btn compact" disabled={busy} onClick={() => void refreshExchangeRates()}><RotateCw size={15} />立即更新</button> : null}<button type="button" className="secondary-btn compact usage-toggle" aria-expanded={exchangeRatesExpanded} onClick={() => setExchangeRatesExpanded((expanded) => !expanded)}>{exchangeRatesExpanded ? <ChevronUp size={15} /> : <ChevronDown size={15} />}{exchangeRatesExpanded ? "收起" : "展开"}</button></div></div>
+8
View File
@@ -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); }
+1
View File
@@ -313,6 +313,7 @@ export interface DatabaseStats {
kind: "sqlite" | "postgresql";
size_bytes: number;
reclaimable_bytes: number | null;
restart_required: boolean;
}
export interface DatabaseMigrationResult {