diff --git a/.env.example b/.env.example index f838443..32073d9 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,9 @@ TALLYNOTE_PORT=3000 TALLYNOTE_DATA_DIR=./data TALLYNOTE_TIMEZONE=Asia/Shanghai TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000 +# Optional additional browser Origins for an explicit reverse-proxy alias. +# Keep the primary public origin above and list only trusted HTTPS origins. +# TALLYNOTE_ALLOWED_ORIGINS=https://tally.example.com,https://tally.internal.example TALLYNOTE_TRUST_PROXY=false TALLYNOTE_COOKIE_SECURE=false # Set TALLYNOTE_HOST=0.0.0.0 and the server's real IP Origin for direct diff --git a/README.md b/README.md index 6e6d8b5..7f5367f 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/ra 卸载器会逐项输出停止、禁用和删除进度;每次 systemd/dbus 调用默认最多等待 30 秒,避免终端无限无响应。可通过 `TALLYNOTE_UNINSTALL_SYSTEMCTL_TIMEOUT_SECONDS` 调整超时时间。 -公网反代必须使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。 +公网反代必须使用 HTTPS,并在环境文件中设置真实的 `TALLYNOTE_PUBLIC_ORIGIN=https://...`、`TALLYNOTE_COOKIE_SECURE=true` 和明确的 `TALLYNOTE_TRUST_PROXY` 跳数(不要使用生产值 `true`)。登录和所有写入请求会校验浏览器 `Origin`;反代必须原样转发 `Origin`,且访问地址必须与 `TALLYNOTE_PUBLIC_ORIGIN` 完全一致。若确实需要多个受信任域名,可用 `TALLYNOTE_ALLOWED_ORIGINS=https://a.example.com,https://b.example.com` 显式列出(只写 Origin,不含路径),不要把它设为任意来源。 ### 构建发布包 diff --git a/install.sh b/install.sh index 2cde94b..bbf0632 100755 --- a/install.sh +++ b/install.sh @@ -1077,7 +1077,7 @@ validate_existing_env() { mode_bits=$(stat_mode_bits "$file") (( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入' local key key_count - for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_HOST TALLYNOTE_PORT TALLYNOTE_PUBLIC_ORIGIN TALLYNOTE_ALLOW_INSECURE_HTTP TALLYNOTE_COOKIE_SECURE TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do + for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_HOST TALLYNOTE_PORT TALLYNOTE_PUBLIC_ORIGIN TALLYNOTE_ALLOWED_ORIGINS TALLYNOTE_ALLOW_INSECURE_HTTP TALLYNOTE_COOKIE_SECURE TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do key_count=$(env_key_count "$file" "$key") [[ "$key_count" =~ ^[0-9]+$ && "$key_count" -le 1 ]] || die "环境文件包含重复配置:$key" done @@ -1460,6 +1460,7 @@ main() { elif [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" ]]; then set_env_key TALLYNOTE_PUBLIC_ORIGIN "$INSTALL_PUBLIC_ORIGIN" fi + if [[ -n "${TALLYNOTE_ALLOWED_ORIGINS+x}" ]]; then set_env_key TALLYNOTE_ALLOWED_ORIGINS "$TALLYNOTE_ALLOWED_ORIGINS"; fi if [[ -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]; then set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP"; fi ensure_env_key TALLYNOTE_INSTALL_PREFIX "$PREFIX" ensure_env_key TALLYNOTE_DATA_DIR "$DATA_DIR" diff --git a/migrations/0005_update_progress.sql b/migrations/0005_update_progress.sql new file mode 100644 index 0000000..f51031a --- /dev/null +++ b/migrations/0005_update_progress.sql @@ -0,0 +1,3 @@ +ALTER TABLE update_jobs ADD COLUMN downloaded_bytes INTEGER; +ALTER TABLE update_jobs ADD COLUMN download_started_at INTEGER; +ALTER TABLE update_jobs ADD COLUMN download_speed_bps INTEGER; diff --git a/server/app.ts b/server/app.ts index 398e4e3..d138ec0 100644 --- a/server/app.ts +++ b/server/app.ts @@ -651,7 +651,7 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { app.addHook("onRequest", async (request) => { if (!unsafeMethods.has(request.method) || !request.url.startsWith("/api/")) return; const origin = request.headers.origin; - const allowed = new Set([config.publicOrigin]); + const allowed = new Set(config.allowedOrigins); if (!config.isProduction) { allowed.add("http://127.0.0.1:5173"); allowed.add("http://localhost:5173"); @@ -947,6 +947,8 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { SELECT id, operation, status, version, platform, asset_name AS assetName, size_bytes AS sizeBytes, error_message AS errorMessage, created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt, + downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt, + download_speed_bps AS downloadSpeedBps, requested_at AS applyQueuedAt FROM update_jobs WHERE admin_id=? ORDER BY created_at DESC LIMIT 1 `).get(request.auth!.admin.id) as Record | undefined; @@ -1193,6 +1195,8 @@ export async function buildApp(database: DatabaseContext, config: AppConfig) { SELECT id, operation, status, version, platform, asset_name AS assetName, size_bytes AS sizeBytes, error_message AS errorMessage, created_at AS createdAt, updated_at AS updatedAt, completed_at AS completedAt, + downloaded_bytes AS downloadedBytes, download_started_at AS downloadStartedAt, + download_speed_bps AS downloadSpeedBps, requested_at AS applyQueuedAt FROM update_jobs WHERE id=? AND admin_id=? `).get(id, request.auth!.admin.id) as Record | undefined; diff --git a/server/cli/update.ts b/server/cli/update.ts index 8d06ef2..e03b6e1 100644 --- a/server/cli/update.ts +++ b/server/cli/update.ts @@ -291,7 +291,24 @@ export async function runUpdate(options: UpdateRunOptions): Promise { + const now = Date.now(); + if (!options.sqlite || now - lastProgressWrite < 250) return; + lastProgressWrite = now; + const elapsed = Math.max(1, now - progressStartedAt); + const speedBps = Math.round(downloadedBytes * 1000 / elapsed); + options.sqlite.prepare("UPDATE update_jobs SET downloaded_bytes=?, size_bytes=COALESCE(?, size_bytes), download_started_at=?, download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'").run(downloadedBytes, totalBytes, progressStartedAt, speedBps, now, jobId); + }, + }); + if (options.sqlite) { + const finishedAt = Date.now(); + const elapsed = Math.max(1, finishedAt - progressStartedAt); + options.sqlite.prepare("UPDATE update_jobs SET downloaded_bytes=?, size_bytes=?, download_started_at=?, download_speed_bps=?, updated_at=? WHERE id=? AND status='downloading'").run(downloaded.size, downloaded.size, progressStartedAt, Math.round(downloaded.size * 1000 / elapsed), finishedAt, jobId); + } if (expectedSha256 && downloaded.sha256 !== expectedSha256) throw new Error("更新文件 SHA-256 校验失败"); updateJob(options.sqlite, jobId, { operation, status: "verifying", version: resolved.version, platform: platform.target, releaseUrl: resolved.releaseUrl, assetName: resolved.asset.name, assetUrl: resolved.asset.url, expectedSha256, actualSha256: downloaded.sha256, sizeBytes: downloaded.size, downloadPath: path.basename(archivePath) }); if (!archivePath.endsWith(".tar.gz") && !archivePath.endsWith(".tgz") && !archivePath.endsWith(".tar") && !archivePath.endsWith(".zip")) throw new Error("更新文件格式仅支持 tar.gz、tar 或 zip"); diff --git a/server/config.ts b/server/config.ts index 4440cb9..a7ff47a 100644 --- a/server/config.ts +++ b/server/config.ts @@ -43,6 +43,21 @@ function csvEnv(name: string): string[] { .filter(Boolean); } +function originListEnv(name: string, primary: string): string[] { + const values = [primary, ...csvEnv(name)]; + const origins = new Set(); + for (const value of values) { + try { + const parsed = new URL(value); + if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== "/" && parsed.pathname !== "" || parsed.search || parsed.hash) throw new Error(); + origins.add(parsed.origin); + } catch { + throw new Error(`${name} 必须是逗号分隔的 HTTP(S) Origin(不含路径)`); + } + } + return [...origins]; +} + function updatePublicKeyEnv(): string | undefined { const inline = process.env.TALLYNOTE_UPDATE_PUBLIC_KEY?.trim(); const file = process.env.TALLYNOTE_UPDATE_PUBLIC_KEY_FILE?.trim(); @@ -98,6 +113,7 @@ export function loadConfig() { throw new Error("TALLYNOTE_PUBLIC_ORIGIN 不能使用通配监听地址,请填写服务器 IP 或域名"); } const localOrigin = ["127.0.0.1", "localhost", "::1"].includes(publicHost); + const allowedOrigins = originListEnv("TALLYNOTE_ALLOWED_ORIGINS", parsedOrigin.origin); const appVersion = (() => { try { const packageJson = JSON.parse(readFileSync(path.join(projectRoot, "package.json"), "utf8")) as { version?: unknown }; @@ -127,6 +143,7 @@ export function loadConfig() { host, port, publicOrigin: parsedOrigin.origin, + allowedOrigins, timezone, trustProxy: trustProxyEnv(), cookieSecure, diff --git a/server/db/schema.ts b/server/db/schema.ts index d64751f..d82df10 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -148,6 +148,9 @@ export const updateJobs = sqliteTable("update_jobs", { downloadPath: text("download_path"), backupPath: text("backup_path"), sizeBytes: integer("size_bytes"), + downloadedBytes: integer("downloaded_bytes"), + downloadStartedAt: integer("download_started_at"), + downloadSpeedBps: integer("download_speed_bps"), errorMessage: text("error_message"), createdAt: integer("created_at").notNull(), requestedAt: integer("requested_at"), diff --git a/server/update-service.ts b/server/update-service.ts index 21d298b..87e3473 100644 --- a/server/update-service.ts +++ b/server/update-service.ts @@ -351,6 +351,9 @@ export function publicUpdateJob(row: Record | undefined): Recor platform: row.platform, assetName: row.assetName ?? null, sizeBytes: row.sizeBytes ?? null, + downloadedBytes: row.downloadedBytes ?? null, + downloadStartedAt: row.downloadStartedAt ?? null, + downloadSpeedBps: row.downloadSpeedBps ?? null, // Do not expose filesystem paths, command output, or upstream response // text through the authenticated status endpoint. Detailed diagnostics // remain in the server journal for operators. diff --git a/server/update.ts b/server/update.ts index b8d0f8b..3bc3dc8 100644 --- a/server/update.ts +++ b/server/update.ts @@ -423,7 +423,7 @@ export async function verifySha256(filePath: string, expected: string): Promise< export async function downloadReleaseAsset( url: string | URL, destination: string, - options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined } = {}, + options: UrlPolicy & { fetchImpl?: typeof fetch | undefined; maxBytes?: number | undefined; onProgress?: ((downloadedBytes: number, totalBytes: number | null) => void) | undefined } = {}, ): Promise<{ size: number; sha256: string }> { const fetchImpl = options.fetchImpl ?? fetch; let current = validateHttpsUrl(url, options); @@ -446,6 +446,7 @@ export async function downloadReleaseAsset( } if (response.status < 200 || response.status >= 300 || !response.body) throw new Error("更新文件下载失败"); const declared = Number(response.headers.get("content-length") ?? 0); + const totalBytes = Number.isSafeInteger(declared) && declared > 0 ? declared : null; const maxBytes = options.maxBytes ?? 512 * 1024 * 1024; if (declared > maxBytes) throw new Error("更新文件超过大小限制"); await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 }); @@ -454,6 +455,7 @@ export async function downloadReleaseAsset( const hash = createHash("sha256"); const meter = new Transform({ transform(chunk: Buffer, _encoding, callback) { size += chunk.length; + options.onProgress?.(size, totalBytes); if (size > maxBytes) return callback(new Error("更新文件超过大小限制")); hash.update(chunk); callback(null, chunk); diff --git a/systemd/tallynote.env.example b/systemd/tallynote.env.example index 1cc6d8c..ee64d19 100644 --- a/systemd/tallynote.env.example +++ b/systemd/tallynote.env.example @@ -3,6 +3,7 @@ TALLYNOTE_PORT=3000 TALLYNOTE_DATA_DIR=/var/lib/tallynote TALLYNOTE_INSTALL_PREFIX=/opt/tallynote TALLYNOTE_PUBLIC_ORIGIN=http://127.0.0.1:3000 +TALLYNOTE_ALLOWED_ORIGINS=http://127.0.0.1:3000 TALLYNOTE_COOKIE_SECURE=false TALLYNOTE_ALLOW_INSECURE_HTTP=false TALLYNOTE_TIMEZONE=Asia/Shanghai diff --git a/tests/migration.test.ts b/tests/migration.test.ts index 245b8b9..8c0506e 100644 --- a/tests/migration.test.ts +++ b/tests/migration.test.ts @@ -42,9 +42,10 @@ describe("数据库迁移", () => { { name: "0002_update_jobs.sql" }, { name: "0003_update_job_ownership.sql" }, { name: "0004_update_download_apply.sql" }, + { name: "0005_update_progress.sql" }, ]); const updateColumns = migrated.sqlite.prepare("PRAGMA table_info(update_jobs)").all() as Array<{ name: string }>; - expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at", "operation"])); + expect(updateColumns.map((column) => column.name)).toEqual(expect.arrayContaining(["admin_id", "session_hash", "request_id", "requested_at", "started_at", "operation", "downloaded_bytes", "download_started_at", "download_speed_bps"])); expect(migrated.sqlite.prepare("SELECT note, invoice_missing_reason AS reason FROM expenses WHERE id='00000000-0000-4000-8000-000000000099'").get()).toEqual({ note: "旧账目", reason: null }); migrated.sqlite.close(); migrated = openDatabase(config); diff --git a/tests/security.test.ts b/tests/security.test.ts index 5334648..eadf624 100644 --- a/tests/security.test.ts +++ b/tests/security.test.ts @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { loadConfig, prepareDataDirectories } from "../server/config.js"; -const keys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_HOST", "TALLYNOTE_PORT", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_ALLOW_INSECURE_HTTP", "TALLYNOTE_TRUST_PROXY", "NODE_ENV", "TALLYNOTE_ENV", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY", "TALLYNOTE_UPDATE_PUBLIC_KEY_FILE"]; +const keys = ["TALLYNOTE_DATA_DIR", "TALLYNOTE_HOST", "TALLYNOTE_PORT", "TALLYNOTE_PUBLIC_ORIGIN", "TALLYNOTE_ALLOWED_ORIGINS", "TALLYNOTE_COOKIE_SECURE", "TALLYNOTE_ALLOW_INSECURE_HTTP", "TALLYNOTE_TRUST_PROXY", "NODE_ENV", "TALLYNOTE_ENV", "TALLYNOTE_UPDATE_STRATEGY", "TALLYNOTE_UPDATE_ALLOWED_HOSTS", "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE", "TALLYNOTE_UPDATE_PUBLIC_KEY", "TALLYNOTE_UPDATE_PUBLIC_KEY_FILE"]; afterEach(() => { for (const key of keys) delete process.env[key]; }); @@ -48,6 +48,15 @@ describe("部署安全配置", () => { expect(loadConfig().trustProxy).toBe(1); }); + it("允许显式列出反向代理的多个可信 Origin", () => { + process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://tally.example.test"; + process.env.TALLYNOTE_COOKIE_SECURE = "true"; + process.env.TALLYNOTE_ALLOWED_ORIGINS = "https://tally.example.test, https://tally.internal.test:8443"; + expect(loadConfig().allowedOrigins).toEqual(["https://tally.example.test", "https://tally.internal.test:8443"]); + process.env.TALLYNOTE_ALLOWED_ORIGINS = "https://tally.example.test/app"; + expect(() => loadConfig()).toThrow(/Origin/); + }); + it("systemd 更新必须绑定主机白名单,签名校验默认关闭", () => { process.env.TALLYNOTE_UPDATE_STRATEGY = "systemd"; process.env.TALLYNOTE_PUBLIC_ORIGIN = "https://example.test"; diff --git a/web-next/src/pages/update/UpdatePage.tsx b/web-next/src/pages/update/UpdatePage.tsx index 573616c..0ed33d1 100644 --- a/web-next/src/pages/update/UpdatePage.tsx +++ b/web-next/src/pages/update/UpdatePage.tsx @@ -7,7 +7,7 @@ import { ErrorBanner, Page, Surface } from "../common"; import type { Notify } from "../expenses/types"; type JobStatus = "queued" | "downloading" | "verifying" | "staged" | "backing_up" | "applying" | "completed" | "failed" | "cancelled"; -type UpdateJob = { id: string; operation?: "download" | "apply"; status: JobStatus; version: string; platform: string; assetName?: string | null; sizeBytes?: number | null; errorMessage?: string | null; createdAt?: number; updatedAt?: number; completedAt?: number | null; applyQueuedAt?: number | string | null; restartWindowSeconds?: number | null; restartDeadline?: number | string | null; restartAt?: number | string | null; expectedRecoveryAt?: number | string | null }; +type UpdateJob = { id: string; operation?: "download" | "apply"; status: JobStatus; version: string; platform: string; assetName?: string | null; sizeBytes?: number | null; downloadedBytes?: number | null; downloadStartedAt?: number | null; downloadSpeedBps?: number | null; errorMessage?: string | null; createdAt?: number; updatedAt?: number; completedAt?: number | null; applyQueuedAt?: number | string | null; restartWindowSeconds?: number | null; restartDeadline?: number | string | null; restartAt?: number | string | null; expectedRecoveryAt?: number | string | null }; type LatestRelease = { version: string; tagName?: string; releaseName?: string; publishedAt?: string; compatible: boolean; integrityReady: boolean; signatureReady: boolean; isNewer: boolean; assetName?: string; assetSize?: number; notes?: string | null; releaseNotes?: string | null; body?: string | null; htmlUrl?: string | null }; type UpdateInfo = { configured: boolean; strategy: "disabled" | "systemd"; currentVersion: string; platform: { target: string; os: string; arch: string }; checkedAt: number; latest: LatestRelease | null; job: UpdateJob | null }; const active = new Set(["queued", "downloading", "verifying", "staged", "backing_up", "applying"]); @@ -24,6 +24,16 @@ function notesFor(latest: LatestRelease): string | null { const value = latest.notes ?? latest.releaseNotes ?? latest.body; return typeof value === "string" && value.trim() ? value.trim() : null; } +function bytesText(value: number | null | undefined): string { + if (!Number.isFinite(value) || !value || value < 0) return "0 B"; + if (value >= 1024 * 1024 * 1024) return `${(value / 1024 / 1024 / 1024).toFixed(1)} GB`; + if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)} MB`; + if (value >= 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${Math.round(value)} B`; +} +function speedText(value: number | null | undefined): string { + return value && value > 0 ? `${bytesText(value)}/s` : "计算中"; +} export default function UpdatePage({ timezone = "Asia/Shanghai", notify }: { timezone?: string; notify?: Notify }) { const [info, setInfo] = useState(null); @@ -106,7 +116,9 @@ export default function UpdatePage({ timezone = "Asia/Shanghai", notify }: { tim const sameCompleted = Boolean(job?.status === "completed" && latest && job.version === latest.version); const canDownload = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && !hasActiveJob && !sameCompleted && (!job || job.version !== latest.version || job.status === "failed" || job.status === "cancelled")); const canApply = Boolean(info?.strategy === "systemd" && latest?.isNewer && latest.compatible && latest.integrityReady && job?.operation === "download" && job.status === "staged" && job.version === latest.version); - const progress = job ? ({ queued: 8, downloading: 28, verifying: 48, staged: 65, backing_up: 80, applying: 92 } as Partial>)[job.status] ?? 100 : 0; + const progress = job?.status === "downloading" && job.sizeBytes && job.downloadedBytes !== null && job.downloadedBytes !== undefined + ? Math.min(99, Math.max(8, Math.round(job.downloadedBytes / job.sizeBytes * 100))) + : job ? ({ queued: 8, downloading: 28, verifying: 48, staged: 65, backing_up: 80, applying: 92 } as Partial>)[job.status] ?? 100 : 0; const notes = latest ? notesFor(latest) : null; return void check()} disabled={checking || loading} icon={}>{checking ? "检查中…" : "检查更新"}}> @@ -115,7 +127,7 @@ export default function UpdatePage({ timezone = "Asia/Shanghai", notify }: { tim
当前版本v{info.currentVersion}运行平台:{info.platform.target}更新方式{info.strategy === "systemd" ? "后台一键更新" : "手动命令行更新"}{info.strategy === "systemd" ? (info.configured ? "由 systemd 更新服务执行" : "尚未配置发布源") : "当前安装未启用后台更新"}
{latest ?
最新 Release

{latest.releaseName || latest.tagName || `v${latest.version}`}

{latest.publishedAt && 发布时间:{dateText(Date.parse(latest.publishedAt), timezone)}}
{latest.isNewer ? "有新版本" : "已是最新"}
{notes &&
Release notes
{notes}
}
平台文件{latest.compatible ? latest.assetName : "无匹配文件"}
完整性{latest.integrityReady ? "SHA-256 + 签名可验证" : "不可验证"}
文件大小{latest.assetSize ? `${(latest.assetSize / 1024 / 1024).toFixed(1)} MB` : "-"}
{latest.isNewer && !latest.compatible &&
当前平台没有可安装的 Release 文件。
}{latest.isNewer && latest.compatible && !latest.integrityReady &&
发布文件缺少完整校验,已禁用更新。
}
{canDownload && }{canApply && }{reloadReady && }
:
点击“检查更新”获取最新 Release。
} {!info.configured &&
当前为手动更新模式

源码安装默认不启用后台更新。需要更新时,在服务器拉取对应 Release 后重新构建并重启服务;安装器部署并配置 systemd 后,才会显示后台一键更新。

} - {job && 更新任务状态:{labels[job.status]}
最近任务

v{job.version}

{labels[job.status]}
{active.has(job.status) && <>
{job.status === "staged" && job.operation === "download" ? "更新包已下载并校验,可以立即应用。" : job.status === "staged" && job.operation === "apply" ? "立即更新请求已提交,服务即将重启。" : "更新服务正在后台运行,页面会自动刷新状态。"}{restartSeconds !== null && (job.status === "applying" || disconnected.current) &&
服务正在重启,预计 {restartSeconds} 秒后恢复
}}{job.status === "failed" && job.errorMessage &&
{job.errorMessage}
}{job.status === "completed" &&
新版本已通过健康检查,数据和附件保持不变。
}
} + {job && 更新任务状态:{labels[job.status]}{job.status === "downloading" ? `,已下载 ${progress}%` : ""}
最近任务

v{job.version}

{labels[job.status]}
{active.has(job.status) && <>
{job.status === "downloading" ? 已下载 {bytesText(job.downloadedBytes)} / {bytesText(job.sizeBytes)}({progress}%) · {speedText(job.downloadSpeedBps)} : {job.status === "staged" && job.operation === "download" ? "更新包已下载并校验,可以立即应用。" : job.status === "staged" && job.operation === "apply" ? "立即更新请求已提交,服务即将重启。" : "更新服务正在后台运行,页面会自动刷新状态。"}}{restartSeconds !== null && (job.status === "applying" || disconnected.current) &&
服务正在重启,预计 {restartSeconds} 秒后恢复
}}{job.status === "failed" && job.errorMessage &&
{job.errorMessage}
}{job.status === "completed" &&
新版本已通过健康检查,数据和附件保持不变。
}
} } { if (!actionBusy) setConfirmVersion(null); }} onConfirm={() => void submitAction()} onCancel={() => { if (!actionBusy) setConfirmVersion(null); }}>{confirmAction === "download" ? `将下载并校验 v${confirmVersion},完成后可选择立即更新。` : `将应用已下载的 v${confirmVersion}。服务会短暂重启,更新前会备份数据目录。`}
;