支持数据库迁移后在线重启
This commit is contained in:
1 parent
35b29f4370
commit
1f36a8df46
8 files changed
+117
-12
No files matched your search
@@ -95,6 +95,7 @@ impl Database {
|
||||
kind: self.kind.as_str().to_string(),
|
||||
size_bytes,
|
||||
reclaimable_bytes,
|
||||
restart_required: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+27
-5
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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") ?? "";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -313,6 +313,7 @@ export interface DatabaseStats {
|
||||
kind: "sqlite" | "postgresql";
|
||||
size_bytes: number;
|
||||
reclaimable_bytes: number | null;
|
||||
restart_required: boolean;
|
||||
}
|
||||
|
||||
export interface DatabaseMigrationResult {
|
||||
|
||||
Reference in new issue
Block a user