diff --git a/src/server/routes/api/updateCenter.test.ts b/src/server/routes/api/updateCenter.test.ts index ca76125..9e8c9a0 100644 --- a/src/server/routes/api/updateCenter.test.ts +++ b/src/server/routes/api/updateCenter.test.ts @@ -36,12 +36,15 @@ vi.mock('../../services/updateCenterHelperClient.js', () => ({ type DbModule = typeof import('../../db/index.js'); type ConfigModule = typeof import('../../config.js'); +type RuntimeStateModule = typeof import('../../services/updateCenterRuntimeStateService.js'); describe('update center routes', () => { let app: FastifyInstance; let db: DbModule['db']; let schema: DbModule['schema']; let appConfig: ConfigModule['config']; + let saveUpdateCenterRuntimeState: RuntimeStateModule['saveUpdateCenterRuntimeState']; + let loadUpdateCenterRuntimeState: RuntimeStateModule['loadUpdateCenterRuntimeState']; let dataDir = ''; let resetBackgroundTasks: (() => void) | null = null; let getBackgroundTask: ((taskId: string) => { status: string; logs?: Array<{ message: string }> } | null) | null = null; @@ -76,12 +79,15 @@ describe('update center routes', () => { const dbModule = await import('../../db/index.js'); const routesModule = await import('./updateCenter.js'); const backgroundTaskModule = await import('../../services/backgroundTaskService.js'); + const runtimeStateModule = await import('../../services/updateCenterRuntimeStateService.js'); appConfig = configModule.config; db = dbModule.db; schema = dbModule.schema; resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests; getBackgroundTask = backgroundTaskModule.getBackgroundTask; + saveUpdateCenterRuntimeState = runtimeStateModule.saveUpdateCenterRuntimeState; + loadUpdateCenterRuntimeState = runtimeStateModule.loadUpdateCenterRuntimeState; app = Fastify(); await app.register(routesModule.updateCenterRoutes); @@ -201,11 +207,11 @@ describe('update center routes', () => { ], }, runtime: { - lastCheckedAt: null, + lastCheckedAt: expect.any(String), lastCheckError: null, - lastResolvedSource: null, - lastResolvedDisplayVersion: null, - lastResolvedCandidateKey: null, + lastResolvedSource: 'github-release', + lastResolvedDisplayVersion: '1.3.0', + lastResolvedCandidateKey: 'github-release:1.3.0', lastNotifiedCandidateKey: null, lastNotifiedAt: null, }, @@ -298,6 +304,150 @@ describe('update center routes', () => { } }); + it('reuses the persisted snapshot for status requests instead of re-querying external sources', async () => { + await saveValidConfig(); + await saveUpdateCenterRuntimeState({ + lastCheckedAt: '2026-03-31 09:00:00', + lastCheckError: null, + lastResolvedSource: 'docker-hub-tag', + lastResolvedDisplayVersion: 'latest @ sha256:efb2ee655386', + lastResolvedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', + lastNotifiedCandidateKey: null, + lastNotifiedAt: null, + statusSnapshot: { + githubRelease: { + source: 'github-release', + rawVersion: 'v1.3.0', + normalizedVersion: '1.3.0', + url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0', + tagName: 'v1.3.0', + digest: null, + displayVersion: '1.3.0', + publishedAt: '2026-03-31T09:00:00Z', + }, + dockerHubTag: { + source: 'docker-hub-tag', + rawVersion: 'latest', + normalizedVersion: 'latest', + tagName: 'latest', + digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', + displayVersion: 'latest @ sha256:efb2ee655386', + publishedAt: '2026-03-31T09:00:00Z', + url: null, + }, + helper: { + ok: true, + releaseName: 'metapi', + namespace: 'ai', + revision: '12', + imageRepository: '1467078763/metapi', + imageTag: 'latest', + imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + healthy: true, + error: null, + history: [], + }, + }, + }); + + const statusResponse = await app.inject({ + method: 'GET', + url: '/api/update-center/status', + }); + + expect(statusResponse.statusCode).toBe(200); + expect(statusResponse.json()).toMatchObject({ + githubRelease: { + normalizedVersion: '1.3.0', + }, + dockerHubTag: { + displayVersion: 'latest @ sha256:efb2ee655386', + }, + helper: { + imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + }, + runtime: { + lastCheckedAt: '2026-03-31 09:00:00', + }, + }); + expect(fetchLatestStableGitHubReleaseMock).not.toHaveBeenCalled(); + expect(fetchLatestDockerHubTagMock).not.toHaveBeenCalled(); + expect(getUpdateCenterHelperStatusMock).not.toHaveBeenCalled(); + }); + + it('forces a live refresh on manual check and persists the refreshed snapshot', async () => { + await saveValidConfig(); + fetchLatestStableGitHubReleaseMock.mockResolvedValue({ + source: 'github-release', + rawVersion: 'v1.3.1', + normalizedVersion: '1.3.1', + tagName: 'v1.3.1', + displayVersion: '1.3.1', + publishedAt: '2026-03-31T10:00:00Z', + url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.1', + }); + fetchLatestDockerHubTagMock.mockResolvedValue({ + source: 'docker-hub-tag', + rawVersion: 'latest', + normalizedVersion: 'latest', + tagName: 'latest', + digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + displayVersion: 'latest @ sha256:dddddddddddd', + publishedAt: '2026-03-31T10:00:00Z', + url: null, + }); + getUpdateCenterHelperStatusMock.mockResolvedValue({ + ok: true, + releaseName: 'metapi', + namespace: 'ai', + revision: '13', + imageRepository: '1467078763/metapi', + imageTag: 'latest', + imageDigest: 'sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + healthy: true, + history: [], + }); + + const checkResponse = await app.inject({ + method: 'POST', + url: '/api/update-center/check', + }); + + expect(checkResponse.statusCode).toBe(200); + expect(checkResponse.json()).toMatchObject({ + githubRelease: { + normalizedVersion: '1.3.1', + }, + dockerHubTag: { + digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + }, + helper: { + revision: '13', + }, + runtime: { + lastCheckedAt: expect.any(String), + }, + }); + expect(fetchLatestStableGitHubReleaseMock).toHaveBeenCalledTimes(1); + expect(fetchLatestDockerHubTagMock).toHaveBeenCalledTimes(1); + expect(getUpdateCenterHelperStatusMock).toHaveBeenCalledTimes(1); + expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({ + lastResolvedSource: 'github-release', + lastResolvedDisplayVersion: '1.3.1', + statusSnapshot: { + githubRelease: expect.objectContaining({ + normalizedVersion: '1.3.1', + }), + dockerHubTag: expect.objectContaining({ + digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + }), + helper: expect.objectContaining({ + revision: '13', + }), + }, + })); + }); + it('dedupes deploy requests while a task is already running', async () => { await saveValidConfig(); diff --git a/src/server/routes/api/updateCenter.ts b/src/server/routes/api/updateCenter.ts index 4e9912c..dfb7955 100644 --- a/src/server/routes/api/updateCenter.ts +++ b/src/server/routes/api/updateCenter.ts @@ -23,7 +23,10 @@ import { streamUpdateCenterDeploy, streamUpdateCenterRollback, } from '../../services/updateCenterHelperClient.js'; -import { buildUpdateCenterStatus } from '../../services/updateCenterStatusService.js'; +import { + getUpdateCenterStatus, + refreshUpdateCenterStatusCache, +} from '../../services/updateCenterStatusService.js'; import { UPDATE_CENTER_DEPLOY_DEDUPE_KEY, UPDATE_CENTER_DEPLOY_TASK_TYPE, @@ -74,11 +77,11 @@ function writeSseEvent(reply: { raw: NodeJS.WritableStream & { write: (chunk: st export async function updateCenterRoutes(app: FastifyInstance) { app.get('/api/update-center/status', async () => { - return await buildUpdateCenterStatus(); + return await getUpdateCenterStatus(); }); app.post('/api/update-center/check', async () => { - return await buildUpdateCenterStatus(); + return (await refreshUpdateCenterStatusCache()).status; }); app.put<{ Body: UpdateCenterConfig }>('/api/update-center/config', async (request) => { diff --git a/src/server/services/updateCenterPollingService.test.ts b/src/server/services/updateCenterPollingService.test.ts index 943d445..2c50df7 100644 --- a/src/server/services/updateCenterPollingService.test.ts +++ b/src/server/services/updateCenterPollingService.test.ts @@ -4,15 +4,15 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; const { - buildUpdateCenterStatusMock, + refreshUpdateCenterStatusCacheMock, sendNotificationMock, } = vi.hoisted(() => ({ - buildUpdateCenterStatusMock: vi.fn(), + refreshUpdateCenterStatusCacheMock: vi.fn(), sendNotificationMock: vi.fn(), })); vi.mock('./updateCenterStatusService.js', () => ({ - buildUpdateCenterStatus: (...args: unknown[]) => buildUpdateCenterStatusMock(...args), + refreshUpdateCenterStatusCache: (...args: unknown[]) => refreshUpdateCenterStatusCacheMock(...args), })); vi.mock('./notifyService.js', () => ({ @@ -51,7 +51,7 @@ describe('updateCenterPollingService', () => { beforeEach(async () => { vi.useFakeTimers(); - buildUpdateCenterStatusMock.mockReset(); + refreshUpdateCenterStatusCacheMock.mockReset(); sendNotificationMock.mockReset(); await db.delete(schema.events).run(); @@ -76,17 +76,59 @@ describe('updateCenterPollingService', () => { }); 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', - }, + refreshUpdateCenterStatusCacheMock.mockImplementation(async () => { + const previousRuntime = await loadUpdateCenterRuntimeState(); + const runtime = { + ...previousRuntime, + lastCheckedAt: '2026-03-31 12:00:00', + lastCheckError: null, + lastResolvedSource: 'github-release' as const, + lastResolvedDisplayVersion: '1.3.0', + lastResolvedCandidateKey: 'github-release:v1.3.0', + statusSnapshot: { + githubRelease: { + source: 'github-release' as const, + rawVersion: 'v1.3.0', + normalizedVersion: '1.3.0', + url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0', + tagName: 'v1.3.0', + digest: null, + displayVersion: '1.3.0', + publishedAt: '2026-03-31T12:00:00Z', + }, + dockerHubTag: null, + helper: { + ok: true, + releaseName: 'metapi', + namespace: 'ai', + revision: '12', + imageRepository: '1467078763/metapi', + imageTag: '1.2.3', + imageDigest: null, + healthy: true, + history: [], + }, + }, + }; + return { + status: { + currentVersion: '1.2.3', + githubRelease: runtime.statusSnapshot.githubRelease, + dockerHubTag: null, + helper: runtime.statusSnapshot.helper, + runtime, + }, + candidate: { + source: 'github-release', + kind: 'new-version', + candidateKey: 'github-release:v1.3.0', + displayVersion: '1.3.0', + tagName: 'v1.3.0', + digest: null, + }, + previousRuntime, + runtime, + }; }); startUpdateCenterPolling(60_000); @@ -109,6 +151,15 @@ describe('updateCenterPollingService', () => { lastResolvedSource: 'github-release', lastResolvedCandidateKey: 'github-release:v1.3.0', lastNotifiedCandidateKey: 'github-release:v1.3.0', + statusSnapshot: { + githubRelease: expect.objectContaining({ + normalizedVersion: '1.3.0', + }), + dockerHubTag: null, + helper: expect.objectContaining({ + imageTag: '1.2.3', + }), + }, })); await vi.advanceTimersByTimeAsync(60_000); @@ -119,7 +170,7 @@ describe('updateCenterPollingService', () => { }); 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')); + refreshUpdateCenterStatusCacheMock.mockRejectedValue(new Error('GitHub releases lookup timed out')); startUpdateCenterPolling(60_000); await vi.advanceTimersByTimeAsync(0); @@ -133,19 +184,60 @@ describe('updateCenterPollingService', () => { }); 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(), - })); + refreshUpdateCenterStatusCacheMock.mockImplementation(async () => { + const previousRuntime = await loadUpdateCenterRuntimeState(); + const runtime = { + ...previousRuntime, + lastCheckedAt: '2026-03-31 12:01:00', + lastCheckError: null, + lastResolvedSource: 'github-release' as const, + lastResolvedDisplayVersion: '1.3.0', + lastResolvedCandidateKey: 'github-release:v1.3.0', + statusSnapshot: { + githubRelease: { + source: 'github-release' as const, + rawVersion: 'v1.3.0', + normalizedVersion: '1.3.0', + url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0', + tagName: 'v1.3.0', + digest: null, + displayVersion: '1.3.0', + publishedAt: '2026-03-31T12:01:00Z', + }, + dockerHubTag: null, + helper: { + ok: true, + releaseName: 'metapi', + namespace: 'ai', + revision: '12', + imageRepository: '1467078763/metapi', + imageTag: '1.2.3', + imageDigest: null, + healthy: true, + history: [], + }, + }, + }; + return { + status: { + currentVersion: '1.2.3', + githubRelease: runtime.statusSnapshot.githubRelease, + dockerHubTag: null, + helper: runtime.statusSnapshot.helper, + runtime, + }, + candidate: { + source: 'github-release', + kind: 'new-version', + candidateKey: 'github-release:v1.3.0', + displayVersion: '1.3.0', + tagName: 'v1.3.0', + digest: null, + }, + previousRuntime, + runtime, + }; + }); sendNotificationMock.mockRejectedValue(new Error('notification downstream failed')); startUpdateCenterPolling(60_000); @@ -155,10 +247,19 @@ describe('updateCenterPollingService', () => { expect(sendNotificationMock).toHaveBeenCalledTimes(1); expect(await db.select().from(schema.events).all()).toHaveLength(1); expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({ - lastCheckError: null, + lastCheckError: 'notification downstream failed', lastResolvedCandidateKey: 'github-release:v1.3.0', lastNotifiedCandidateKey: 'github-release:v1.3.0', lastNotifiedAt: expect.any(String), + statusSnapshot: { + githubRelease: expect.objectContaining({ + normalizedVersion: '1.3.0', + }), + dockerHubTag: null, + helper: expect.objectContaining({ + imageTag: '1.2.3', + }), + }, })); }); }); diff --git a/src/server/services/updateCenterPollingService.ts b/src/server/services/updateCenterPollingService.ts index bf099d8..2f79a71 100644 --- a/src/server/services/updateCenterPollingService.ts +++ b/src/server/services/updateCenterPollingService.ts @@ -1,9 +1,9 @@ import { db, schema } from '../db/index.js'; import { formatUtcSqlDateTime } from './localTimeService.js'; import { sendNotification } from './notifyService.js'; -import { buildUpdateCenterStatus } from './updateCenterStatusService.js'; +import { refreshUpdateCenterStatusCache } from './updateCenterStatusService.js'; import { loadUpdateCenterRuntimeState, saveUpdateCenterRuntimeState } from './updateCenterRuntimeStateService.js'; -import { resolveUpdateReminderCandidate } from './updateCenterReminderService.js'; +import type { UpdateReminderCandidate } from './updateCenterReminderService.js'; const DEFAULT_UPDATE_CENTER_INTERVAL_MS = 15 * 60 * 1000; @@ -15,7 +15,7 @@ function summarizeError(error: unknown) { return String(error || 'unknown error'); } -function buildReminderEvent(candidate: ReturnType) { +function buildReminderEvent(candidate: UpdateReminderCandidate | null) { if (!candidate) return null; const title = candidate.kind === 'new-digest' ? '更新中心发现新 digest' @@ -35,23 +35,11 @@ async function runSyncOnce() { 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, - }; + const { + candidate, + previousRuntime, + runtime, + } = await refreshUpdateCenterStatusCache(checkedAt); if (candidate && candidate.candidateKey !== previousRuntime.lastNotifiedCandidateKey) { const reminderEvent = buildReminderEvent(candidate); @@ -64,16 +52,16 @@ async function runSyncOnce() { relatedType: 'update_center', createdAt: checkedAt, }).run(); - nextRuntime.lastNotifiedCandidateKey = candidate.candidateKey; - nextRuntime.lastNotifiedAt = checkedAt; - await saveUpdateCenterRuntimeState(nextRuntime); + await saveUpdateCenterRuntimeState({ + ...runtime, + lastNotifiedCandidateKey: candidate.candidateKey, + lastNotifiedAt: checkedAt, + }); await sendNotification(reminderEvent.title, reminderEvent.message, 'info', { bypassThrottle: true, }); } } - - await saveUpdateCenterRuntimeState(nextRuntime); } catch (error) { const previousRuntime = await loadUpdateCenterRuntimeState(); await saveUpdateCenterRuntimeState({ diff --git a/src/server/services/updateCenterRuntimeStateService.test.ts b/src/server/services/updateCenterRuntimeStateService.test.ts index 35596be..80a4a83 100644 --- a/src/server/services/updateCenterRuntimeStateService.test.ts +++ b/src/server/services/updateCenterRuntimeStateService.test.ts @@ -54,6 +54,7 @@ describe('updateCenterRuntimeStateService', () => { lastResolvedCandidateKey: null, lastNotifiedCandidateKey: null, lastNotifiedAt: null, + statusSnapshot: null, }); }); @@ -66,6 +67,50 @@ describe('updateCenterRuntimeStateService', () => { lastResolvedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', lastNotifiedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', lastNotifiedAt: '2026-03-30 20:31:00', + statusSnapshot: { + githubRelease: { + source: 'github-release', + rawVersion: 'v1.3.0', + normalizedVersion: '1.3.0', + url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0', + tagName: 'v1.3.0', + digest: null, + displayVersion: '1.3.0', + publishedAt: '2026-03-30T20:30:00Z', + }, + dockerHubTag: { + source: 'docker-hub-tag', + rawVersion: 'latest', + normalizedVersion: 'latest', + url: null, + tagName: 'latest', + digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', + displayVersion: 'latest @ sha256:efb2ee655386', + publishedAt: '2026-03-30T20:30:00Z', + }, + helper: { + ok: true, + releaseName: 'metapi', + namespace: 'ai', + revision: '12', + imageRepository: '1467078763/metapi', + imageTag: 'latest', + imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + healthy: true, + error: undefined, + history: [ + { + revision: '11', + updatedAt: '2026-03-29T20:00:00Z', + status: 'superseded', + description: 'Rollback to stable digest', + imageRepository: '1467078763/metapi', + imageTag: 'main', + imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }, + ], + }, + }, }); await expect(loadUpdateCenterRuntimeState()).resolves.toEqual({ @@ -76,6 +121,50 @@ describe('updateCenterRuntimeStateService', () => { lastResolvedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', lastNotifiedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', lastNotifiedAt: '2026-03-30 20:31:00', + statusSnapshot: { + githubRelease: { + source: 'github-release', + rawVersion: 'v1.3.0', + normalizedVersion: '1.3.0', + url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0', + tagName: 'v1.3.0', + digest: null, + displayVersion: '1.3.0', + publishedAt: '2026-03-30T20:30:00Z', + }, + dockerHubTag: { + source: 'docker-hub-tag', + rawVersion: 'latest', + normalizedVersion: 'latest', + url: null, + tagName: 'latest', + digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', + displayVersion: 'latest @ sha256:efb2ee655386', + publishedAt: '2026-03-30T20:30:00Z', + }, + helper: { + ok: true, + releaseName: 'metapi', + namespace: 'ai', + revision: '12', + imageRepository: '1467078763/metapi', + imageTag: 'latest', + imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + healthy: true, + error: undefined, + history: [ + { + revision: '11', + updatedAt: '2026-03-29T20:00:00Z', + status: 'superseded', + description: 'Rollback to stable digest', + imageRepository: '1467078763/metapi', + imageTag: 'main', + imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }, + ], + }, + }, }); }); }); diff --git a/src/server/services/updateCenterRuntimeStateService.ts b/src/server/services/updateCenterRuntimeStateService.ts index c2e88c3..a3e7c31 100644 --- a/src/server/services/updateCenterRuntimeStateService.ts +++ b/src/server/services/updateCenterRuntimeStateService.ts @@ -2,7 +2,14 @@ import { eq } from 'drizzle-orm'; import { db, schema } from '../db/index.js'; import { upsertSetting } from '../db/upsertSetting.js'; -import type { UpdateCenterVersionSource } from './updateCenterVersionService.js'; +import type { UpdateCenterHelperStatus } from './updateCenterHelperClient.js'; +import type { UpdateCenterVersionCandidate, UpdateCenterVersionSource } from './updateCenterVersionService.js'; + +export type UpdateCenterStatusSnapshot = { + githubRelease: UpdateCenterVersionCandidate | null; + dockerHubTag: UpdateCenterVersionCandidate | null; + helper: UpdateCenterHelperStatus | null; +}; export type UpdateCenterRuntimeState = { lastCheckedAt: string | null; @@ -12,6 +19,7 @@ export type UpdateCenterRuntimeState = { lastResolvedCandidateKey: string | null; lastNotifiedCandidateKey: string | null; lastNotifiedAt: string | null; + statusSnapshot: UpdateCenterStatusSnapshot | null; }; export const UPDATE_CENTER_RUNTIME_STATE_SETTING_KEY = 'update_center_runtime_state_v1'; @@ -25,6 +33,7 @@ export function getDefaultUpdateCenterRuntimeState(): UpdateCenterRuntimeState { lastResolvedCandidateKey: null, lastNotifiedCandidateKey: null, lastNotifiedAt: null, + statusSnapshot: null, }; } @@ -38,6 +47,72 @@ function normalizeNullableSource(value: unknown): UpdateCenterVersionSource | nu return value === 'docker-hub-tag' || value === 'github-release' ? value : null; } +function normalizeVersionCandidate(input: unknown): UpdateCenterVersionCandidate | null { + if (!input || typeof input !== 'object') return null; + const record = input as Record; + const source = normalizeNullableSource(record.source); + const rawVersion = normalizeNullableString(record.rawVersion); + const normalizedVersion = normalizeNullableString(record.normalizedVersion); + if (!source || !rawVersion || !normalizedVersion) return null; + return { + source, + rawVersion, + normalizedVersion, + url: normalizeNullableString(record.url), + tagName: normalizeNullableString(record.tagName), + digest: normalizeNullableString(record.digest), + displayVersion: normalizeNullableString(record.displayVersion), + publishedAt: normalizeNullableString(record.publishedAt), + }; +} + +function normalizeHelperHistoryEntry(input: unknown): NonNullable[number] | null { + if (!input || typeof input !== 'object') return null; + const record = input as Record; + const revision = normalizeNullableString(record.revision); + if (!revision) return null; + return { + revision, + updatedAt: normalizeNullableString(record.updatedAt), + status: normalizeNullableString(record.status), + description: normalizeNullableString(record.description), + imageRepository: normalizeNullableString(record.imageRepository), + imageTag: normalizeNullableString(record.imageTag), + imageDigest: normalizeNullableString(record.imageDigest), + }; +} + +function normalizeHelperSnapshot(input: unknown): UpdateCenterHelperStatus | null { + if (!input || typeof input !== 'object') return null; + const record = input as Record; + return { + ok: typeof record.ok === 'boolean' ? record.ok : false, + releaseName: normalizeNullableString(record.releaseName), + namespace: normalizeNullableString(record.namespace), + revision: normalizeNullableString(record.revision), + imageRepository: normalizeNullableString(record.imageRepository), + imageTag: normalizeNullableString(record.imageTag), + imageDigest: normalizeNullableString(record.imageDigest), + healthy: typeof record.healthy === 'boolean' ? record.healthy : false, + error: normalizeNullableString(record.error) || undefined, + history: Array.isArray(record.history) + ? record.history + .map((entry) => normalizeHelperHistoryEntry(entry)) + .filter((entry): entry is NonNullable[number] => !!entry) + : [], + }; +} + +function normalizeStatusSnapshot(input: unknown): UpdateCenterStatusSnapshot | null { + if (!input || typeof input !== 'object') return null; + const record = input as Record; + return { + githubRelease: normalizeVersionCandidate(record.githubRelease), + dockerHubTag: normalizeVersionCandidate(record.dockerHubTag), + helper: normalizeHelperSnapshot(record.helper), + }; +} + export function normalizeUpdateCenterRuntimeState(input: unknown): UpdateCenterRuntimeState { const defaults = getDefaultUpdateCenterRuntimeState(); const record = input && typeof input === 'object' ? input as Record : {}; @@ -49,6 +124,9 @@ export function normalizeUpdateCenterRuntimeState(input: unknown): UpdateCenterR lastResolvedCandidateKey: normalizeNullableString(record.lastResolvedCandidateKey) ?? defaults.lastResolvedCandidateKey, lastNotifiedCandidateKey: normalizeNullableString(record.lastNotifiedCandidateKey) ?? defaults.lastNotifiedCandidateKey, lastNotifiedAt: normalizeNullableString(record.lastNotifiedAt) ?? defaults.lastNotifiedAt, + statusSnapshot: Object.prototype.hasOwnProperty.call(record, 'statusSnapshot') + ? normalizeStatusSnapshot(record.statusSnapshot) + : defaults.statusSnapshot, }; } diff --git a/src/server/services/updateCenterStatusService.ts b/src/server/services/updateCenterStatusService.ts index a9e138f..73fb90a 100644 --- a/src/server/services/updateCenterStatusService.ts +++ b/src/server/services/updateCenterStatusService.ts @@ -1,4 +1,5 @@ import { config as runtimeConfig } from '../config.js'; +import { formatUtcSqlDateTime } from './localTimeService.js'; import { listBackgroundTasks } from './backgroundTaskService.js'; import { fetchLatestDockerHubTag, @@ -7,13 +8,21 @@ import { type UpdateCenterVersionCandidate, } from './updateCenterVersionService.js'; import { + type UpdateCenterConfig, loadUpdateCenterConfig, } from './updateCenterConfigService.js'; import { getUpdateCenterHelperStatus, + type UpdateCenterHelperStatus, } from './updateCenterHelperClient.js'; -import { loadUpdateCenterRuntimeState } from './updateCenterRuntimeStateService.js'; +import { + loadUpdateCenterRuntimeState, + saveUpdateCenterRuntimeState, + type UpdateCenterRuntimeState, + type UpdateCenterStatusSnapshot, +} from './updateCenterRuntimeStateService.js'; import { UPDATE_CENTER_DEPLOY_TASK_TYPE } from './updateCenterTaskConstants.js'; +import { resolveUpdateReminderCandidate, type UpdateReminderCandidate } from './updateCenterReminderService.js'; function getUpdateCenterHelperToken(): string { return String( @@ -57,7 +66,85 @@ function getDeployTasks() { return listBackgroundTasks(50).filter((task) => task.type === UPDATE_CENTER_DEPLOY_TASK_TYPE); } -export async function buildUpdateCenterStatus() { +export type UpdateCenterStatusResult = { + currentVersion: string; + config: UpdateCenterConfig; + githubRelease: UpdateCenterVersionCandidate | null; + dockerHubTag: UpdateCenterVersionCandidate | null; + helper: UpdateCenterHelperStatus; + runningTask: ReturnType[number] | null; + lastFinishedTask: ReturnType[number] | null; + runtime: UpdateCenterRuntimeState; +}; + +function buildUnavailableHelperStatus(error: string | null = null): UpdateCenterHelperStatus { + return { + ok: false, + releaseName: null, + namespace: null, + revision: null, + imageRepository: null, + imageTag: null, + imageDigest: null, + healthy: false, + error: error || undefined, + history: [], + }; +} + +function buildStatusSnapshot(status: Pick): UpdateCenterStatusSnapshot { + return { + githubRelease: status.githubRelease || null, + dockerHubTag: status.dockerHubTag || null, + helper: status.helper || null, + }; +} + +function buildNextRuntimeState( + status: Pick, + previousRuntime: UpdateCenterRuntimeState, + checkedAt: string, +): { candidate: UpdateReminderCandidate | null; nextRuntime: UpdateCenterRuntimeState } { + const candidate = resolveUpdateReminderCandidate({ + currentVersion: status.currentVersion, + helper: status.helper, + githubRelease: status.githubRelease, + dockerHubTag: status.dockerHubTag, + }); + + return { + candidate, + nextRuntime: { + ...previousRuntime, + lastCheckedAt: checkedAt, + lastCheckError: null, + lastResolvedSource: candidate?.source || null, + lastResolvedDisplayVersion: candidate?.displayVersion || null, + lastResolvedCandidateKey: candidate?.candidateKey || null, + statusSnapshot: buildStatusSnapshot(status), + }, + }; +} + +function buildResponseFromState(config: UpdateCenterConfig, runtime: UpdateCenterRuntimeState): UpdateCenterStatusResult { + const snapshot = runtime.statusSnapshot; + 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: snapshot?.githubRelease || null, + dockerHubTag: snapshot?.dockerHubTag || null, + helper: snapshot?.helper || buildUnavailableHelperStatus(runtime.lastCheckError), + runningTask, + lastFinishedTask, + runtime, + }; +} + +export async function buildUpdateCenterStatus(): Promise { const config = await loadUpdateCenterConfig(); const helperToken = getUpdateCenterHelperToken(); @@ -75,11 +162,7 @@ export async function buildUpdateCenterStatus() { 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 helper = (helperLookup.value as UpdateCenterHelperStatus | null) || buildUnavailableHelperStatus(helperLookup.error); const tasks = getDeployTasks(); const runningTask = tasks.find((task) => task.status === 'pending' || task.status === 'running') || null; @@ -96,3 +179,40 @@ export async function buildUpdateCenterStatus() { runtime, }; } + +export async function buildCachedUpdateCenterStatus(): Promise { + const [config, runtime] = await Promise.all([ + loadUpdateCenterConfig(), + loadUpdateCenterRuntimeState(), + ]); + return buildResponseFromState(config, runtime); +} + +export async function refreshUpdateCenterStatusCache(checkedAt = formatUtcSqlDateTime(new Date())): Promise<{ + status: UpdateCenterStatusResult; + candidate: UpdateReminderCandidate | null; + previousRuntime: UpdateCenterRuntimeState; + runtime: UpdateCenterRuntimeState; +}> { + const status = await buildUpdateCenterStatus(); + const previousRuntime = status.runtime || await loadUpdateCenterRuntimeState(); + const { candidate, nextRuntime } = buildNextRuntimeState(status, previousRuntime, checkedAt); + const runtime = await saveUpdateCenterRuntimeState(nextRuntime); + return { + status: { + ...status, + runtime, + }, + candidate, + previousRuntime, + runtime, + }; +} + +export async function getUpdateCenterStatus(): Promise { + const cached = await buildCachedUpdateCenterStatus(); + if (cached.runtime.statusSnapshot) { + return cached; + } + return (await refreshUpdateCenterStatusCache()).status; +} diff --git a/src/web/pages/settings/UpdateCenterSection.test.tsx b/src/web/pages/settings/UpdateCenterSection.test.tsx index 846c429..a3e524a 100644 --- a/src/web/pages/settings/UpdateCenterSection.test.tsx +++ b/src/web/pages/settings/UpdateCenterSection.test.tsx @@ -136,6 +136,18 @@ describe('UpdateCenterSection', () => { }, }); apiMock.checkUpdateCenter.mockResolvedValue({ + currentVersion: '1.2.3', + config: { + enabled: true, + helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850', + namespace: 'ai', + releaseName: 'metapi', + chartRef: 'oci://ghcr.io/cita-777/charts/metapi', + imageRepository: '1467078763/metapi', + githubReleasesEnabled: true, + dockerHubTagsEnabled: true, + defaultDeploySource: 'github-release', + }, githubRelease: { normalizedVersion: '1.3.0', displayVersion: '1.3.0', @@ -147,6 +159,25 @@ describe('UpdateCenterSection', () => { displayVersion: 'latest @ sha256:efb2ee655386', publishedAt: '2026-03-29T11:54:35.591877Z', }, + helper: { + ok: true, + healthy: true, + revision: '18', + imageTag: 'latest', + imageDigest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', + history: [], + }, + runtime: { + lastCheckedAt: '2026-03-30 20:35: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, }); apiMock.deployUpdateCenter.mockResolvedValue({ success: true, @@ -259,6 +290,7 @@ describe('UpdateCenterSection', () => { targetDigest: null, }); expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object)); + expect(apiMock.checkUpdateCenter).toHaveBeenCalledTimes(1); const text = collectText(root.root); expect(text).toContain('latest @ sha256:efb2ee655386'); @@ -501,10 +533,10 @@ describe('UpdateCenterSection', () => { targetRevision: '16', }); expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-2', expect.any(Object)); + expect(apiMock.checkUpdateCenter).toHaveBeenCalledTimes(1); const text = collectText(root.root); - expect(text).toContain('sha256:bbbbbbbbbbbb'); - expect(text).toContain('Rollback to stable digest'); + expect(text).toContain('最近状态:succeeded'); } finally { root?.unmount(); } diff --git a/src/web/pages/settings/UpdateCenterSection.tsx b/src/web/pages/settings/UpdateCenterSection.tsx index c7e422e..91f09ea 100644 --- a/src/web/pages/settings/UpdateCenterSection.tsx +++ b/src/web/pages/settings/UpdateCenterSection.tsx @@ -228,12 +228,16 @@ export default function UpdateCenterSection() { const [historyModalOpen, setHistoryModalOpen] = useState(false); const streamAbortRef = useRef(null); + const applyStatus = (next: UpdateCenterStatus) => { + setStatus(next); + setConfig(next.config || DEFAULT_CONFIG); + }; + const loadStatus = async () => { setLoading(true); try { const next = await api.getUpdateCenterStatus() as UpdateCenterStatus; - setStatus(next); - setConfig(next.config || DEFAULT_CONFIG); + applyStatus(next); } catch (error: any) { toast.error(error?.message || '加载更新中心失败'); } finally { @@ -241,6 +245,19 @@ export default function UpdateCenterSection() { } }; + const refreshStatus = async (showErrorToast = true) => { + try { + const next = await api.checkUpdateCenter() as UpdateCenterStatus; + applyStatus(next); + return next; + } catch (error: any) { + if (showErrorToast) { + toast.error(error?.message || '检查更新失败'); + } + throw error; + } + }; + useEffect(() => { void loadStatus(); return () => { @@ -269,12 +286,10 @@ export default function UpdateCenterSection() { const checkNow = async () => { setChecking(true); try { - const next = await api.checkUpdateCenter() as UpdateCenterStatus; - setStatus(next); - setConfig(next.config || DEFAULT_CONFIG); + await refreshStatus(true); toast.success('已刷新更新信息'); - } catch (error: any) { - toast.error(error?.message || '检查更新失败'); + } catch { + // refreshStatus already handled the toast } finally { setChecking(false); } @@ -343,7 +358,7 @@ export default function UpdateCenterSection() { toast.error(error?.message || '部署失败'); } finally { setDeploying(false); - void loadStatus(); + void refreshStatus(false).catch(() => {}); } }; @@ -378,7 +393,7 @@ export default function UpdateCenterSection() { toast.error(error?.message || '回退失败'); } finally { setDeploying(false); - void loadStatus(); + void refreshStatus(false).catch(() => {}); } };