From 9eb33df6d4b0da6d582725245f3966fe80fd99bc Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 30 Mar 2026 22:37:24 +0800 Subject: [PATCH] [codex] add automatic update-center reminders (#333) * add automatic update-center reminders * fix update-center reminder review issues --- docs/configuration.md | 9 + docs/k3s-update-center.md | 3 +- src/server/index.ts | 6 +- src/server/routes/api/updateCenter.test.ts | 9 + src/server/routes/api/updateCenter.ts | 84 +-------- .../updateCenterPollingService.test.ts | 164 ++++++++++++++++++ .../services/updateCenterPollingService.ts | 104 +++++++++++ .../services/updateCenterReminderService.ts | 6 + .../updateCenterRuntimeStateService.test.ts | 81 +++++++++ .../updateCenterRuntimeStateService.ts | 72 ++++++++ .../services/updateCenterStatusService.ts | 98 +++++++++++ .../services/updateCenterTaskConstants.ts | 2 + src/server/shared/updateCenterReminder.ts | 153 ++++++++++++++++ src/shared/updateCenterReminder.test.ts | 123 +++++++++++++ src/shared/updateCenterReminder.ts | 12 ++ src/web/pages/helpers/navigationFocus.test.ts | 6 + src/web/pages/helpers/navigationFocus.ts | 3 + .../helpers/updateCenterPresentation.test.ts | 40 +++++ .../pages/helpers/updateCenterPresentation.ts | 145 ++++++---------- .../settings/UpdateCenterSection.test.tsx | 35 ++++ .../pages/settings/UpdateCenterSection.tsx | 30 +++- 21 files changed, 1009 insertions(+), 176 deletions(-) create mode 100644 src/server/services/updateCenterPollingService.test.ts create mode 100644 src/server/services/updateCenterPollingService.ts create mode 100644 src/server/services/updateCenterReminderService.ts create mode 100644 src/server/services/updateCenterRuntimeStateService.test.ts create mode 100644 src/server/services/updateCenterRuntimeStateService.ts create mode 100644 src/server/services/updateCenterStatusService.ts create mode 100644 src/server/services/updateCenterTaskConstants.ts create mode 100644 src/server/shared/updateCenterReminder.ts create mode 100644 src/shared/updateCenterReminder.test.ts create mode 100644 src/shared/updateCenterReminder.ts diff --git a/docs/configuration.md b/docs/configuration.md index 7c94531..faa0362 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -265,6 +265,15 @@ Metapi 的路由引擎按多因子加权选择最优通道。 - 当前支持的上游公告来源包括 `new-api`、`done-hub` 与 `sub2api` - 「清空公告」只删除 Metapi 本地保存的公告记录,不会修改上游站点数据 +## 更新提醒 + +更新中心现在会在后台定时检查 GitHub Releases / Docker Hub 的可部署候选,并把结果保存为本地运行时状态。 + +- 首次发现新的版本候选或新的 Docker digest 时,会写入站内通知,并按现有通知渠道外发一次 +- 相同候选后续重复检查只更新本地运行时状态,不会重复外发同一条提醒 +- 这类提醒不会自动触发部署,只是把用户带到「设置 → 更新中心」继续手动确认和执行 +- K3s 用户可以在收到提醒后直接去更新中心部署;Compose 用户也可以收到提醒,但仍按自己的升级方式处理 + ## 下一步 - [部署指南](./deployment.md) — Docker Compose 与反向代理 diff --git a/docs/k3s-update-center.md b/docs/k3s-update-center.md index bd64d86..6287334 100644 --- a/docs/k3s-update-center.md +++ b/docs/k3s-update-center.md @@ -95,6 +95,7 @@ docker compose up -d - 在后台里看当前运行版本 - 看官方 GitHub Releases / Docker Hub 有没有新的稳定版 +- 在后台定时检查新版本,并在首次发现时提醒一次 - 确认 Deploy Helper 是否健康 - 点一次按钮,触发一轮 Helm 升级 - 在页面里回看部署日志 @@ -276,7 +277,7 @@ helper 端使用: 推荐这样用: 1. 先保存配置 -2. 再点“检查更新” +2. 等后台自动发现新版本,或手动点一次“检查更新”立即刷新 3. 确认页面状态都正常: - 当前运行版本正常显示 - 版本来源发现了可部署版本 diff --git a/src/server/index.ts b/src/server/index.ts index 6a2d259..af9bf9a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -33,7 +33,8 @@ import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js'; import { ensureOauthIdentityBackfill } from './services/oauth/oauthIdentityBackfill.js'; import { ensureOauthProviderSitesExist } from './services/oauth/oauthSiteRegistry.js'; import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.js'; -import { startSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js'; +import { startSiteAnnouncementPolling, stopSiteAnnouncementPolling } from './services/siteAnnouncementPollingService.js'; +import { startUpdateCenterPolling, stopUpdateCenterPolling } from './services/updateCenterPollingService.js'; import { reloadBackupWebdavScheduler } from './services/backupService.js'; import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js'; import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js'; @@ -483,6 +484,7 @@ if (existsSync(webDir)) { await startScheduler(); await reloadBackupWebdavScheduler(); startSiteAnnouncementPolling(); +startUpdateCenterPolling(); try { await startOAuthLoopbackCallbackServers(); } catch (error) { @@ -491,6 +493,8 @@ try { setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured); startProxyFileRetentionService(); app.addHook('onClose', async () => { + stopSiteAnnouncementPolling(); + stopUpdateCenterPolling(); stopProxyFileRetentionService(); stopProxyLogRetentionService(); await stopOAuthLoopbackCallbackServers(); diff --git a/src/server/routes/api/updateCenter.test.ts b/src/server/routes/api/updateCenter.test.ts index c0237b8..ca76125 100644 --- a/src/server/routes/api/updateCenter.test.ts +++ b/src/server/routes/api/updateCenter.test.ts @@ -200,6 +200,15 @@ describe('update center routes', () => { }, ], }, + runtime: { + lastCheckedAt: null, + lastCheckError: null, + lastResolvedSource: null, + lastResolvedDisplayVersion: null, + lastResolvedCandidateKey: null, + lastNotifiedCandidateKey: null, + lastNotifiedAt: null, + }, }); }); diff --git a/src/server/routes/api/updateCenter.ts b/src/server/routes/api/updateCenter.ts index 27ca74d..4e9912c 100644 --- a/src/server/routes/api/updateCenter.ts +++ b/src/server/routes/api/updateCenter.ts @@ -4,17 +4,10 @@ import { config as runtimeConfig } from '../../config.js'; import { appendBackgroundTaskLog, getBackgroundTask, - listBackgroundTasks, startBackgroundTask, subscribeToBackgroundTaskLogs, } from '../../services/backgroundTaskService.js'; -import { - fetchLatestDockerHubTag, - fetchLatestStableGitHubRelease, - getCurrentRuntimeVersion, - type UpdateCenterVersionCandidate, - type UpdateCenterVersionSource, -} from '../../services/updateCenterVersionService.js'; +import { type UpdateCenterVersionSource } from '../../services/updateCenterVersionService.js'; import { getUpdateCenterDeployBlockMessage, normalizeUpdateCenterTargetDigest, @@ -27,10 +20,14 @@ import { type UpdateCenterConfig, } from '../../services/updateCenterConfigService.js'; import { - getUpdateCenterHelperStatus, streamUpdateCenterDeploy, streamUpdateCenterRollback, } from '../../services/updateCenterHelperClient.js'; +import { buildUpdateCenterStatus } from '../../services/updateCenterStatusService.js'; +import { + UPDATE_CENTER_DEPLOY_DEDUPE_KEY, + UPDATE_CENTER_DEPLOY_TASK_TYPE, +} from '../../services/updateCenterTaskConstants.js'; type DeployBody = { source?: UpdateCenterVersionSource; @@ -43,9 +40,6 @@ type RollbackBody = { targetRevision?: string; }; -const UPDATE_CENTER_DEPLOY_TASK_TYPE = 'update-center.deploy'; -const UPDATE_CENTER_DEPLOY_DEDUPE_KEY = 'update-center.deploy'; - function getUpdateCenterHelperToken(): string { return String( runtimeConfig.deployHelperToken @@ -69,77 +63,11 @@ function assertDeployableConfig(config: UpdateCenterConfig) { if (!config.imageRepository) throw new Error('imageRepository is required'); } -function getDeployTasks() { - return listBackgroundTasks(50).filter((task) => task.type === UPDATE_CENTER_DEPLOY_TASK_TYPE); -} - function summarizeHelperError(error: unknown) { if (error instanceof Error && error.message) return error.message; return String(error || 'unknown helper error'); } -async function settleOptional(enabled: boolean, loader: () => Promise): Promise<{ - value: T | null; - error: string | null; -}> { - if (!enabled) { - return { - value: null, - error: null, - }; - } - - try { - return { - value: await loader(), - error: null, - }; - } catch (error) { - return { - value: null, - error: summarizeHelperError(error), - }; - } -} - -async function buildUpdateCenterStatus() { - const config = await loadUpdateCenterConfig(); - const helperToken = getUpdateCenterHelperToken(); - - const [githubLookup, dockerLookup, helperLookup] = await Promise.all([ - settleOptional(config.githubReleasesEnabled, async () => await fetchLatestStableGitHubRelease()), - settleOptional(config.dockerHubTagsEnabled, async () => await fetchLatestDockerHubTag()), - settleOptional(!!config.helperBaseUrl, async () => { - if (!helperToken) { - throw new Error('DEPLOY_HELPER_TOKEN is required'); - } - return await getUpdateCenterHelperStatus(config, helperToken); - }), - ]); - - const githubRelease = githubLookup.value as UpdateCenterVersionCandidate | null; - const dockerHubTag = dockerLookup.value as UpdateCenterVersionCandidate | null; - const helper: Record = helperLookup.value || { - ok: false, - healthy: false, - error: helperLookup.error, - }; - - const tasks = getDeployTasks(); - const runningTask = tasks.find((task) => task.status === 'pending' || task.status === 'running') || null; - const lastFinishedTask = tasks.find((task) => task.status === 'succeeded' || task.status === 'failed') || null; - - return { - currentVersion: getCurrentRuntimeVersion(), - config, - githubRelease, - dockerHubTag, - helper, - runningTask, - lastFinishedTask, - }; -} - function writeSseEvent(reply: { raw: NodeJS.WritableStream & { write: (chunk: string) => void } }, event: string, data: unknown) { reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } diff --git a/src/server/services/updateCenterPollingService.test.ts b/src/server/services/updateCenterPollingService.test.ts new file mode 100644 index 0000000..943d445 --- /dev/null +++ b/src/server/services/updateCenterPollingService.test.ts @@ -0,0 +1,164 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +const { + buildUpdateCenterStatusMock, + sendNotificationMock, +} = vi.hoisted(() => ({ + buildUpdateCenterStatusMock: vi.fn(), + sendNotificationMock: vi.fn(), +})); + +vi.mock('./updateCenterStatusService.js', () => ({ + buildUpdateCenterStatus: (...args: unknown[]) => buildUpdateCenterStatusMock(...args), +})); + +vi.mock('./notifyService.js', () => ({ + sendNotification: (...args: unknown[]) => sendNotificationMock(...args), +})); + +type DbModule = typeof import('../db/index.js'); +type PollingModule = typeof import('./updateCenterPollingService.js'); +type RuntimeStateModule = typeof import('./updateCenterRuntimeStateService.js'); + +describe('updateCenterPollingService', () => { + let dataDir = ''; + let db: DbModule['db']; + let schema: DbModule['schema']; + let closeDbConnections: DbModule['closeDbConnections']; + let startUpdateCenterPolling: PollingModule['startUpdateCenterPolling']; + let stopUpdateCenterPolling: PollingModule['stopUpdateCenterPolling']; + let loadUpdateCenterRuntimeState: RuntimeStateModule['loadUpdateCenterRuntimeState']; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-update-center-polling-')); + process.env.DATA_DIR = dataDir; + + await import('../db/migrate.js'); + const dbModule = await import('../db/index.js'); + const pollingModule = await import('./updateCenterPollingService.js'); + const runtimeStateModule = await import('./updateCenterRuntimeStateService.js'); + + db = dbModule.db; + schema = dbModule.schema; + closeDbConnections = dbModule.closeDbConnections; + startUpdateCenterPolling = pollingModule.startUpdateCenterPolling; + stopUpdateCenterPolling = pollingModule.stopUpdateCenterPolling; + loadUpdateCenterRuntimeState = runtimeStateModule.loadUpdateCenterRuntimeState; + }); + + beforeEach(async () => { + vi.useFakeTimers(); + buildUpdateCenterStatusMock.mockReset(); + sendNotificationMock.mockReset(); + + await db.delete(schema.events).run(); + await db.delete(schema.settings).run(); + }); + + afterEach(() => { + stopUpdateCenterPolling(); + vi.useRealTimers(); + }); + + afterAll(async () => { + if (typeof closeDbConnections === 'function') { + await closeDbConnections(); + } + if (dataDir) { + try { + rmSync(dataDir, { recursive: true, force: true }); + } catch {} + } + delete process.env.DATA_DIR; + }); + + it('runs immediately, writes one event, and only notifies once for the same candidate', async () => { + buildUpdateCenterStatusMock.mockResolvedValue({ + currentVersion: '1.2.3', + githubRelease: { + normalizedVersion: '1.3.0', + displayVersion: '1.3.0', + tagName: 'v1.3.0', + }, + dockerHubTag: null, + helper: { + imageTag: '1.2.3', + }, + }); + + startUpdateCenterPolling(60_000); + await vi.advanceTimersByTimeAsync(0); + + expect(sendNotificationMock).toHaveBeenCalledTimes(1); + expect(sendNotificationMock.mock.calls[0]?.[0]).toContain('更新中心'); + expect(sendNotificationMock.mock.calls[0]?.[1]).toContain('1.3.0'); + + let events = await db.select().from(schema.events).all(); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: 'status', + relatedType: 'update_center', + level: 'info', + }); + + expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({ + lastCheckError: null, + lastResolvedSource: 'github-release', + lastResolvedCandidateKey: 'github-release:v1.3.0', + lastNotifiedCandidateKey: 'github-release:v1.3.0', + })); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(sendNotificationMock).toHaveBeenCalledTimes(1); + events = await db.select().from(schema.events).all(); + expect(events).toHaveLength(1); + }); + + it('stores the latest check error without creating events or notifications when the background check fails', async () => { + buildUpdateCenterStatusMock.mockRejectedValue(new Error('GitHub releases lookup timed out')); + + startUpdateCenterPolling(60_000); + await vi.advanceTimersByTimeAsync(0); + + expect(sendNotificationMock).not.toHaveBeenCalled(); + expect(await db.select().from(schema.events).all()).toEqual([]); + expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({ + lastCheckError: 'GitHub releases lookup timed out', + lastNotifiedCandidateKey: null, + })); + }); + + it('persists the notified candidate even when the downstream notification send fails', async () => { + buildUpdateCenterStatusMock.mockImplementation(async () => ({ + currentVersion: '1.2.3', + githubRelease: { + normalizedVersion: '1.3.0', + displayVersion: '1.3.0', + tagName: 'v1.3.0', + }, + dockerHubTag: null, + helper: { + imageTag: '1.2.3', + }, + runtime: await loadUpdateCenterRuntimeState(), + })); + sendNotificationMock.mockRejectedValue(new Error('notification downstream failed')); + + startUpdateCenterPolling(60_000); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(60_000); + + expect(sendNotificationMock).toHaveBeenCalledTimes(1); + expect(await db.select().from(schema.events).all()).toHaveLength(1); + expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({ + lastCheckError: null, + lastResolvedCandidateKey: 'github-release:v1.3.0', + lastNotifiedCandidateKey: 'github-release:v1.3.0', + lastNotifiedAt: expect.any(String), + })); + }); +}); diff --git a/src/server/services/updateCenterPollingService.ts b/src/server/services/updateCenterPollingService.ts new file mode 100644 index 0000000..bf099d8 --- /dev/null +++ b/src/server/services/updateCenterPollingService.ts @@ -0,0 +1,104 @@ +import { db, schema } from '../db/index.js'; +import { formatUtcSqlDateTime } from './localTimeService.js'; +import { sendNotification } from './notifyService.js'; +import { buildUpdateCenterStatus } from './updateCenterStatusService.js'; +import { loadUpdateCenterRuntimeState, saveUpdateCenterRuntimeState } from './updateCenterRuntimeStateService.js'; +import { resolveUpdateReminderCandidate } from './updateCenterReminderService.js'; + +const DEFAULT_UPDATE_CENTER_INTERVAL_MS = 15 * 60 * 1000; + +let pollingTimer: ReturnType | null = null; +let syncRunning = false; + +function summarizeError(error: unknown) { + if (error instanceof Error && error.message) return error.message; + return String(error || 'unknown error'); +} + +function buildReminderEvent(candidate: ReturnType) { + if (!candidate) return null; + const title = candidate.kind === 'new-digest' + ? '更新中心发现新 digest' + : '更新中心发现新版本'; + const message = candidate.kind === 'new-digest' + ? `检测到 ${candidate.displayVersion} 指向了新的镜像 digest,可前往更新中心查看并手动部署。` + : `检测到 ${candidate.displayVersion} 已可部署,可前往更新中心查看并手动部署。`; + return { + title, + message, + }; +} + +async function runSyncOnce() { + if (syncRunning) return; + syncRunning = true; + const checkedAt = formatUtcSqlDateTime(new Date()); + + try { + const status = await buildUpdateCenterStatus(); + const previousRuntime = status.runtime || await loadUpdateCenterRuntimeState(); + const candidate = resolveUpdateReminderCandidate({ + currentVersion: status.currentVersion, + helper: status.helper as { imageTag?: string | null; imageDigest?: string | null } | null, + githubRelease: status.githubRelease, + dockerHubTag: status.dockerHubTag, + }); + + const nextRuntime = { + ...previousRuntime, + lastCheckedAt: checkedAt, + lastCheckError: null, + lastResolvedSource: candidate?.source || null, + lastResolvedDisplayVersion: candidate?.displayVersion || null, + lastResolvedCandidateKey: candidate?.candidateKey || null, + }; + + if (candidate && candidate.candidateKey !== previousRuntime.lastNotifiedCandidateKey) { + const reminderEvent = buildReminderEvent(candidate); + if (reminderEvent) { + await db.insert(schema.events).values({ + type: 'status', + title: reminderEvent.title, + message: reminderEvent.message, + level: 'info', + relatedType: 'update_center', + createdAt: checkedAt, + }).run(); + nextRuntime.lastNotifiedCandidateKey = candidate.candidateKey; + nextRuntime.lastNotifiedAt = checkedAt; + await saveUpdateCenterRuntimeState(nextRuntime); + await sendNotification(reminderEvent.title, reminderEvent.message, 'info', { + bypassThrottle: true, + }); + } + } + + await saveUpdateCenterRuntimeState(nextRuntime); + } catch (error) { + const previousRuntime = await loadUpdateCenterRuntimeState(); + await saveUpdateCenterRuntimeState({ + ...previousRuntime, + lastCheckedAt: checkedAt, + lastCheckError: summarizeError(error), + }); + } finally { + syncRunning = false; + } +} + +export function startUpdateCenterPolling(intervalMs = DEFAULT_UPDATE_CENTER_INTERVAL_MS) { + stopUpdateCenterPolling(); + pollingTimer = setInterval(() => { + void runSyncOnce(); + }, Math.max(10_000, intervalMs)); + pollingTimer.unref?.(); + void runSyncOnce(); + return { intervalMs: Math.max(10_000, intervalMs) }; +} + +export function stopUpdateCenterPolling() { + if (pollingTimer) { + clearInterval(pollingTimer); + pollingTimer = null; + } +} diff --git a/src/server/services/updateCenterReminderService.ts b/src/server/services/updateCenterReminderService.ts new file mode 100644 index 0000000..326f81a --- /dev/null +++ b/src/server/services/updateCenterReminderService.ts @@ -0,0 +1,6 @@ +export type { + UpdateHelperRuntimeLike, + UpdateReminderCandidate, + UpdateVersionCandidateLike, +} from '../shared/updateCenterReminder.js'; +export { resolveUpdateReminderCandidate } from '../shared/updateCenterReminder.js'; diff --git a/src/server/services/updateCenterRuntimeStateService.test.ts b/src/server/services/updateCenterRuntimeStateService.test.ts new file mode 100644 index 0000000..35596be --- /dev/null +++ b/src/server/services/updateCenterRuntimeStateService.test.ts @@ -0,0 +1,81 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +type DbModule = typeof import('../db/index.js'); +type RuntimeStateModule = typeof import('./updateCenterRuntimeStateService.js'); + +describe('updateCenterRuntimeStateService', () => { + let dataDir = ''; + let db: DbModule['db']; + let schema: DbModule['schema']; + let closeDbConnections: DbModule['closeDbConnections']; + let loadUpdateCenterRuntimeState: RuntimeStateModule['loadUpdateCenterRuntimeState']; + let saveUpdateCenterRuntimeState: RuntimeStateModule['saveUpdateCenterRuntimeState']; + + beforeAll(async () => { + dataDir = mkdtempSync(join(tmpdir(), 'metapi-update-center-runtime-state-')); + process.env.DATA_DIR = dataDir; + + await import('../db/migrate.js'); + const dbModule = await import('../db/index.js'); + const runtimeStateModule = await import('./updateCenterRuntimeStateService.js'); + + db = dbModule.db; + schema = dbModule.schema; + closeDbConnections = dbModule.closeDbConnections; + loadUpdateCenterRuntimeState = runtimeStateModule.loadUpdateCenterRuntimeState; + saveUpdateCenterRuntimeState = runtimeStateModule.saveUpdateCenterRuntimeState; + }); + + beforeEach(async () => { + await db.delete(schema.settings).run(); + }); + + afterAll(async () => { + if (typeof closeDbConnections === 'function') { + await closeDbConnections(); + } + if (dataDir) { + try { + rmSync(dataDir, { recursive: true, force: true }); + } catch {} + } + delete process.env.DATA_DIR; + }); + + it('returns an empty default state when nothing has been persisted yet', async () => { + await expect(loadUpdateCenterRuntimeState()).resolves.toEqual({ + lastCheckedAt: null, + lastCheckError: null, + lastResolvedSource: null, + lastResolvedDisplayVersion: null, + lastResolvedCandidateKey: null, + lastNotifiedCandidateKey: null, + lastNotifiedAt: null, + }); + }); + + it('persists and reloads reminder runtime metadata', async () => { + await saveUpdateCenterRuntimeState({ + lastCheckedAt: '2026-03-30 20:30:00', + lastCheckError: null, + lastResolvedSource: 'docker-hub-tag', + lastResolvedDisplayVersion: 'latest @ sha256:efb2ee655386', + lastResolvedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', + lastNotifiedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', + lastNotifiedAt: '2026-03-30 20:31:00', + }); + + await expect(loadUpdateCenterRuntimeState()).resolves.toEqual({ + lastCheckedAt: '2026-03-30 20:30:00', + lastCheckError: null, + lastResolvedSource: 'docker-hub-tag', + lastResolvedDisplayVersion: 'latest @ sha256:efb2ee655386', + lastResolvedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', + lastNotifiedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', + lastNotifiedAt: '2026-03-30 20:31:00', + }); + }); +}); diff --git a/src/server/services/updateCenterRuntimeStateService.ts b/src/server/services/updateCenterRuntimeStateService.ts new file mode 100644 index 0000000..c2e88c3 --- /dev/null +++ b/src/server/services/updateCenterRuntimeStateService.ts @@ -0,0 +1,72 @@ +import { eq } from 'drizzle-orm'; + +import { db, schema } from '../db/index.js'; +import { upsertSetting } from '../db/upsertSetting.js'; +import type { UpdateCenterVersionSource } from './updateCenterVersionService.js'; + +export type UpdateCenterRuntimeState = { + lastCheckedAt: string | null; + lastCheckError: string | null; + lastResolvedSource: UpdateCenterVersionSource | null; + lastResolvedDisplayVersion: string | null; + lastResolvedCandidateKey: string | null; + lastNotifiedCandidateKey: string | null; + lastNotifiedAt: string | null; +}; + +export const UPDATE_CENTER_RUNTIME_STATE_SETTING_KEY = 'update_center_runtime_state_v1'; + +export function getDefaultUpdateCenterRuntimeState(): UpdateCenterRuntimeState { + return { + lastCheckedAt: null, + lastCheckError: null, + lastResolvedSource: null, + lastResolvedDisplayVersion: null, + lastResolvedCandidateKey: null, + lastNotifiedCandidateKey: null, + lastNotifiedAt: null, + }; +} + +function normalizeNullableString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed || null; +} + +function normalizeNullableSource(value: unknown): UpdateCenterVersionSource | null { + return value === 'docker-hub-tag' || value === 'github-release' ? value : null; +} + +export function normalizeUpdateCenterRuntimeState(input: unknown): UpdateCenterRuntimeState { + const defaults = getDefaultUpdateCenterRuntimeState(); + const record = input && typeof input === 'object' ? input as Record : {}; + return { + lastCheckedAt: normalizeNullableString(record.lastCheckedAt) ?? defaults.lastCheckedAt, + lastCheckError: normalizeNullableString(record.lastCheckError) ?? defaults.lastCheckError, + lastResolvedSource: normalizeNullableSource(record.lastResolvedSource) ?? defaults.lastResolvedSource, + lastResolvedDisplayVersion: normalizeNullableString(record.lastResolvedDisplayVersion) ?? defaults.lastResolvedDisplayVersion, + lastResolvedCandidateKey: normalizeNullableString(record.lastResolvedCandidateKey) ?? defaults.lastResolvedCandidateKey, + lastNotifiedCandidateKey: normalizeNullableString(record.lastNotifiedCandidateKey) ?? defaults.lastNotifiedCandidateKey, + lastNotifiedAt: normalizeNullableString(record.lastNotifiedAt) ?? defaults.lastNotifiedAt, + }; +} + +export async function loadUpdateCenterRuntimeState(): Promise { + const row = await db.select().from(schema.settings).where(eq(schema.settings.key, UPDATE_CENTER_RUNTIME_STATE_SETTING_KEY)).get(); + if (!row?.value) { + return getDefaultUpdateCenterRuntimeState(); + } + + try { + return normalizeUpdateCenterRuntimeState(JSON.parse(row.value)); + } catch { + return getDefaultUpdateCenterRuntimeState(); + } +} + +export async function saveUpdateCenterRuntimeState(input: unknown): Promise { + const next = normalizeUpdateCenterRuntimeState(input); + await upsertSetting(UPDATE_CENTER_RUNTIME_STATE_SETTING_KEY, next); + return next; +} diff --git a/src/server/services/updateCenterStatusService.ts b/src/server/services/updateCenterStatusService.ts new file mode 100644 index 0000000..a9e138f --- /dev/null +++ b/src/server/services/updateCenterStatusService.ts @@ -0,0 +1,98 @@ +import { config as runtimeConfig } from '../config.js'; +import { listBackgroundTasks } from './backgroundTaskService.js'; +import { + fetchLatestDockerHubTag, + fetchLatestStableGitHubRelease, + getCurrentRuntimeVersion, + type UpdateCenterVersionCandidate, +} from './updateCenterVersionService.js'; +import { + loadUpdateCenterConfig, +} from './updateCenterConfigService.js'; +import { + getUpdateCenterHelperStatus, +} from './updateCenterHelperClient.js'; +import { loadUpdateCenterRuntimeState } from './updateCenterRuntimeStateService.js'; +import { UPDATE_CENTER_DEPLOY_TASK_TYPE } from './updateCenterTaskConstants.js'; + +function getUpdateCenterHelperToken(): string { + return String( + runtimeConfig.deployHelperToken + || process.env.DEPLOY_HELPER_TOKEN + || process.env.UPDATE_CENTER_HELPER_TOKEN + || '', + ).trim(); +} + +function summarizeHelperError(error: unknown) { + if (error instanceof Error && error.message) return error.message; + return String(error || 'unknown helper error'); +} + +async function settleOptional(enabled: boolean, loader: () => Promise): Promise<{ + value: T | null; + error: string | null; +}> { + if (!enabled) { + return { + value: null, + error: null, + }; + } + + try { + return { + value: await loader(), + error: null, + }; + } catch (error) { + return { + value: null, + error: summarizeHelperError(error), + }; + } +} + +function getDeployTasks() { + return listBackgroundTasks(50).filter((task) => task.type === UPDATE_CENTER_DEPLOY_TASK_TYPE); +} + +export async function buildUpdateCenterStatus() { + const config = await loadUpdateCenterConfig(); + const helperToken = getUpdateCenterHelperToken(); + + const [githubLookup, dockerLookup, helperLookup, runtime] = await Promise.all([ + settleOptional(config.githubReleasesEnabled, async () => await fetchLatestStableGitHubRelease()), + settleOptional(config.dockerHubTagsEnabled, async () => await fetchLatestDockerHubTag()), + settleOptional(!!config.helperBaseUrl, async () => { + if (!helperToken) { + throw new Error('DEPLOY_HELPER_TOKEN is required'); + } + return await getUpdateCenterHelperStatus(config, helperToken); + }), + loadUpdateCenterRuntimeState(), + ]); + + const githubRelease = githubLookup.value as UpdateCenterVersionCandidate | null; + const dockerHubTag = dockerLookup.value as UpdateCenterVersionCandidate | null; + const helper: Record = helperLookup.value || { + ok: false, + healthy: false, + error: helperLookup.error, + }; + + const tasks = getDeployTasks(); + const runningTask = tasks.find((task) => task.status === 'pending' || task.status === 'running') || null; + const lastFinishedTask = tasks.find((task) => task.status === 'succeeded' || task.status === 'failed') || null; + + return { + currentVersion: getCurrentRuntimeVersion(), + config, + githubRelease, + dockerHubTag, + helper, + runningTask, + lastFinishedTask, + runtime, + }; +} diff --git a/src/server/services/updateCenterTaskConstants.ts b/src/server/services/updateCenterTaskConstants.ts new file mode 100644 index 0000000..baa8b58 --- /dev/null +++ b/src/server/services/updateCenterTaskConstants.ts @@ -0,0 +1,2 @@ +export const UPDATE_CENTER_DEPLOY_TASK_TYPE = 'update-center.deploy'; +export const UPDATE_CENTER_DEPLOY_DEDUPE_KEY = UPDATE_CENTER_DEPLOY_TASK_TYPE; diff --git a/src/server/shared/updateCenterReminder.ts b/src/server/shared/updateCenterReminder.ts new file mode 100644 index 0000000..1eb384d --- /dev/null +++ b/src/server/shared/updateCenterReminder.ts @@ -0,0 +1,153 @@ +export type UpdateVersionCandidateLike = { + normalizedVersion?: string | null; + displayVersion?: string | null; + tagName?: string | null; + digest?: string | null; +}; + +export type UpdateHelperRuntimeLike = { + imageTag?: string | null; + imageDigest?: string | null; +}; + +export type UpdateReminderCandidate = { + source: 'github-release' | 'docker-hub-tag'; + kind: 'new-version' | 'new-digest'; + candidateKey: string; + displayVersion: string; + tagName: string; + digest: string | null; +}; + +const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i; + +function normalizeString(value?: string | null): string { + return String(value || '').trim(); +} + +function normalizeDigest(value?: string | null): string { + const digest = normalizeString(value); + return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : ''; +} + +function normalizeStableTag(value?: string | null): string { + return normalizeStableVersion(value); +} + +function hasSameStableTag(left?: string | null, right?: string | null): boolean { + const normalizedLeft = normalizeStableTag(left); + const normalizedRight = normalizeStableTag(right); + return !!normalizedLeft && normalizedLeft === normalizedRight; +} + +export function normalizeStableVersion(value?: string | null): string { + const raw = normalizeString(value); + if (!raw) return ''; + const match = raw.match(STABLE_SEMVER_PATTERN); + if (!match) return ''; + return [ + Number.parseInt(match[1], 10), + Number.parseInt(match[2], 10), + Number.parseInt(match[3], 10), + ].join('.'); +} + +export function compareStableVersions(left?: string | null, right?: string | null): number | null { + const normalizedLeft = normalizeStableVersion(left); + const normalizedRight = normalizeStableVersion(right); + if (!normalizedLeft || !normalizedRight) return null; + + const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10)); + const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10)); + for (let index = 0; index < 3; index += 1) { + if (leftParts[index] === rightParts[index]) continue; + return leftParts[index] < rightParts[index] ? -1 : 1; + } + return 0; +} + +export function isSameImageTarget( + current: UpdateHelperRuntimeLike | null | undefined, + target: { tag?: string | null; digest?: string | null }, +): boolean { + const currentDigest = normalizeDigest(current?.imageDigest); + const targetDigest = normalizeDigest(target.digest); + if (currentDigest && targetDigest) { + return currentDigest === targetDigest; + } + + return hasSameStableTag(current?.imageTag, target.tag); +} + +export function buildUpdateReminderCandidateKey( + source: 'github-release' | 'docker-hub-tag', + candidate: { tagName?: string | null; digest?: string | null }, +): string { + const tagName = normalizeString(candidate.tagName); + const digest = normalizeDigest(candidate.digest); + if (!tagName) return ''; + if (source === 'docker-hub-tag' && digest) { + return `${source}:${tagName}@${digest}`; + } + return `${source}:${tagName}`; +} + +export function resolveUpdateReminderCandidate(input: { + currentVersion?: string | null; + helper: UpdateHelperRuntimeLike | null | undefined; + githubRelease: UpdateVersionCandidateLike | null | undefined; + dockerHubTag: UpdateVersionCandidateLike | null | undefined; +}): UpdateReminderCandidate | null { + const githubCandidateVersion = normalizeString(input.githubRelease?.normalizedVersion); + const githubTag = normalizeString(input.githubRelease?.tagName || githubCandidateVersion); + if (githubTag) { + const githubTargetVersion = githubCandidateVersion || githubTag; + const versionCompare = compareStableVersions(input.currentVersion, githubTargetVersion); + const helperVersionCompare = compareStableVersions(input.helper?.imageTag, githubTargetVersion); + if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) { + return { + source: 'github-release', + kind: 'new-version', + candidateKey: buildUpdateReminderCandidateKey('github-release', { tagName: githubTag, digest: null }), + displayVersion: normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion || githubTag), + tagName: githubTag, + digest: null, + }; + } + } + + const dockerCandidateVersion = normalizeString(input.dockerHubTag?.normalizedVersion); + const dockerTag = normalizeString(input.dockerHubTag?.tagName || dockerCandidateVersion); + const dockerDigest = normalizeDigest(input.dockerHubTag?.digest); + if (!dockerTag) return null; + + if (isSameImageTarget(input.helper, { tag: dockerTag, digest: dockerDigest })) { + return null; + } + + const dockerVersionCompare = compareStableVersions(input.currentVersion, dockerCandidateVersion || dockerTag); + if (dockerVersionCompare === -1) { + return { + source: 'docker-hub-tag', + kind: 'new-version', + candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest || null }), + displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag), + tagName: dockerTag, + digest: dockerDigest || null, + }; + } + + const helperDigest = normalizeDigest(input.helper?.imageDigest); + if (dockerDigest && helperDigest && hasSameStableTag(input.helper?.imageTag, dockerTag) && helperDigest !== dockerDigest) { + return { + source: 'docker-hub-tag', + kind: 'new-digest', + candidateKey: buildUpdateReminderCandidateKey('docker-hub-tag', { tagName: dockerTag, digest: dockerDigest }), + displayVersion: normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion || dockerTag), + tagName: dockerTag, + digest: dockerDigest, + }; + } + + return null; +} diff --git a/src/shared/updateCenterReminder.test.ts b/src/shared/updateCenterReminder.test.ts new file mode 100644 index 0000000..db19aa5 --- /dev/null +++ b/src/shared/updateCenterReminder.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildUpdateReminderCandidateKey, + resolveUpdateReminderCandidate, +} from './updateCenterReminder.js'; + +describe('updateCenterReminder shared logic', () => { + it('prefers a newer GitHub release when the current runtime is behind', () => { + expect(resolveUpdateReminderCandidate({ + currentVersion: '1.2.3', + helper: { + imageTag: '1.2.3', + }, + githubRelease: { + normalizedVersion: '1.3.0', + displayVersion: '1.3.0', + tagName: 'v1.3.0', + }, + dockerHubTag: { + normalizedVersion: 'latest', + displayVersion: 'latest @ sha256:efb2ee655386', + tagName: 'latest', + digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', + }, + })).toEqual(expect.objectContaining({ + source: 'github-release', + kind: 'new-version', + candidateKey: 'github-release:v1.3.0', + displayVersion: '1.3.0', + })); + }); + + it('treats a same-version Docker target with a different digest as a reminder-worthy new digest', () => { + expect(resolveUpdateReminderCandidate({ + currentVersion: '1.2.3', + helper: { + imageTag: '1.2.3', + imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + githubRelease: null, + dockerHubTag: { + normalizedVersion: '1.2.3', + displayVersion: '1.2.3 @ sha256:bbbbbbbbbbbb', + tagName: '1.2.3', + digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }, + })).toEqual(expect.objectContaining({ + source: 'docker-hub-tag', + kind: 'new-digest', + candidateKey: 'docker-hub-tag:1.2.3@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + })); + }); + + it('treats semver tags with and without a v prefix as the same digest-tracked image target', () => { + expect(resolveUpdateReminderCandidate({ + currentVersion: '1.2.3', + helper: { + imageTag: 'v1.2.3', + imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + githubRelease: null, + dockerHubTag: { + normalizedVersion: '1.2.3', + displayVersion: '1.2.3 @ sha256:bbbbbbbbbbbb', + tagName: '1.2.3', + digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }, + })).toEqual(expect.objectContaining({ + source: 'docker-hub-tag', + kind: 'new-digest', + candidateKey: 'docker-hub-tag:1.2.3@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + })); + }); + + it('does not return an older GitHub candidate when the helper is already ahead of it', () => { + expect(resolveUpdateReminderCandidate({ + currentVersion: '1.2.3', + helper: { + imageTag: '1.4.0', + }, + githubRelease: { + normalizedVersion: '1.3.0', + displayVersion: '1.3.0', + tagName: 'v1.3.0', + }, + dockerHubTag: null, + })).toBeNull(); + }); + + it('returns null when the discovered targets do not differ meaningfully from the current runtime', () => { + expect(resolveUpdateReminderCandidate({ + currentVersion: '1.2.3', + helper: { + imageTag: '1.2.3', + imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + githubRelease: { + normalizedVersion: '1.2.3', + displayVersion: '1.2.3', + tagName: 'v1.2.3', + }, + dockerHubTag: { + normalizedVersion: '1.2.3', + displayVersion: '1.2.3 @ sha256:aaaaaaaaaaaa', + tagName: '1.2.3', + digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + })).toBeNull(); + }); + + it('builds stable candidate keys for both source types', () => { + expect(buildUpdateReminderCandidateKey('github-release', { + tagName: 'v1.3.0', + digest: null, + })).toBe('github-release:v1.3.0'); + + expect(buildUpdateReminderCandidateKey('docker-hub-tag', { + tagName: 'latest', + digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', + })).toBe('docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35'); + }); +}); diff --git a/src/shared/updateCenterReminder.ts b/src/shared/updateCenterReminder.ts new file mode 100644 index 0000000..d8d1721 --- /dev/null +++ b/src/shared/updateCenterReminder.ts @@ -0,0 +1,12 @@ +export type { + UpdateHelperRuntimeLike, + UpdateReminderCandidate, + UpdateVersionCandidateLike, +} from '../server/shared/updateCenterReminder.js'; +export { + buildUpdateReminderCandidateKey, + compareStableVersions, + isSameImageTarget, + normalizeStableVersion, + resolveUpdateReminderCandidate, +} from '../server/shared/updateCenterReminder.js'; diff --git a/src/web/pages/helpers/navigationFocus.test.ts b/src/web/pages/helpers/navigationFocus.test.ts index 03dd7cb..522f860 100644 --- a/src/web/pages/helpers/navigationFocus.test.ts +++ b/src/web/pages/helpers/navigationFocus.test.ts @@ -76,6 +76,12 @@ describe('navigationFocus helpers', () => { type: 'proxy', })).toBe('/routes'); + expect(buildEventNavigationPath({ + relatedType: 'update_center', + relatedId: null, + type: 'status', + })).toBe('/settings'); + expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs'); expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin'); expect(buildEventNavigationPath({ type: 'site_notice' })).toBe('/site-announcements'); diff --git a/src/web/pages/helpers/navigationFocus.ts b/src/web/pages/helpers/navigationFocus.ts index e61c0b8..069bc95 100644 --- a/src/web/pages/helpers/navigationFocus.ts +++ b/src/web/pages/helpers/navigationFocus.ts @@ -98,6 +98,9 @@ export function buildEventNavigationPath(event: { if (relatedType === 'site_announcement' && relatedId) { return buildAnnouncementFocusPath(relatedId); } + if (relatedType === 'update_center') { + return '/settings'; + } if (relatedType === 'route') { return '/routes'; } diff --git a/src/web/pages/helpers/updateCenterPresentation.test.ts b/src/web/pages/helpers/updateCenterPresentation.test.ts index ccb714f..7eb41a5 100644 --- a/src/web/pages/helpers/updateCenterPresentation.test.ts +++ b/src/web/pages/helpers/updateCenterPresentation.test.ts @@ -40,4 +40,44 @@ describe('updateCenterPresentation', () => { expect(state.canDeploy).toBe(true); expect(state.badgeLabel).toBe('发现新 digest'); }); + + it('treats semver tags with and without a v prefix as the same Docker digest target', () => { + const state = describeDockerDeployState({ + enabled: true, + helperHealthy: true, + currentVersion: '1.2.3', + helper: { + imageTag: 'v1.2.3', + imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + candidate: { + normalizedVersion: '1.2.3', + tagName: '1.2.3', + digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }, + }); + + expect(state.kind).toBe('new-digest'); + expect(state.canDeploy).toBe(true); + }); + + it('does not advertise an older GitHub reminder when the helper is already ahead', () => { + expect(buildUpdateReminder({ + currentVersion: '1.2.3', + helper: { + imageTag: '1.4.0', + }, + githubRelease: { + normalizedVersion: '1.3.0', + displayVersion: '1.3.0', + tagName: 'v1.3.0', + }, + dockerHubTag: null, + })).toEqual({ + label: '已是最新', + badgeClassName: 'badge badge-muted', + detail: '当前运行版本与已发现的部署目标没有明显差异。', + highlight: false, + }); + }); }); diff --git a/src/web/pages/helpers/updateCenterPresentation.ts b/src/web/pages/helpers/updateCenterPresentation.ts index 74f8645..e189c7c 100644 --- a/src/web/pages/helpers/updateCenterPresentation.ts +++ b/src/web/pages/helpers/updateCenterPresentation.ts @@ -1,14 +1,10 @@ -export type UpdateVersionCandidateLike = { - normalizedVersion?: string | null; - displayVersion?: string | null; - tagName?: string | null; - digest?: string | null; -}; - -export type UpdateHelperRuntimeLike = { - imageTag?: string | null; - imageDigest?: string | null; -}; +import { + compareStableVersions, + isSameImageTarget, + resolveUpdateReminderCandidate, + type UpdateHelperRuntimeLike, + type UpdateVersionCandidateLike, +} from '../../../shared/updateCenterReminder.js'; export type UpdateDeployState = { kind: 'disabled' | 'missing' | 'helper-unhealthy' | 'same-version' | 'same-image' | 'new-version' | 'new-digest' | 'available'; @@ -26,8 +22,6 @@ export type UpdateReminder = { highlight: boolean; }; -const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i; - function normalizeString(value?: string | null): string { return String(value || '').trim(); } @@ -37,53 +31,6 @@ function normalizeDigest(value?: string | null): string { return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : ''; } -export function normalizeStableVersion(value?: string | null): string { - const raw = normalizeString(value); - if (!raw) return ''; - const match = raw.match(STABLE_SEMVER_PATTERN); - if (!match) return ''; - return [ - Number.parseInt(match[1], 10), - Number.parseInt(match[2], 10), - Number.parseInt(match[3], 10), - ].join('.'); -} - -export function compareStableVersions(left?: string | null, right?: string | null): number | null { - const normalizedLeft = normalizeStableVersion(left); - const normalizedRight = normalizeStableVersion(right); - if (!normalizedLeft || !normalizedRight) return null; - - const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10)); - const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10)); - for (let index = 0; index < 3; index += 1) { - if (leftParts[index] === rightParts[index]) continue; - return leftParts[index] < rightParts[index] ? -1 : 1; - } - return 0; -} - -export function isSameImageTarget( - current: UpdateHelperRuntimeLike | null | undefined, - target: { tag?: string | null; digest?: string | null }, -): boolean { - const currentDigest = normalizeDigest(current?.imageDigest); - const targetDigest = normalizeDigest(target.digest); - if (currentDigest && targetDigest) { - return currentDigest === targetDigest; - } - - const currentTag = normalizeString(current?.imageTag); - const targetTag = normalizeString(target.tag); - if (!currentTag || !targetTag || currentTag !== targetTag) { - return false; - } - - const currentVersion = normalizeStableVersion(currentTag); - const targetVersion = normalizeStableVersion(targetTag); - return !!currentVersion && currentVersion === targetVersion; -} - export function describeGitHubDeployState(input: { enabled: boolean; helperHealthy: boolean; @@ -127,14 +74,17 @@ export function describeGitHubDeployState(input: { }; } - const versionCompare = compareStableVersions(input.currentVersion, candidateVersion || candidateTag); - const helperVersionCompare = compareStableVersions(input.helperImageTag, candidateTag); - if (versionCompare === 0 || helperVersionCompare === 0) { + const candidateTargetVersion = candidateVersion || candidateTag; + const versionCompare = compareStableVersions(input.currentVersion, candidateTargetVersion); + const helperVersionCompare = compareStableVersions(input.helperImageTag, candidateTargetVersion); + if (versionCompare === 0 || helperVersionCompare === 0 || helperVersionCompare === 1) { return { kind: 'same-version', badgeClassName: 'badge badge-muted', badgeLabel: '当前运行', - reason: '当前已运行该版本,无需重复部署。', + reason: helperVersionCompare === 1 + ? 'Deploy Helper 已指向更高版本,无需回退到较旧的 GitHub 稳定版。' + : '当前已运行该版本,无需重复部署。', canDeploy: false, highlight: false, }; @@ -216,8 +166,21 @@ export function describeDockerDeployState(input: { }; } - const versionCompare = compareStableVersions(input.currentVersion, candidateVersion || candidateTag); - if (versionCompare === -1) { + const candidateTargetVersion = candidateVersion || candidateTag; + const helperVersionCompare = compareStableVersions(input.helper?.imageTag, candidateTargetVersion); + if (helperVersionCompare === 1) { + return { + kind: 'same-version', + badgeClassName: 'badge badge-muted', + badgeLabel: '当前运行', + reason: 'Deploy Helper 已指向更高版本,无需回退到较旧镜像。', + canDeploy: false, + highlight: false, + }; + } + + const versionCompare = compareStableVersions(input.currentVersion, candidateTargetVersion); + if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) { return { kind: 'new-version', badgeClassName: 'badge badge-success', @@ -228,9 +191,18 @@ export function describeDockerDeployState(input: { }; } - const helperTag = normalizeString(input.helper?.imageTag); const helperDigest = normalizeDigest(input.helper?.imageDigest); - if (candidateDigest && helperTag && helperTag === candidateTag && helperDigest && helperDigest !== candidateDigest) { + const hasSameStableTag = isSameImageTarget( + { + imageTag: input.helper?.imageTag, + imageDigest: null, + }, + { + tag: candidateTag, + digest: null, + }, + ); + if (candidateDigest && helperDigest && hasSameStableTag && helperDigest !== candidateDigest) { return { kind: 'new-digest', badgeClassName: 'badge badge-success', @@ -277,37 +249,22 @@ export function buildUpdateReminder(input: { }; } - const githubState = describeGitHubDeployState({ - enabled: true, - helperHealthy: true, - currentVersion: input.currentVersion, - helperImageTag: input.helper?.imageTag, - candidate: input.githubRelease, - }); - if (githubState.kind === 'new-version') { - return { - label: '发现新版本', - badgeClassName: githubState.badgeClassName, - detail: `GitHub 稳定版 ${normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion)} 已可部署。`, - highlight: true, - }; - } - - const dockerState = describeDockerDeployState({ - enabled: true, - helperHealthy: true, + const candidate = resolveUpdateReminderCandidate({ currentVersion: input.currentVersion, helper: input.helper, - candidate: input.dockerHubTag, + githubRelease: input.githubRelease, + dockerHubTag: input.dockerHubTag, }); - if (dockerState.kind === 'new-version' || dockerState.kind === 'new-digest') { + if (candidate) { return { - label: dockerState.badgeLabel, - badgeClassName: dockerState.badgeClassName, - detail: dockerState.kind === 'new-digest' + label: candidate.kind === 'new-digest' ? '发现新 digest' : '发现新版本', + badgeClassName: 'badge badge-success', + detail: candidate.kind === 'new-digest' ? 'Docker Hub 的 alias tag 已指向新 digest,可按需部署。' - : `Docker Hub ${normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion)} 已可部署。`, - highlight: dockerState.highlight, + : candidate.source === 'github-release' + ? `GitHub 稳定版 ${normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion)} 已可部署。` + : `Docker Hub ${normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion)} 已可部署。`, + highlight: true, }; } diff --git a/src/web/pages/settings/UpdateCenterSection.test.tsx b/src/web/pages/settings/UpdateCenterSection.test.tsx index b424150..846c429 100644 --- a/src/web/pages/settings/UpdateCenterSection.test.tsx +++ b/src/web/pages/settings/UpdateCenterSection.test.tsx @@ -109,6 +109,15 @@ describe('UpdateCenterSection', () => { }, ], }, + runtime: { + lastCheckedAt: '2026-03-30 20:30:00', + lastCheckError: null, + lastResolvedSource: 'github-release', + lastResolvedDisplayVersion: '1.3.0', + lastResolvedCandidateKey: 'github-release:v1.3.0', + lastNotifiedCandidateKey: 'github-release:v1.3.0', + lastNotifiedAt: '2026-03-30 20:31:00', + }, runningTask: null, lastFinishedTask: null, }); @@ -254,6 +263,8 @@ describe('UpdateCenterSection', () => { const text = collectText(root.root); expect(text).toContain('latest @ sha256:efb2ee655386'); expect(text).toContain('发现新版本'); + expect(text).toContain('后台检查'); + expect(text).toContain('1.3.0'); expect(text).toContain('Running helm upgrade'); expect(text).toContain('Waiting for rollout'); expect(text).toContain('任务状态 · 已完成'); @@ -437,6 +448,30 @@ describe('UpdateCenterSection', () => { } }); + it('parses timezone-less SQL runtime timestamps as UTC before formatting', async () => { + const parseSpy = vi.spyOn(Date, 'parse'); + let root!: ReactTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const parseInputs = parseSpy.mock.calls.map(([value]) => String(value)); + expect(parseInputs).toContain('2026-03-30T20:30:00Z'); + expect(parseInputs).not.toContain('2026-03-30 20:30:00'); + } finally { + parseSpy.mockRestore(); + root?.unmount(); + } + }); + it('renders rollback history and triggers rollback tasks for previous revisions', async () => { let root!: ReactTestRenderer; try { diff --git a/src/web/pages/settings/UpdateCenterSection.tsx b/src/web/pages/settings/UpdateCenterSection.tsx index e5ca7cc..c7e422e 100644 --- a/src/web/pages/settings/UpdateCenterSection.tsx +++ b/src/web/pages/settings/UpdateCenterSection.tsx @@ -65,6 +65,15 @@ type UpdateCenterStatus = { status?: string; finishedAt?: string | null; } | null; + runtime?: { + lastCheckedAt?: string | null; + lastCheckError?: string | null; + lastResolvedSource?: 'github-release' | 'docker-hub-tag' | null; + lastResolvedDisplayVersion?: string | null; + lastResolvedCandidateKey?: string | null; + lastNotifiedCandidateKey?: string | null; + lastNotifiedAt?: string | null; + } | null; }; const DEFAULT_CONFIG: NonNullable = { @@ -137,7 +146,10 @@ const fieldHintStyle: CSSProperties = { function formatTaskTime(value?: string | null) { if (!value) return '暂无完成记录'; - const timestamp = Date.parse(value); + const normalizedValue = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value) + ? `${value.replace(' ', 'T')}Z` + : value; + const timestamp = Date.parse(normalizedValue); if (!Number.isFinite(timestamp)) return value; return new Date(timestamp).toLocaleString('zh-CN', { month: '2-digit', @@ -402,6 +414,7 @@ export default function UpdateCenterSection() { const helperHistory = Array.isArray(status?.helper?.history) ? status.helper.history : []; const historyPreview = helperHistory.slice(0, 2); const currentRevision = String(status?.helper?.revision || '').trim(); + const runtimeStatus = status?.runtime || null; if (loading) { return ( @@ -475,6 +488,19 @@ export default function UpdateCenterSection() { : formatTaskTime(status?.lastFinishedTask?.finishedAt)} +
+
后台检查
+
+ {runtimeStatus?.lastCheckedAt ? formatTaskTime(runtimeStatus.lastCheckedAt) : '尚无检查记录'} +
+
+ {runtimeStatus?.lastCheckError + ? `最近错误:${runtimeStatus.lastCheckError}` + : runtimeStatus?.lastResolvedDisplayVersion + ? `最近发现:${runtimeStatus.lastResolvedDisplayVersion}` + : '后台会定时检查新版本,并在首次发现时提醒一次。'} +
+
启用更新中心 - 允许系统检查版本,并在配置齐全时触发 K3s 部署。 + 允许通过本页触发 K3s 部署。后台版本提醒会按已启用来源持续检查。