diff --git a/README.md b/README.md index e0d98ea..eec1ca0 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ nodeflare.example.com { 只有 `trusted_proxies` 中列出的代理写入的 `X-Forwarded-For` 会被信任,代理不在本机时将其 IP 或 CIDR 加入 `config.toml`。 -更新:同一数据库基线内重新运行安装脚本即可,已安装的配置和数据会保留;也可直接执行 `install.sh --install`(Windows 为 `install.ps1 -Install`)。从旧迁移 `01–04` 切换到本版前,必须先按[数据库基线切换](#数据库基线切换)导出备份并准备全新数据库。卸载默认保留配置和数据,追加 `--purge` 一并删除: +更新:重新运行安装脚本即可,已安装的配置和数据会保留;也可直接执行 `install.sh --install`(Windows 为 `install.ps1 -Install`)。卸载默认保留配置和数据,追加 `--purge` 一并删除: ```bash # Linux / macOS @@ -178,18 +178,7 @@ database_url = "postgres://nodeflare:password@127.0.0.1:5432/nodeflare?sslmode=d - 历史指标默认保留 30 天,过期数据自动清理;完整空间回收在后台手动触发。 - 升级建议先更新服务端再更新 Agent;恢复备份建议使用相同版本。 -### 数据库基线切换 - -本版按新项目维护,SQLite / PostgreSQL 均只有一个包含完整结构的 `01_initial.sql`。Agent 协议为 `2`,不再接受旧协议或缺少 `persist` 标志的上报,服务端与 Agent 必须配套更新。 - -从已经执行旧迁移 `01–04` 的版本切换时: - -1. **更新前**在原版本后台导出 ZIP 备份,并保留原程序、配置和数据库以便回退。支持该版本导出的完整表结构备份(格式版本 `2`),不支持更早缺少历史聚合字段的备份。 -2. 停止服务端,将 `database_url` 指向全新的 SQLite 文件或空 PostgreSQL 数据库。不要覆盖原库、清空迁移记录,或把旧 `.db` / SQL 转储直接作为新库使用。 -3. 安装本版,用临时管理员账号初始化新库;原配置中的管理员密码已被清空时,需要重新设置 `admin_username`、`admin_password`。登录新后台后恢复刚导出的 ZIP。 -4. 恢复会覆盖临时账号和新库业务数据,并退出登录。使用备份中的管理员账号、密码和两步验证重新登录,再更新各节点 Agent;使用额外安装 Token 的节点还需重新安装以更换 Token。 - -ZIP 恢复只导入业务数据,不导入 `_sqlx_migrations`,新库仍保留单一 `01` 基线。现版本备份中未填充的历史统计字段会在恢复事务内补齐;设置、节点、历史记录、主题文件和安全配置会保留。全部验证正常前不要删除原库。 +SQLite / PostgreSQL 均使用一份 `01_initial.sql`,首次启动时自动创建完整数据库结构。ZIP 恢复校验表结构并导入业务数据,保留目标库的初始化记录。服务端与 Agent 须配套使用,当前 Agent 协议为 `2`。 ## 从源码开发 diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 85a9a4e..df39c52 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -113,7 +113,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", - "axum-macros", "base64", "bytes", "form_urlencoded", @@ -162,17 +161,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "axum-macros" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "base64" version = "0.22.1" @@ -446,9 +434,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "serde_core", -] [[package]] name = "digest" @@ -1951,7 +1936,6 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx-core", - "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", "syn 2.0.119", @@ -2169,7 +2153,6 @@ dependencies = [ "powerfmt", "serde_core", "time-core", - "time-macros", ] [[package]] @@ -2178,16 +2161,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.4" @@ -2222,7 +2195,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 8b61b89..e9dd5d7 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -11,18 +11,18 @@ name = "nodeflare" path = "src/main.rs" [dependencies] -axum = { version = "0.8.9", features = ["ws", "macros"] } -tokio = { version = "1", features = ["full"] } +axum = { version = "0.8.9", features = ["ws"] } +tokio = { version = "1", features = ["fs", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tokio-util = { version = "0.7", features = ["io"] } tower-http = { version = "0.7.1", features = ["trace"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring", "any", "sqlite-bundled", "postgres", "migrate", "macros", "json"] } +sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring", "any", "sqlite-bundled", "postgres", "migrate", "macros"] } toml = "1.1.5" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } futures-util = "0.3" -time = { version = "0.3", features = ["serde", "formatting", "parsing"] } +time = "0.3" sha2 = "0.11" pbkdf2 = "0.13" totp-lite = "2" diff --git a/backend/migrations/postgres/01_initial.sql b/backend/migrations/postgres/01_initial.sql index eb4691c..35cf6fe 100644 --- a/backend/migrations/postgres/01_initial.sql +++ b/backend/migrations/postgres/01_initial.sql @@ -99,20 +99,10 @@ CREATE INDEX metric_history_time ON metric_history(timestamp); CREATE TABLE server_latest_state ( server_id TEXT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE, latest_timestamp BIGINT NOT NULL, - cpu DOUBLE PRECISION NOT NULL DEFAULT 0, - mem_used BIGINT NOT NULL DEFAULT 0, - mem_total BIGINT NOT NULL DEFAULT 0, - disk_used BIGINT NOT NULL DEFAULT 0, - disk_total BIGINT NOT NULL DEFAULT 0, - net_in DOUBLE PRECISION NOT NULL DEFAULT 0, - net_out DOUBLE PRECISION NOT NULL DEFAULT 0, - uptime BIGINT NOT NULL DEFAULT 0, latest_json TEXT NOT NULL DEFAULT '{}', last_batch_id TEXT NOT NULL DEFAULT '' ); -CREATE INDEX server_latest_state_time ON server_latest_state(latest_timestamp); - CREATE TABLE admin_2fa ( username TEXT PRIMARY KEY, totp_secret TEXT NOT NULL, @@ -133,8 +123,7 @@ CREATE TABLE remote_tasks ( exit_code BIGINT ); -CREATE INDEX remote_tasks_server_status_time -ON remote_tasks(server_id, status, requested_at); +CREATE INDEX remote_tasks_server ON remote_tasks(server_id); CREATE INDEX remote_tasks_status_completed ON remote_tasks(status, completed_at); diff --git a/backend/migrations/sqlite/01_initial.sql b/backend/migrations/sqlite/01_initial.sql index e28ebdd..00b85d7 100644 --- a/backend/migrations/sqlite/01_initial.sql +++ b/backend/migrations/sqlite/01_initial.sql @@ -102,20 +102,10 @@ CREATE INDEX metric_history_time ON metric_history(timestamp); CREATE TABLE server_latest_state ( server_id TEXT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE, latest_timestamp INTEGER NOT NULL, - cpu REAL NOT NULL DEFAULT 0, - mem_used INTEGER NOT NULL DEFAULT 0, - mem_total INTEGER NOT NULL DEFAULT 0, - disk_used INTEGER NOT NULL DEFAULT 0, - disk_total INTEGER NOT NULL DEFAULT 0, - net_in REAL NOT NULL DEFAULT 0, - net_out REAL NOT NULL DEFAULT 0, - uptime INTEGER NOT NULL DEFAULT 0, latest_json TEXT NOT NULL DEFAULT '{}', last_batch_id TEXT NOT NULL DEFAULT '' ) WITHOUT ROWID; -CREATE INDEX server_latest_state_time ON server_latest_state(latest_timestamp); - CREATE TABLE admin_2fa ( username TEXT PRIMARY KEY, totp_secret TEXT NOT NULL, @@ -136,8 +126,7 @@ CREATE TABLE remote_tasks ( exit_code INTEGER ) WITHOUT ROWID; -CREATE INDEX remote_tasks_server_status_time -ON remote_tasks(server_id, status, requested_at); +CREATE INDEX remote_tasks_server ON remote_tasks(server_id); CREATE INDEX remote_tasks_status_completed ON remote_tasks(status, completed_at); diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 91dc969..c062c0d 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -201,7 +201,7 @@ impl Database { MigrateError::VersionMissing(_) | MigrateError::VersionMismatch(_) ) { return Err(anyhow::Error::new(error).context( - "数据库迁移基线不匹配;请保留原数据库,使用原版本导出完整结构的 ZIP 备份,配置全新数据库后再恢复;不要删除或修改 _sqlx_migrations", + "数据库结构与当前版本不匹配;请使用匹配版本的程序,或配置全新数据库", )); } return Err(error.into()); diff --git a/backend/src/db/queries.rs b/backend/src/db/queries.rs index 6feb790..ee91cfa 100644 --- a/backend/src/db/queries.rs +++ b/backend/src/db/queries.rs @@ -717,25 +717,13 @@ pub async fn save_agent_batch( let persisted_through = latest.timestamp; let latest_json = serde_json::to_string(latest)?; sqlx::query(db.sql( - "INSERT INTO server_latest_state(server_id, latest_timestamp, cpu, mem_used, mem_total, \ - disk_used, disk_total, net_in, net_out, uptime, latest_json, last_batch_id) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ + "INSERT INTO server_latest_state(server_id, latest_timestamp, latest_json, last_batch_id) \ + VALUES (?, ?, ?, ?) \ ON CONFLICT(server_id) DO UPDATE SET latest_timestamp=excluded.latest_timestamp, \ - cpu=excluded.cpu, mem_used=excluded.mem_used, mem_total=excluded.mem_total, \ - disk_used=excluded.disk_used, disk_total=excluded.disk_total, net_in=excluded.net_in, \ - net_out=excluded.net_out, uptime=excluded.uptime, latest_json=excluded.latest_json, \ - last_batch_id=excluded.last_batch_id", + latest_json=excluded.latest_json, last_batch_id=excluded.last_batch_id", )) .bind(&identity.server_id) .bind(latest.timestamp) - .bind(latest.cpu) - .bind(latest.mem_used) - .bind(latest.mem_total) - .bind(latest.disk_used) - .bind(latest.disk_total) - .bind(latest.net_in) - .bind(latest.net_out) - .bind(latest.uptime) .bind(latest_json) .bind(batch_id) .execute(&mut *transaction) @@ -3155,7 +3143,7 @@ mod tests { "SELECT name FROM sqlite_master WHERE type='index' AND name IN (\ 'metric_history_time',\ 'latency_results_time',\ - 'remote_tasks_server_status_time',\ + 'remote_tasks_server',\ 'servers_public_sort')", ) .fetch_all(db.pool()) diff --git a/frontend/src/install.test.ts b/frontend/src/install.test.ts index 4568711..226bb59 100644 --- a/frontend/src/install.test.ts +++ b/frontend/src/install.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { spawnSync } from "node:child_process"; const installer = readFileSync(new URL("../../install.sh", import.meta.url), "utf8"); @@ -294,21 +294,30 @@ describe("agent installer service safety", () => { test("a failed stop of an existing service prevents replacement", () => { const stop = agentInstaller.slice(agentInstaller.indexOf(" rollback_agent=true\n"), agentInstaller.indexOf(' mv "$temporary" "$AGENT_FILE"')); expect(stop).toContain("systemctl stop"); - for (const init of ["systemd", "openrc"]) { - const result = spawnSync("sh", ["-c", ` - set -eu - ${agentShellFunctions("fail")} - init_system=$TEST_INIT - had_service=true - SERVICE_NAME=nodeflare-agent - systemctl() { return 1; } - rc-service() { return 1; } - ${stop} - printf 'replaced' - `], { env: { ...process.env, TEST_INIT: init }, encoding: "utf8" }); - expect(result.status).toBe(1); - expect(result.stdout).toBe(""); - expect(result.stderr).toContain("未替换程序"); + const directory = mkdtempSync(join(tmpdir(), "nodeflare-agent-stop-")); + const log = join(directory, "stop.log"); + try { + // Dash rejects hyphenated function names, so mock the external commands. + for (const command of ["systemctl", "rc-service"]) { + writeFileSync(join(directory, command), '#!/bin/sh\nprintf \'%s\\n\' "$*" > "$TEST_STOP_LOG"\nexit 1\n', { mode: 0o755 }); + } + for (const init of ["systemd", "openrc"]) { + const result = spawnSync("sh", ["-c", ` + set -eu + ${agentShellFunctions("fail")} + init_system=$TEST_INIT + had_service=true + SERVICE_NAME=nodeflare-agent + ${stop} + printf 'replaced' + `], { env: { ...process.env, PATH: `${directory}${delimiter}${process.env.PATH ?? ""}`, TEST_INIT: init, TEST_STOP_LOG: log }, encoding: "utf8" }); + expect(result.status).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("未替换程序"); + expect(readFileSync(log, "utf8")).toBe(init === "systemd" ? "stop nodeflare-agent\n" : "nodeflare-agent stop\n"); + } + } finally { + rmSync(directory, { recursive: true, force: true }); } }); diff --git a/frontend/src/styles/admin.css b/frontend/src/styles/admin.css index dad610c..6167892 100644 --- a/frontend/src/styles/admin.css +++ b/frontend/src/styles/admin.css @@ -115,7 +115,6 @@ .section-head span { color: var(--admin-muted); font-size: 12px; } .row-actions { display: flex; align-items: center; gap: 3px; } .section-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; } -.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; margin: -1px; clip: rect(0, 0, 0, 0); white-space: nowrap; } .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; } @@ -409,8 +408,6 @@ .task-status.success { background: var(--admin-success-bg); color: var(--admin-success); } .task-status.failed { background: var(--admin-danger-bg); color: var(--admin-danger); } .task-time { font-size: 12px; color: var(--admin-muted); margin-left: auto; } -.task-command { margin: 8px 0; } -.task-command strong { color: var(--admin-text); font-size: 13px; font-family: ui-monospace, monospace; } .remote-auto-refresh { display: flex; align-items: center; gap: 5px; margin-top: 5px; } .remote-auto-refresh svg { animation: remote-refresh-spin 1.2s linear infinite; } .remote-2fa-required { display: grid; align-items: center; gap: 10px; border: 1px solid color-mix(in srgb, var(--admin-warning) 35%, var(--admin-border)); border-radius: 7px; padding: 12px; background: color-mix(in srgb, var(--admin-warning) 7%, var(--admin-surface)); grid-template-columns: auto minmax(0, 1fr) auto; } @@ -421,9 +418,6 @@ .task-progress { margin: 9px 0 0; color: var(--admin-muted); font-size: 12px; } .task-result { overflow-x: auto; margin: 10px 0 0; border: 1px solid var(--admin-border); border-radius: 6px; padding: 12px; background: var(--admin-bg); font-size: 12px; } .task-result.failed { border-color: color-mix(in srgb, var(--admin-danger) 38%, var(--admin-border)); } -.task-item details { margin-top: 10px; } -.task-item summary { cursor: pointer; color: var(--admin-accent); font-size: 13px; font-weight: 500; } -.task-item summary:hover { text-decoration: underline; } @keyframes remote-refresh-spin { to { transform: rotate(360deg); } } @media (max-width: 639px) { diff --git a/frontend/src/styles/base.css b/frontend/src/styles/base.css index dbb1b02..9ef2ef0 100644 --- a/frontend/src/styles/base.css +++ b/frontend/src/styles/base.css @@ -3,7 +3,6 @@ --foreground: #11191f; --card-solid: #f8fafb; --card: rgb(248 250 251 / 0.78); - --card-hover: rgb(255 255 255 / 0.92); --node-card-bg: rgb(241 245 249 / 0.74); --node-card-hover: rgb(248 250 252 / 0.8); --node-card-border: rgb(226 232 240 / 0.68); @@ -24,7 +23,6 @@ --info: #2563b9; --chart-grid: rgb(83 104 116 / 0.15); --shadow: 0 10px 32px rgb(31 48 58 / 0.09), inset 0 1px 0 rgb(255 255 255 / 0.58); - --node-offline-bg: rgb(255 255 255 / 0.2); /* Card geometry lives here so the grid and the latency panels cannot drift apart and make rows jump as data arrives. */ --node-card-min-height: 320px; @@ -43,7 +41,6 @@ --foreground: #f3f6f5; --card-solid: #161d1e; --card: rgb(22 29 30 / 0.86); - --card-hover: rgb(28 36 37 / 0.94); --node-card-bg: rgb(22 29 30 / 0.86); --node-card-hover: rgb(26 34 35 / 0.93); --node-card-border: rgb(255 255 255 / 0.16); @@ -63,7 +60,6 @@ --info: #69a7e8; --chart-grid: rgb(218 234 230 / 0.14); --shadow: 0 12px 36px rgb(0 0 0 / 0.28), inset 0 1px 0 rgb(255 255 255 / 0.035); - --node-offline-bg: rgb(0 0 0 / 0.2); } * { box-sizing: border-box; scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--foreground) 18%, transparent) transparent; }