forked from boos/BoosAPI
[codex] add automatic update-center reminders (#333)
* add automatic update-center reminders * fix update-center reminder review issues
This commit is contained in:
@@ -265,6 +265,15 @@ Metapi 的路由引擎按多因子加权选择最优通道。
|
|||||||
- 当前支持的上游公告来源包括 `new-api`、`done-hub` 与 `sub2api`
|
- 当前支持的上游公告来源包括 `new-api`、`done-hub` 与 `sub2api`
|
||||||
- 「清空公告」只删除 Metapi 本地保存的公告记录,不会修改上游站点数据
|
- 「清空公告」只删除 Metapi 本地保存的公告记录,不会修改上游站点数据
|
||||||
|
|
||||||
|
## 更新提醒
|
||||||
|
|
||||||
|
更新中心现在会在后台定时检查 GitHub Releases / Docker Hub 的可部署候选,并把结果保存为本地运行时状态。
|
||||||
|
|
||||||
|
- 首次发现新的版本候选或新的 Docker digest 时,会写入站内通知,并按现有通知渠道外发一次
|
||||||
|
- 相同候选后续重复检查只更新本地运行时状态,不会重复外发同一条提醒
|
||||||
|
- 这类提醒不会自动触发部署,只是把用户带到「设置 → 更新中心」继续手动确认和执行
|
||||||
|
- K3s 用户可以在收到提醒后直接去更新中心部署;Compose 用户也可以收到提醒,但仍按自己的升级方式处理
|
||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
|
|
||||||
- [部署指南](./deployment.md) — Docker Compose 与反向代理
|
- [部署指南](./deployment.md) — Docker Compose 与反向代理
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ docker compose up -d
|
|||||||
|
|
||||||
- 在后台里看当前运行版本
|
- 在后台里看当前运行版本
|
||||||
- 看官方 GitHub Releases / Docker Hub 有没有新的稳定版
|
- 看官方 GitHub Releases / Docker Hub 有没有新的稳定版
|
||||||
|
- 在后台定时检查新版本,并在首次发现时提醒一次
|
||||||
- 确认 Deploy Helper 是否健康
|
- 确认 Deploy Helper 是否健康
|
||||||
- 点一次按钮,触发一轮 Helm 升级
|
- 点一次按钮,触发一轮 Helm 升级
|
||||||
- 在页面里回看部署日志
|
- 在页面里回看部署日志
|
||||||
@@ -276,7 +277,7 @@ helper 端使用:
|
|||||||
推荐这样用:
|
推荐这样用:
|
||||||
|
|
||||||
1. 先保存配置
|
1. 先保存配置
|
||||||
2. 再点“检查更新”
|
2. 等后台自动发现新版本,或手动点一次“检查更新”立即刷新
|
||||||
3. 确认页面状态都正常:
|
3. 确认页面状态都正常:
|
||||||
- 当前运行版本正常显示
|
- 当前运行版本正常显示
|
||||||
- 版本来源发现了可部署版本
|
- 版本来源发现了可部署版本
|
||||||
|
|||||||
+5
-1
@@ -33,7 +33,8 @@ import { ensureDefaultSitesSeeded } from './services/defaultSiteSeedService.js';
|
|||||||
import { ensureOauthIdentityBackfill } from './services/oauth/oauthIdentityBackfill.js';
|
import { ensureOauthIdentityBackfill } from './services/oauth/oauthIdentityBackfill.js';
|
||||||
import { ensureOauthProviderSitesExist } from './services/oauth/oauthSiteRegistry.js';
|
import { ensureOauthProviderSitesExist } from './services/oauth/oauthSiteRegistry.js';
|
||||||
import { startOAuthLoopbackCallbackServers, stopOAuthLoopbackCallbackServers } from './services/oauth/localCallbackServer.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 { reloadBackupWebdavScheduler } from './services/backupService.js';
|
||||||
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
|
import { ensureRuntimeDatabaseReady } from './runtimeDatabaseBootstrap.js';
|
||||||
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
import { isPublicApiRoute, registerDesktopRoutes } from './desktop.js';
|
||||||
@@ -483,6 +484,7 @@ if (existsSync(webDir)) {
|
|||||||
await startScheduler();
|
await startScheduler();
|
||||||
await reloadBackupWebdavScheduler();
|
await reloadBackupWebdavScheduler();
|
||||||
startSiteAnnouncementPolling();
|
startSiteAnnouncementPolling();
|
||||||
|
startUpdateCenterPolling();
|
||||||
try {
|
try {
|
||||||
await startOAuthLoopbackCallbackServers();
|
await startOAuthLoopbackCallbackServers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -491,6 +493,8 @@ try {
|
|||||||
setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured);
|
setLegacyProxyLogRetentionFallbackEnabled(!config.logCleanupConfigured);
|
||||||
startProxyFileRetentionService();
|
startProxyFileRetentionService();
|
||||||
app.addHook('onClose', async () => {
|
app.addHook('onClose', async () => {
|
||||||
|
stopSiteAnnouncementPolling();
|
||||||
|
stopUpdateCenterPolling();
|
||||||
stopProxyFileRetentionService();
|
stopProxyFileRetentionService();
|
||||||
stopProxyLogRetentionService();
|
stopProxyLogRetentionService();
|
||||||
await stopOAuthLoopbackCallbackServers();
|
await stopOAuthLoopbackCallbackServers();
|
||||||
|
|||||||
@@ -200,6 +200,15 @@ describe('update center routes', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
runtime: {
|
||||||
|
lastCheckedAt: null,
|
||||||
|
lastCheckError: null,
|
||||||
|
lastResolvedSource: null,
|
||||||
|
lastResolvedDisplayVersion: null,
|
||||||
|
lastResolvedCandidateKey: null,
|
||||||
|
lastNotifiedCandidateKey: null,
|
||||||
|
lastNotifiedAt: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,10 @@ import { config as runtimeConfig } from '../../config.js';
|
|||||||
import {
|
import {
|
||||||
appendBackgroundTaskLog,
|
appendBackgroundTaskLog,
|
||||||
getBackgroundTask,
|
getBackgroundTask,
|
||||||
listBackgroundTasks,
|
|
||||||
startBackgroundTask,
|
startBackgroundTask,
|
||||||
subscribeToBackgroundTaskLogs,
|
subscribeToBackgroundTaskLogs,
|
||||||
} from '../../services/backgroundTaskService.js';
|
} from '../../services/backgroundTaskService.js';
|
||||||
import {
|
import { type UpdateCenterVersionSource } from '../../services/updateCenterVersionService.js';
|
||||||
fetchLatestDockerHubTag,
|
|
||||||
fetchLatestStableGitHubRelease,
|
|
||||||
getCurrentRuntimeVersion,
|
|
||||||
type UpdateCenterVersionCandidate,
|
|
||||||
type UpdateCenterVersionSource,
|
|
||||||
} from '../../services/updateCenterVersionService.js';
|
|
||||||
import {
|
import {
|
||||||
getUpdateCenterDeployBlockMessage,
|
getUpdateCenterDeployBlockMessage,
|
||||||
normalizeUpdateCenterTargetDigest,
|
normalizeUpdateCenterTargetDigest,
|
||||||
@@ -27,10 +20,14 @@ import {
|
|||||||
type UpdateCenterConfig,
|
type UpdateCenterConfig,
|
||||||
} from '../../services/updateCenterConfigService.js';
|
} from '../../services/updateCenterConfigService.js';
|
||||||
import {
|
import {
|
||||||
getUpdateCenterHelperStatus,
|
|
||||||
streamUpdateCenterDeploy,
|
streamUpdateCenterDeploy,
|
||||||
streamUpdateCenterRollback,
|
streamUpdateCenterRollback,
|
||||||
} from '../../services/updateCenterHelperClient.js';
|
} 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 = {
|
type DeployBody = {
|
||||||
source?: UpdateCenterVersionSource;
|
source?: UpdateCenterVersionSource;
|
||||||
@@ -43,9 +40,6 @@ type RollbackBody = {
|
|||||||
targetRevision?: string;
|
targetRevision?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UPDATE_CENTER_DEPLOY_TASK_TYPE = 'update-center.deploy';
|
|
||||||
const UPDATE_CENTER_DEPLOY_DEDUPE_KEY = 'update-center.deploy';
|
|
||||||
|
|
||||||
function getUpdateCenterHelperToken(): string {
|
function getUpdateCenterHelperToken(): string {
|
||||||
return String(
|
return String(
|
||||||
runtimeConfig.deployHelperToken
|
runtimeConfig.deployHelperToken
|
||||||
@@ -69,77 +63,11 @@ function assertDeployableConfig(config: UpdateCenterConfig) {
|
|||||||
if (!config.imageRepository) throw new Error('imageRepository is required');
|
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) {
|
function summarizeHelperError(error: unknown) {
|
||||||
if (error instanceof Error && error.message) return error.message;
|
if (error instanceof Error && error.message) return error.message;
|
||||||
return String(error || 'unknown helper error');
|
return String(error || 'unknown helper error');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function settleOptional<T>(enabled: boolean, loader: () => Promise<T>): 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<string, unknown> = 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) {
|
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`);
|
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof setInterval> | 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<typeof resolveUpdateReminderCandidate>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export type {
|
||||||
|
UpdateHelperRuntimeLike,
|
||||||
|
UpdateReminderCandidate,
|
||||||
|
UpdateVersionCandidateLike,
|
||||||
|
} from '../shared/updateCenterReminder.js';
|
||||||
|
export { resolveUpdateReminderCandidate } from '../shared/updateCenterReminder.js';
|
||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown> : {};
|
||||||
|
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<UpdateCenterRuntimeState> {
|
||||||
|
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<UpdateCenterRuntimeState> {
|
||||||
|
const next = normalizeUpdateCenterRuntimeState(input);
|
||||||
|
await upsertSetting(UPDATE_CENTER_RUNTIME_STATE_SETTING_KEY, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -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<T>(enabled: boolean, loader: () => Promise<T>): 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<string, unknown> = 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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';
|
||||||
@@ -76,6 +76,12 @@ describe('navigationFocus helpers', () => {
|
|||||||
type: 'proxy',
|
type: 'proxy',
|
||||||
})).toBe('/routes');
|
})).toBe('/routes');
|
||||||
|
|
||||||
|
expect(buildEventNavigationPath({
|
||||||
|
relatedType: 'update_center',
|
||||||
|
relatedId: null,
|
||||||
|
type: 'status',
|
||||||
|
})).toBe('/settings');
|
||||||
|
|
||||||
expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs');
|
expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs');
|
||||||
expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin');
|
expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin');
|
||||||
expect(buildEventNavigationPath({ type: 'site_notice' })).toBe('/site-announcements');
|
expect(buildEventNavigationPath({ type: 'site_notice' })).toBe('/site-announcements');
|
||||||
|
|||||||
@@ -98,6 +98,9 @@ export function buildEventNavigationPath(event: {
|
|||||||
if (relatedType === 'site_announcement' && relatedId) {
|
if (relatedType === 'site_announcement' && relatedId) {
|
||||||
return buildAnnouncementFocusPath(relatedId);
|
return buildAnnouncementFocusPath(relatedId);
|
||||||
}
|
}
|
||||||
|
if (relatedType === 'update_center') {
|
||||||
|
return '/settings';
|
||||||
|
}
|
||||||
if (relatedType === 'route') {
|
if (relatedType === 'route') {
|
||||||
return '/routes';
|
return '/routes';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,4 +40,44 @@ describe('updateCenterPresentation', () => {
|
|||||||
expect(state.canDeploy).toBe(true);
|
expect(state.canDeploy).toBe(true);
|
||||||
expect(state.badgeLabel).toBe('发现新 digest');
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,10 @@
|
|||||||
export type UpdateVersionCandidateLike = {
|
import {
|
||||||
normalizedVersion?: string | null;
|
compareStableVersions,
|
||||||
displayVersion?: string | null;
|
isSameImageTarget,
|
||||||
tagName?: string | null;
|
resolveUpdateReminderCandidate,
|
||||||
digest?: string | null;
|
type UpdateHelperRuntimeLike,
|
||||||
};
|
type UpdateVersionCandidateLike,
|
||||||
|
} from '../../../shared/updateCenterReminder.js';
|
||||||
export type UpdateHelperRuntimeLike = {
|
|
||||||
imageTag?: string | null;
|
|
||||||
imageDigest?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type UpdateDeployState = {
|
export type UpdateDeployState = {
|
||||||
kind: 'disabled' | 'missing' | 'helper-unhealthy' | 'same-version' | 'same-image' | 'new-version' | 'new-digest' | 'available';
|
kind: 'disabled' | 'missing' | 'helper-unhealthy' | 'same-version' | 'same-image' | 'new-version' | 'new-digest' | 'available';
|
||||||
@@ -26,8 +22,6 @@ export type UpdateReminder = {
|
|||||||
highlight: boolean;
|
highlight: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
|
||||||
|
|
||||||
function normalizeString(value?: string | null): string {
|
function normalizeString(value?: string | null): string {
|
||||||
return String(value || '').trim();
|
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() : '';
|
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: {
|
export function describeGitHubDeployState(input: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
helperHealthy: boolean;
|
helperHealthy: boolean;
|
||||||
@@ -127,14 +74,17 @@ export function describeGitHubDeployState(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const versionCompare = compareStableVersions(input.currentVersion, candidateVersion || candidateTag);
|
const candidateTargetVersion = candidateVersion || candidateTag;
|
||||||
const helperVersionCompare = compareStableVersions(input.helperImageTag, candidateTag);
|
const versionCompare = compareStableVersions(input.currentVersion, candidateTargetVersion);
|
||||||
if (versionCompare === 0 || helperVersionCompare === 0) {
|
const helperVersionCompare = compareStableVersions(input.helperImageTag, candidateTargetVersion);
|
||||||
|
if (versionCompare === 0 || helperVersionCompare === 0 || helperVersionCompare === 1) {
|
||||||
return {
|
return {
|
||||||
kind: 'same-version',
|
kind: 'same-version',
|
||||||
badgeClassName: 'badge badge-muted',
|
badgeClassName: 'badge badge-muted',
|
||||||
badgeLabel: '当前运行',
|
badgeLabel: '当前运行',
|
||||||
reason: '当前已运行该版本,无需重复部署。',
|
reason: helperVersionCompare === 1
|
||||||
|
? 'Deploy Helper 已指向更高版本,无需回退到较旧的 GitHub 稳定版。'
|
||||||
|
: '当前已运行该版本,无需重复部署。',
|
||||||
canDeploy: false,
|
canDeploy: false,
|
||||||
highlight: false,
|
highlight: false,
|
||||||
};
|
};
|
||||||
@@ -216,8 +166,21 @@ export function describeDockerDeployState(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const versionCompare = compareStableVersions(input.currentVersion, candidateVersion || candidateTag);
|
const candidateTargetVersion = candidateVersion || candidateTag;
|
||||||
if (versionCompare === -1) {
|
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 {
|
return {
|
||||||
kind: 'new-version',
|
kind: 'new-version',
|
||||||
badgeClassName: 'badge badge-success',
|
badgeClassName: 'badge badge-success',
|
||||||
@@ -228,9 +191,18 @@ export function describeDockerDeployState(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const helperTag = normalizeString(input.helper?.imageTag);
|
|
||||||
const helperDigest = normalizeDigest(input.helper?.imageDigest);
|
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 {
|
return {
|
||||||
kind: 'new-digest',
|
kind: 'new-digest',
|
||||||
badgeClassName: 'badge badge-success',
|
badgeClassName: 'badge badge-success',
|
||||||
@@ -277,37 +249,22 @@ export function buildUpdateReminder(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const githubState = describeGitHubDeployState({
|
const candidate = resolveUpdateReminderCandidate({
|
||||||
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,
|
|
||||||
currentVersion: input.currentVersion,
|
currentVersion: input.currentVersion,
|
||||||
helper: input.helper,
|
helper: input.helper,
|
||||||
candidate: input.dockerHubTag,
|
githubRelease: input.githubRelease,
|
||||||
|
dockerHubTag: input.dockerHubTag,
|
||||||
});
|
});
|
||||||
if (dockerState.kind === 'new-version' || dockerState.kind === 'new-digest') {
|
if (candidate) {
|
||||||
return {
|
return {
|
||||||
label: dockerState.badgeLabel,
|
label: candidate.kind === 'new-digest' ? '发现新 digest' : '发现新版本',
|
||||||
badgeClassName: dockerState.badgeClassName,
|
badgeClassName: 'badge badge-success',
|
||||||
detail: dockerState.kind === 'new-digest'
|
detail: candidate.kind === 'new-digest'
|
||||||
? 'Docker Hub 的 alias tag 已指向新 digest,可按需部署。'
|
? 'Docker Hub 的 alias tag 已指向新 digest,可按需部署。'
|
||||||
|
: candidate.source === 'github-release'
|
||||||
|
? `GitHub 稳定版 ${normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion)} 已可部署。`
|
||||||
: `Docker Hub ${normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion)} 已可部署。`,
|
: `Docker Hub ${normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion)} 已可部署。`,
|
||||||
highlight: dockerState.highlight,
|
highlight: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
runningTask: null,
|
||||||
lastFinishedTask: null,
|
lastFinishedTask: null,
|
||||||
});
|
});
|
||||||
@@ -254,6 +263,8 @@ describe('UpdateCenterSection', () => {
|
|||||||
const text = collectText(root.root);
|
const text = collectText(root.root);
|
||||||
expect(text).toContain('latest @ sha256:efb2ee655386');
|
expect(text).toContain('latest @ sha256:efb2ee655386');
|
||||||
expect(text).toContain('发现新版本');
|
expect(text).toContain('发现新版本');
|
||||||
|
expect(text).toContain('后台检查');
|
||||||
|
expect(text).toContain('1.3.0');
|
||||||
expect(text).toContain('Running helm upgrade');
|
expect(text).toContain('Running helm upgrade');
|
||||||
expect(text).toContain('Waiting for rollout');
|
expect(text).toContain('Waiting for rollout');
|
||||||
expect(text).toContain('任务状态 · 已完成');
|
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(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<UpdateCenterSection />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
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 () => {
|
it('renders rollback history and triggers rollback tasks for previous revisions', async () => {
|
||||||
let root!: ReactTestRenderer;
|
let root!: ReactTestRenderer;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ type UpdateCenterStatus = {
|
|||||||
status?: string;
|
status?: string;
|
||||||
finishedAt?: string | null;
|
finishedAt?: string | null;
|
||||||
} | 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<UpdateCenterStatus['config']> = {
|
const DEFAULT_CONFIG: NonNullable<UpdateCenterStatus['config']> = {
|
||||||
@@ -137,7 +146,10 @@ const fieldHintStyle: CSSProperties = {
|
|||||||
|
|
||||||
function formatTaskTime(value?: string | null) {
|
function formatTaskTime(value?: string | null) {
|
||||||
if (!value) return '暂无完成记录';
|
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;
|
if (!Number.isFinite(timestamp)) return value;
|
||||||
return new Date(timestamp).toLocaleString('zh-CN', {
|
return new Date(timestamp).toLocaleString('zh-CN', {
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
@@ -402,6 +414,7 @@ export default function UpdateCenterSection() {
|
|||||||
const helperHistory = Array.isArray(status?.helper?.history) ? status.helper.history : [];
|
const helperHistory = Array.isArray(status?.helper?.history) ? status.helper.history : [];
|
||||||
const historyPreview = helperHistory.slice(0, 2);
|
const historyPreview = helperHistory.slice(0, 2);
|
||||||
const currentRevision = String(status?.helper?.revision || '').trim();
|
const currentRevision = String(status?.helper?.revision || '').trim();
|
||||||
|
const runtimeStatus = status?.runtime || null;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -475,6 +488,19 @@ export default function UpdateCenterSection() {
|
|||||||
: formatTaskTime(status?.lastFinishedTask?.finishedAt)}
|
: formatTaskTime(status?.lastFinishedTask?.finishedAt)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div style={sectionPanelStyle}>
|
||||||
|
<div style={summaryLabelStyle}>后台检查</div>
|
||||||
|
<div style={summaryValueStyle}>
|
||||||
|
{runtimeStatus?.lastCheckedAt ? formatTaskTime(runtimeStatus.lastCheckedAt) : '尚无检查记录'}
|
||||||
|
</div>
|
||||||
|
<div style={fieldHintStyle}>
|
||||||
|
{runtimeStatus?.lastCheckError
|
||||||
|
? `最近错误:${runtimeStatus.lastCheckError}`
|
||||||
|
: runtimeStatus?.lastResolvedDisplayVersion
|
||||||
|
? `最近发现:${runtimeStatus.lastResolvedDisplayVersion}`
|
||||||
|
: '后台会定时检查新版本,并在首次发现时提醒一次。'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -494,7 +520,7 @@ export default function UpdateCenterSection() {
|
|||||||
/>
|
/>
|
||||||
<span style={{ display: 'grid', gap: 4 }}>
|
<span style={{ display: 'grid', gap: 4 }}>
|
||||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>启用更新中心</span>
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>启用更新中心</span>
|
||||||
<span style={fieldHintStyle}>允许系统检查版本,并在配置齐全时触发 K3s 部署。</span>
|
<span style={fieldHintStyle}>允许通过本页触发 K3s 部署。后台版本提醒会按已启用来源持续检查。</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label style={{ ...sectionPanelStyle, display: 'flex', gap: 10, alignItems: 'flex-start', cursor: 'pointer' }}>
|
<label style={{ ...sectionPanelStyle, display: 'flex', gap: 10, alignItems: 'flex-start', cursor: 'pointer' }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user