forked from boos/BoosAPI
cache update center status snapshots (#349)
This commit is contained in:
@@ -36,12 +36,15 @@ vi.mock('../../services/updateCenterHelperClient.js', () => ({
|
|||||||
|
|
||||||
type DbModule = typeof import('../../db/index.js');
|
type DbModule = typeof import('../../db/index.js');
|
||||||
type ConfigModule = typeof import('../../config.js');
|
type ConfigModule = typeof import('../../config.js');
|
||||||
|
type RuntimeStateModule = typeof import('../../services/updateCenterRuntimeStateService.js');
|
||||||
|
|
||||||
describe('update center routes', () => {
|
describe('update center routes', () => {
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
let db: DbModule['db'];
|
let db: DbModule['db'];
|
||||||
let schema: DbModule['schema'];
|
let schema: DbModule['schema'];
|
||||||
let appConfig: ConfigModule['config'];
|
let appConfig: ConfigModule['config'];
|
||||||
|
let saveUpdateCenterRuntimeState: RuntimeStateModule['saveUpdateCenterRuntimeState'];
|
||||||
|
let loadUpdateCenterRuntimeState: RuntimeStateModule['loadUpdateCenterRuntimeState'];
|
||||||
let dataDir = '';
|
let dataDir = '';
|
||||||
let resetBackgroundTasks: (() => void) | null = null;
|
let resetBackgroundTasks: (() => void) | null = null;
|
||||||
let getBackgroundTask: ((taskId: string) => { status: string; logs?: Array<{ message: string }> } | null) | 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 dbModule = await import('../../db/index.js');
|
||||||
const routesModule = await import('./updateCenter.js');
|
const routesModule = await import('./updateCenter.js');
|
||||||
const backgroundTaskModule = await import('../../services/backgroundTaskService.js');
|
const backgroundTaskModule = await import('../../services/backgroundTaskService.js');
|
||||||
|
const runtimeStateModule = await import('../../services/updateCenterRuntimeStateService.js');
|
||||||
|
|
||||||
appConfig = configModule.config;
|
appConfig = configModule.config;
|
||||||
db = dbModule.db;
|
db = dbModule.db;
|
||||||
schema = dbModule.schema;
|
schema = dbModule.schema;
|
||||||
resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests;
|
resetBackgroundTasks = backgroundTaskModule.__resetBackgroundTasksForTests;
|
||||||
getBackgroundTask = backgroundTaskModule.getBackgroundTask;
|
getBackgroundTask = backgroundTaskModule.getBackgroundTask;
|
||||||
|
saveUpdateCenterRuntimeState = runtimeStateModule.saveUpdateCenterRuntimeState;
|
||||||
|
loadUpdateCenterRuntimeState = runtimeStateModule.loadUpdateCenterRuntimeState;
|
||||||
|
|
||||||
app = Fastify();
|
app = Fastify();
|
||||||
await app.register(routesModule.updateCenterRoutes);
|
await app.register(routesModule.updateCenterRoutes);
|
||||||
@@ -201,11 +207,11 @@ describe('update center routes', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
runtime: {
|
runtime: {
|
||||||
lastCheckedAt: null,
|
lastCheckedAt: expect.any(String),
|
||||||
lastCheckError: null,
|
lastCheckError: null,
|
||||||
lastResolvedSource: null,
|
lastResolvedSource: 'github-release',
|
||||||
lastResolvedDisplayVersion: null,
|
lastResolvedDisplayVersion: '1.3.0',
|
||||||
lastResolvedCandidateKey: null,
|
lastResolvedCandidateKey: 'github-release:1.3.0',
|
||||||
lastNotifiedCandidateKey: null,
|
lastNotifiedCandidateKey: null,
|
||||||
lastNotifiedAt: 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 () => {
|
it('dedupes deploy requests while a task is already running', async () => {
|
||||||
await saveValidConfig();
|
await saveValidConfig();
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ import {
|
|||||||
streamUpdateCenterDeploy,
|
streamUpdateCenterDeploy,
|
||||||
streamUpdateCenterRollback,
|
streamUpdateCenterRollback,
|
||||||
} from '../../services/updateCenterHelperClient.js';
|
} from '../../services/updateCenterHelperClient.js';
|
||||||
import { buildUpdateCenterStatus } from '../../services/updateCenterStatusService.js';
|
import {
|
||||||
|
getUpdateCenterStatus,
|
||||||
|
refreshUpdateCenterStatusCache,
|
||||||
|
} from '../../services/updateCenterStatusService.js';
|
||||||
import {
|
import {
|
||||||
UPDATE_CENTER_DEPLOY_DEDUPE_KEY,
|
UPDATE_CENTER_DEPLOY_DEDUPE_KEY,
|
||||||
UPDATE_CENTER_DEPLOY_TASK_TYPE,
|
UPDATE_CENTER_DEPLOY_TASK_TYPE,
|
||||||
@@ -74,11 +77,11 @@ function writeSseEvent(reply: { raw: NodeJS.WritableStream & { write: (chunk: st
|
|||||||
|
|
||||||
export async function updateCenterRoutes(app: FastifyInstance) {
|
export async function updateCenterRoutes(app: FastifyInstance) {
|
||||||
app.get('/api/update-center/status', async () => {
|
app.get('/api/update-center/status', async () => {
|
||||||
return await buildUpdateCenterStatus();
|
return await getUpdateCenterStatus();
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/update-center/check', async () => {
|
app.post('/api/update-center/check', async () => {
|
||||||
return await buildUpdateCenterStatus();
|
return (await refreshUpdateCenterStatusCache()).status;
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put<{ Body: UpdateCenterConfig }>('/api/update-center/config', async (request) => {
|
app.put<{ Body: UpdateCenterConfig }>('/api/update-center/config', async (request) => {
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import { join } from 'node:path';
|
|||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
buildUpdateCenterStatusMock,
|
refreshUpdateCenterStatusCacheMock,
|
||||||
sendNotificationMock,
|
sendNotificationMock,
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
buildUpdateCenterStatusMock: vi.fn(),
|
refreshUpdateCenterStatusCacheMock: vi.fn(),
|
||||||
sendNotificationMock: vi.fn(),
|
sendNotificationMock: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./updateCenterStatusService.js', () => ({
|
vi.mock('./updateCenterStatusService.js', () => ({
|
||||||
buildUpdateCenterStatus: (...args: unknown[]) => buildUpdateCenterStatusMock(...args),
|
refreshUpdateCenterStatusCache: (...args: unknown[]) => refreshUpdateCenterStatusCacheMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./notifyService.js', () => ({
|
vi.mock('./notifyService.js', () => ({
|
||||||
@@ -51,7 +51,7 @@ describe('updateCenterPollingService', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
buildUpdateCenterStatusMock.mockReset();
|
refreshUpdateCenterStatusCacheMock.mockReset();
|
||||||
sendNotificationMock.mockReset();
|
sendNotificationMock.mockReset();
|
||||||
|
|
||||||
await db.delete(schema.events).run();
|
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 () => {
|
it('runs immediately, writes one event, and only notifies once for the same candidate', async () => {
|
||||||
buildUpdateCenterStatusMock.mockResolvedValue({
|
refreshUpdateCenterStatusCacheMock.mockImplementation(async () => {
|
||||||
currentVersion: '1.2.3',
|
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: {
|
githubRelease: {
|
||||||
|
source: 'github-release' as const,
|
||||||
|
rawVersion: 'v1.3.0',
|
||||||
normalizedVersion: '1.3.0',
|
normalizedVersion: '1.3.0',
|
||||||
displayVersion: '1.3.0',
|
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0',
|
||||||
tagName: 'v1.3.0',
|
tagName: 'v1.3.0',
|
||||||
|
digest: null,
|
||||||
|
displayVersion: '1.3.0',
|
||||||
|
publishedAt: '2026-03-31T12:00:00Z',
|
||||||
},
|
},
|
||||||
dockerHubTag: null,
|
dockerHubTag: null,
|
||||||
helper: {
|
helper: {
|
||||||
|
ok: true,
|
||||||
|
releaseName: 'metapi',
|
||||||
|
namespace: 'ai',
|
||||||
|
revision: '12',
|
||||||
|
imageRepository: '1467078763/metapi',
|
||||||
imageTag: '1.2.3',
|
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);
|
startUpdateCenterPolling(60_000);
|
||||||
@@ -109,6 +151,15 @@ describe('updateCenterPollingService', () => {
|
|||||||
lastResolvedSource: 'github-release',
|
lastResolvedSource: 'github-release',
|
||||||
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
||||||
lastNotifiedCandidateKey: '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);
|
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 () => {
|
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);
|
startUpdateCenterPolling(60_000);
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
@@ -133,19 +184,60 @@ describe('updateCenterPollingService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('persists the notified candidate even when the downstream notification send fails', async () => {
|
it('persists the notified candidate even when the downstream notification send fails', async () => {
|
||||||
buildUpdateCenterStatusMock.mockImplementation(async () => ({
|
refreshUpdateCenterStatusCacheMock.mockImplementation(async () => {
|
||||||
currentVersion: '1.2.3',
|
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: {
|
githubRelease: {
|
||||||
|
source: 'github-release' as const,
|
||||||
|
rawVersion: 'v1.3.0',
|
||||||
normalizedVersion: '1.3.0',
|
normalizedVersion: '1.3.0',
|
||||||
displayVersion: '1.3.0',
|
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0',
|
||||||
tagName: 'v1.3.0',
|
tagName: 'v1.3.0',
|
||||||
|
digest: null,
|
||||||
|
displayVersion: '1.3.0',
|
||||||
|
publishedAt: '2026-03-31T12:01:00Z',
|
||||||
},
|
},
|
||||||
dockerHubTag: null,
|
dockerHubTag: null,
|
||||||
helper: {
|
helper: {
|
||||||
|
ok: true,
|
||||||
|
releaseName: 'metapi',
|
||||||
|
namespace: 'ai',
|
||||||
|
revision: '12',
|
||||||
|
imageRepository: '1467078763/metapi',
|
||||||
imageTag: '1.2.3',
|
imageTag: '1.2.3',
|
||||||
|
imageDigest: null,
|
||||||
|
healthy: true,
|
||||||
|
history: [],
|
||||||
},
|
},
|
||||||
runtime: await loadUpdateCenterRuntimeState(),
|
},
|
||||||
}));
|
};
|
||||||
|
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'));
|
sendNotificationMock.mockRejectedValue(new Error('notification downstream failed'));
|
||||||
|
|
||||||
startUpdateCenterPolling(60_000);
|
startUpdateCenterPolling(60_000);
|
||||||
@@ -155,10 +247,19 @@ describe('updateCenterPollingService', () => {
|
|||||||
expect(sendNotificationMock).toHaveBeenCalledTimes(1);
|
expect(sendNotificationMock).toHaveBeenCalledTimes(1);
|
||||||
expect(await db.select().from(schema.events).all()).toHaveLength(1);
|
expect(await db.select().from(schema.events).all()).toHaveLength(1);
|
||||||
expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({
|
expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({
|
||||||
lastCheckError: null,
|
lastCheckError: 'notification downstream failed',
|
||||||
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
||||||
lastNotifiedCandidateKey: 'github-release:v1.3.0',
|
lastNotifiedCandidateKey: 'github-release:v1.3.0',
|
||||||
lastNotifiedAt: expect.any(String),
|
lastNotifiedAt: expect.any(String),
|
||||||
|
statusSnapshot: {
|
||||||
|
githubRelease: expect.objectContaining({
|
||||||
|
normalizedVersion: '1.3.0',
|
||||||
|
}),
|
||||||
|
dockerHubTag: null,
|
||||||
|
helper: expect.objectContaining({
|
||||||
|
imageTag: '1.2.3',
|
||||||
|
}),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
import { formatUtcSqlDateTime } from './localTimeService.js';
|
import { formatUtcSqlDateTime } from './localTimeService.js';
|
||||||
import { sendNotification } from './notifyService.js';
|
import { sendNotification } from './notifyService.js';
|
||||||
import { buildUpdateCenterStatus } from './updateCenterStatusService.js';
|
import { refreshUpdateCenterStatusCache } from './updateCenterStatusService.js';
|
||||||
import { loadUpdateCenterRuntimeState, saveUpdateCenterRuntimeState } from './updateCenterRuntimeStateService.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;
|
const DEFAULT_UPDATE_CENTER_INTERVAL_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ function summarizeError(error: unknown) {
|
|||||||
return String(error || 'unknown error');
|
return String(error || 'unknown error');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildReminderEvent(candidate: ReturnType<typeof resolveUpdateReminderCandidate>) {
|
function buildReminderEvent(candidate: UpdateReminderCandidate | null) {
|
||||||
if (!candidate) return null;
|
if (!candidate) return null;
|
||||||
const title = candidate.kind === 'new-digest'
|
const title = candidate.kind === 'new-digest'
|
||||||
? '更新中心发现新 digest'
|
? '更新中心发现新 digest'
|
||||||
@@ -35,23 +35,11 @@ async function runSyncOnce() {
|
|||||||
const checkedAt = formatUtcSqlDateTime(new Date());
|
const checkedAt = formatUtcSqlDateTime(new Date());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await buildUpdateCenterStatus();
|
const {
|
||||||
const previousRuntime = status.runtime || await loadUpdateCenterRuntimeState();
|
candidate,
|
||||||
const candidate = resolveUpdateReminderCandidate({
|
previousRuntime,
|
||||||
currentVersion: status.currentVersion,
|
runtime,
|
||||||
helper: status.helper as { imageTag?: string | null; imageDigest?: string | null } | null,
|
} = await refreshUpdateCenterStatusCache(checkedAt);
|
||||||
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) {
|
if (candidate && candidate.candidateKey !== previousRuntime.lastNotifiedCandidateKey) {
|
||||||
const reminderEvent = buildReminderEvent(candidate);
|
const reminderEvent = buildReminderEvent(candidate);
|
||||||
@@ -64,16 +52,16 @@ async function runSyncOnce() {
|
|||||||
relatedType: 'update_center',
|
relatedType: 'update_center',
|
||||||
createdAt: checkedAt,
|
createdAt: checkedAt,
|
||||||
}).run();
|
}).run();
|
||||||
nextRuntime.lastNotifiedCandidateKey = candidate.candidateKey;
|
await saveUpdateCenterRuntimeState({
|
||||||
nextRuntime.lastNotifiedAt = checkedAt;
|
...runtime,
|
||||||
await saveUpdateCenterRuntimeState(nextRuntime);
|
lastNotifiedCandidateKey: candidate.candidateKey,
|
||||||
|
lastNotifiedAt: checkedAt,
|
||||||
|
});
|
||||||
await sendNotification(reminderEvent.title, reminderEvent.message, 'info', {
|
await sendNotification(reminderEvent.title, reminderEvent.message, 'info', {
|
||||||
bypassThrottle: true,
|
bypassThrottle: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await saveUpdateCenterRuntimeState(nextRuntime);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const previousRuntime = await loadUpdateCenterRuntimeState();
|
const previousRuntime = await loadUpdateCenterRuntimeState();
|
||||||
await saveUpdateCenterRuntimeState({
|
await saveUpdateCenterRuntimeState({
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ describe('updateCenterRuntimeStateService', () => {
|
|||||||
lastResolvedCandidateKey: null,
|
lastResolvedCandidateKey: null,
|
||||||
lastNotifiedCandidateKey: null,
|
lastNotifiedCandidateKey: null,
|
||||||
lastNotifiedAt: null,
|
lastNotifiedAt: null,
|
||||||
|
statusSnapshot: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -66,6 +67,50 @@ describe('updateCenterRuntimeStateService', () => {
|
|||||||
lastResolvedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
lastResolvedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||||
lastNotifiedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
lastNotifiedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||||
lastNotifiedAt: '2026-03-30 20:31:00',
|
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({
|
await expect(loadUpdateCenterRuntimeState()).resolves.toEqual({
|
||||||
@@ -76,6 +121,50 @@ describe('updateCenterRuntimeStateService', () => {
|
|||||||
lastResolvedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
lastResolvedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||||
lastNotifiedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
lastNotifiedCandidateKey: 'docker-hub-tag:latest@sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||||
lastNotifiedAt: '2026-03-30 20:31:00',
|
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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { eq } from 'drizzle-orm';
|
|||||||
|
|
||||||
import { db, schema } from '../db/index.js';
|
import { db, schema } from '../db/index.js';
|
||||||
import { upsertSetting } from '../db/upsertSetting.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 = {
|
export type UpdateCenterRuntimeState = {
|
||||||
lastCheckedAt: string | null;
|
lastCheckedAt: string | null;
|
||||||
@@ -12,6 +19,7 @@ export type UpdateCenterRuntimeState = {
|
|||||||
lastResolvedCandidateKey: string | null;
|
lastResolvedCandidateKey: string | null;
|
||||||
lastNotifiedCandidateKey: string | null;
|
lastNotifiedCandidateKey: string | null;
|
||||||
lastNotifiedAt: string | null;
|
lastNotifiedAt: string | null;
|
||||||
|
statusSnapshot: UpdateCenterStatusSnapshot | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UPDATE_CENTER_RUNTIME_STATE_SETTING_KEY = 'update_center_runtime_state_v1';
|
export const UPDATE_CENTER_RUNTIME_STATE_SETTING_KEY = 'update_center_runtime_state_v1';
|
||||||
@@ -25,6 +33,7 @@ export function getDefaultUpdateCenterRuntimeState(): UpdateCenterRuntimeState {
|
|||||||
lastResolvedCandidateKey: null,
|
lastResolvedCandidateKey: null,
|
||||||
lastNotifiedCandidateKey: null,
|
lastNotifiedCandidateKey: null,
|
||||||
lastNotifiedAt: 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;
|
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<string, unknown>;
|
||||||
|
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<UpdateCenterHelperStatus['history']>[number] | null {
|
||||||
|
if (!input || typeof input !== 'object') return null;
|
||||||
|
const record = input as Record<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
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<UpdateCenterHelperStatus['history']>[number] => !!entry)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStatusSnapshot(input: unknown): UpdateCenterStatusSnapshot | null {
|
||||||
|
if (!input || typeof input !== 'object') return null;
|
||||||
|
const record = input as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
githubRelease: normalizeVersionCandidate(record.githubRelease),
|
||||||
|
dockerHubTag: normalizeVersionCandidate(record.dockerHubTag),
|
||||||
|
helper: normalizeHelperSnapshot(record.helper),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeUpdateCenterRuntimeState(input: unknown): UpdateCenterRuntimeState {
|
export function normalizeUpdateCenterRuntimeState(input: unknown): UpdateCenterRuntimeState {
|
||||||
const defaults = getDefaultUpdateCenterRuntimeState();
|
const defaults = getDefaultUpdateCenterRuntimeState();
|
||||||
const record = input && typeof input === 'object' ? input as Record<string, unknown> : {};
|
const record = input && typeof input === 'object' ? input as Record<string, unknown> : {};
|
||||||
@@ -49,6 +124,9 @@ export function normalizeUpdateCenterRuntimeState(input: unknown): UpdateCenterR
|
|||||||
lastResolvedCandidateKey: normalizeNullableString(record.lastResolvedCandidateKey) ?? defaults.lastResolvedCandidateKey,
|
lastResolvedCandidateKey: normalizeNullableString(record.lastResolvedCandidateKey) ?? defaults.lastResolvedCandidateKey,
|
||||||
lastNotifiedCandidateKey: normalizeNullableString(record.lastNotifiedCandidateKey) ?? defaults.lastNotifiedCandidateKey,
|
lastNotifiedCandidateKey: normalizeNullableString(record.lastNotifiedCandidateKey) ?? defaults.lastNotifiedCandidateKey,
|
||||||
lastNotifiedAt: normalizeNullableString(record.lastNotifiedAt) ?? defaults.lastNotifiedAt,
|
lastNotifiedAt: normalizeNullableString(record.lastNotifiedAt) ?? defaults.lastNotifiedAt,
|
||||||
|
statusSnapshot: Object.prototype.hasOwnProperty.call(record, 'statusSnapshot')
|
||||||
|
? normalizeStatusSnapshot(record.statusSnapshot)
|
||||||
|
: defaults.statusSnapshot,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { config as runtimeConfig } from '../config.js';
|
import { config as runtimeConfig } from '../config.js';
|
||||||
|
import { formatUtcSqlDateTime } from './localTimeService.js';
|
||||||
import { listBackgroundTasks } from './backgroundTaskService.js';
|
import { listBackgroundTasks } from './backgroundTaskService.js';
|
||||||
import {
|
import {
|
||||||
fetchLatestDockerHubTag,
|
fetchLatestDockerHubTag,
|
||||||
@@ -7,13 +8,21 @@ import {
|
|||||||
type UpdateCenterVersionCandidate,
|
type UpdateCenterVersionCandidate,
|
||||||
} from './updateCenterVersionService.js';
|
} from './updateCenterVersionService.js';
|
||||||
import {
|
import {
|
||||||
|
type UpdateCenterConfig,
|
||||||
loadUpdateCenterConfig,
|
loadUpdateCenterConfig,
|
||||||
} from './updateCenterConfigService.js';
|
} from './updateCenterConfigService.js';
|
||||||
import {
|
import {
|
||||||
getUpdateCenterHelperStatus,
|
getUpdateCenterHelperStatus,
|
||||||
|
type UpdateCenterHelperStatus,
|
||||||
} from './updateCenterHelperClient.js';
|
} 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 { UPDATE_CENTER_DEPLOY_TASK_TYPE } from './updateCenterTaskConstants.js';
|
||||||
|
import { resolveUpdateReminderCandidate, type UpdateReminderCandidate } from './updateCenterReminderService.js';
|
||||||
|
|
||||||
function getUpdateCenterHelperToken(): string {
|
function getUpdateCenterHelperToken(): string {
|
||||||
return String(
|
return String(
|
||||||
@@ -57,7 +66,85 @@ function getDeployTasks() {
|
|||||||
return listBackgroundTasks(50).filter((task) => task.type === UPDATE_CENTER_DEPLOY_TASK_TYPE);
|
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<typeof getDeployTasks>[number] | null;
|
||||||
|
lastFinishedTask: ReturnType<typeof getDeployTasks>[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<UpdateCenterStatusResult, 'githubRelease' | 'dockerHubTag' | 'helper'>): UpdateCenterStatusSnapshot {
|
||||||
|
return {
|
||||||
|
githubRelease: status.githubRelease || null,
|
||||||
|
dockerHubTag: status.dockerHubTag || null,
|
||||||
|
helper: status.helper || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNextRuntimeState(
|
||||||
|
status: Pick<UpdateCenterStatusResult, 'currentVersion' | 'githubRelease' | 'dockerHubTag' | 'helper'>,
|
||||||
|
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<UpdateCenterStatusResult> {
|
||||||
const config = await loadUpdateCenterConfig();
|
const config = await loadUpdateCenterConfig();
|
||||||
const helperToken = getUpdateCenterHelperToken();
|
const helperToken = getUpdateCenterHelperToken();
|
||||||
|
|
||||||
@@ -75,11 +162,7 @@ export async function buildUpdateCenterStatus() {
|
|||||||
|
|
||||||
const githubRelease = githubLookup.value as UpdateCenterVersionCandidate | null;
|
const githubRelease = githubLookup.value as UpdateCenterVersionCandidate | null;
|
||||||
const dockerHubTag = dockerLookup.value as UpdateCenterVersionCandidate | null;
|
const dockerHubTag = dockerLookup.value as UpdateCenterVersionCandidate | null;
|
||||||
const helper: Record<string, unknown> = helperLookup.value || {
|
const helper = (helperLookup.value as UpdateCenterHelperStatus | null) || buildUnavailableHelperStatus(helperLookup.error);
|
||||||
ok: false,
|
|
||||||
healthy: false,
|
|
||||||
error: helperLookup.error,
|
|
||||||
};
|
|
||||||
|
|
||||||
const tasks = getDeployTasks();
|
const tasks = getDeployTasks();
|
||||||
const runningTask = tasks.find((task) => task.status === 'pending' || task.status === 'running') || null;
|
const runningTask = tasks.find((task) => task.status === 'pending' || task.status === 'running') || null;
|
||||||
@@ -96,3 +179,40 @@ export async function buildUpdateCenterStatus() {
|
|||||||
runtime,
|
runtime,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function buildCachedUpdateCenterStatus(): Promise<UpdateCenterStatusResult> {
|
||||||
|
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<UpdateCenterStatusResult> {
|
||||||
|
const cached = await buildCachedUpdateCenterStatus();
|
||||||
|
if (cached.runtime.statusSnapshot) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
return (await refreshUpdateCenterStatusCache()).status;
|
||||||
|
}
|
||||||
|
|||||||
@@ -136,6 +136,18 @@ describe('UpdateCenterSection', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
apiMock.checkUpdateCenter.mockResolvedValue({
|
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: {
|
githubRelease: {
|
||||||
normalizedVersion: '1.3.0',
|
normalizedVersion: '1.3.0',
|
||||||
displayVersion: '1.3.0',
|
displayVersion: '1.3.0',
|
||||||
@@ -147,6 +159,25 @@ describe('UpdateCenterSection', () => {
|
|||||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||||
publishedAt: '2026-03-29T11:54:35.591877Z',
|
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({
|
apiMock.deployUpdateCenter.mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -259,6 +290,7 @@ describe('UpdateCenterSection', () => {
|
|||||||
targetDigest: null,
|
targetDigest: null,
|
||||||
});
|
});
|
||||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
||||||
|
expect(apiMock.checkUpdateCenter).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const text = collectText(root.root);
|
const text = collectText(root.root);
|
||||||
expect(text).toContain('latest @ sha256:efb2ee655386');
|
expect(text).toContain('latest @ sha256:efb2ee655386');
|
||||||
@@ -501,10 +533,10 @@ describe('UpdateCenterSection', () => {
|
|||||||
targetRevision: '16',
|
targetRevision: '16',
|
||||||
});
|
});
|
||||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-2', expect.any(Object));
|
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-2', expect.any(Object));
|
||||||
|
expect(apiMock.checkUpdateCenter).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const text = collectText(root.root);
|
const text = collectText(root.root);
|
||||||
expect(text).toContain('sha256:bbbbbbbbbbbb');
|
expect(text).toContain('最近状态:succeeded');
|
||||||
expect(text).toContain('Rollback to stable digest');
|
|
||||||
} finally {
|
} finally {
|
||||||
root?.unmount();
|
root?.unmount();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,12 +228,16 @@ export default function UpdateCenterSection() {
|
|||||||
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
||||||
const streamAbortRef = useRef<AbortController | null>(null);
|
const streamAbortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const applyStatus = (next: UpdateCenterStatus) => {
|
||||||
|
setStatus(next);
|
||||||
|
setConfig(next.config || DEFAULT_CONFIG);
|
||||||
|
};
|
||||||
|
|
||||||
const loadStatus = async () => {
|
const loadStatus = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const next = await api.getUpdateCenterStatus() as UpdateCenterStatus;
|
const next = await api.getUpdateCenterStatus() as UpdateCenterStatus;
|
||||||
setStatus(next);
|
applyStatus(next);
|
||||||
setConfig(next.config || DEFAULT_CONFIG);
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error?.message || '加载更新中心失败');
|
toast.error(error?.message || '加载更新中心失败');
|
||||||
} finally {
|
} 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(() => {
|
useEffect(() => {
|
||||||
void loadStatus();
|
void loadStatus();
|
||||||
return () => {
|
return () => {
|
||||||
@@ -269,12 +286,10 @@ export default function UpdateCenterSection() {
|
|||||||
const checkNow = async () => {
|
const checkNow = async () => {
|
||||||
setChecking(true);
|
setChecking(true);
|
||||||
try {
|
try {
|
||||||
const next = await api.checkUpdateCenter() as UpdateCenterStatus;
|
await refreshStatus(true);
|
||||||
setStatus(next);
|
|
||||||
setConfig(next.config || DEFAULT_CONFIG);
|
|
||||||
toast.success('已刷新更新信息');
|
toast.success('已刷新更新信息');
|
||||||
} catch (error: any) {
|
} catch {
|
||||||
toast.error(error?.message || '检查更新失败');
|
// refreshStatus already handled the toast
|
||||||
} finally {
|
} finally {
|
||||||
setChecking(false);
|
setChecking(false);
|
||||||
}
|
}
|
||||||
@@ -343,7 +358,7 @@ export default function UpdateCenterSection() {
|
|||||||
toast.error(error?.message || '部署失败');
|
toast.error(error?.message || '部署失败');
|
||||||
} finally {
|
} finally {
|
||||||
setDeploying(false);
|
setDeploying(false);
|
||||||
void loadStatus();
|
void refreshStatus(false).catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -378,7 +393,7 @@ export default function UpdateCenterSection() {
|
|||||||
toast.error(error?.message || '回退失败');
|
toast.error(error?.message || '回退失败');
|
||||||
} finally {
|
} finally {
|
||||||
setDeploying(false);
|
setDeploying(false);
|
||||||
void loadStatus();
|
void refreshStatus(false).catch(() => {});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user