diff --git a/docs/k3s-update-center.md b/docs/k3s-update-center.md index 17bc9be..d09f121 100644 --- a/docs/k3s-update-center.md +++ b/docs/k3s-update-center.md @@ -310,8 +310,9 @@ helper 端使用: 补充一点: -- `Docker Hub` 里的**自动发现**仍然优先 `latest` / `main` / 稳定 SemVer 标签 -- 如果你要部署 `dev`、分支名、临时标签或 `sha-*` 这类非稳定标签,直接在 Docker Hub 卡片底部用“手动部署 Docker Hub 标签”填写即可 +- `Docker Hub` 里的**主候选**仍然优先 `latest` / `main` / 稳定 SemVer 标签 +- 页面还会自动列出最近推送的 `dev`、分支名、临时标签或 `sha-*` 这类**非稳定标签**,并一起带出 digest +- 如果你要部署一个更老或更特殊、没出现在自动列表里的标签,仍然可以在 Docker Hub 卡片底部用“手动部署 Docker Hub 标签”填写 对已经跑起来的 K3s / Helm 用户来说,更新中心的日常配置主要就在这一页: @@ -330,7 +331,7 @@ helper 端使用: - 版本来源发现了可部署版本 - 如果 Docker Hub 显示的是 `latest @ sha256:...`,说明页面已经识别到 alias tag 当前指向的具体镜像 digest - Deploy Helper 显示健康 -4. 如果你要跟随稳定候选,直接点部署按钮;如果你要切到 `dev` / 分支 / 临时标签,就在 Docker Hub 卡片底部手动填写 tag,必要时连 digest 一起填 +4. 如果你要跟随稳定候选,直接点部署按钮;如果你要切到最近的 `dev` / 分支 / 临时标签,可以直接点自动列出的那一项;只有更特殊的 tag 才需要手动填写,必要时连 digest 一起填 5. 在页面下方看部署日志 6. 如果升级后发现问题,可以直接在“回退历史”里点旧 revision 回滚;只要该 revision 当时记录了 digest,就会跟着一起回到对应镜像 diff --git a/src/server/routes/api/updateCenter.test.ts b/src/server/routes/api/updateCenter.test.ts index a7145ef..0d9bc5a 100644 --- a/src/server/routes/api/updateCenter.test.ts +++ b/src/server/routes/api/updateCenter.test.ts @@ -9,13 +9,13 @@ import { waitForBackgroundTaskToReachTerminalState } from '../../test-fixtures/b const { fetchLatestStableGitHubReleaseMock, - fetchLatestDockerHubTagMock, + fetchDockerHubTagCandidatesMock, getUpdateCenterHelperStatusMock, streamUpdateCenterDeployMock, streamUpdateCenterRollbackMock, } = vi.hoisted(() => ({ fetchLatestStableGitHubReleaseMock: vi.fn(), - fetchLatestDockerHubTagMock: vi.fn(), + fetchDockerHubTagCandidatesMock: vi.fn(), getUpdateCenterHelperStatusMock: vi.fn(), streamUpdateCenterDeployMock: vi.fn(), streamUpdateCenterRollbackMock: vi.fn(), @@ -26,7 +26,7 @@ vi.mock('../../services/updateCenterVersionService.js', async () => { return { ...actual, fetchLatestStableGitHubRelease: (...args: unknown[]) => fetchLatestStableGitHubReleaseMock(...args), - fetchLatestDockerHubTag: (...args: unknown[]) => fetchLatestDockerHubTagMock(...args), + fetchDockerHubTagCandidates: (...args: unknown[]) => fetchDockerHubTagCandidatesMock(...args), }; }); @@ -103,7 +103,7 @@ describe('update center routes', () => { beforeEach(async () => { fetchLatestStableGitHubReleaseMock.mockReset(); - fetchLatestDockerHubTagMock.mockReset(); + fetchDockerHubTagCandidatesMock.mockReset(); getUpdateCenterHelperStatusMock.mockReset(); streamUpdateCenterDeployMock.mockReset(); streamUpdateCenterRollbackMock.mockReset(); @@ -139,6 +139,18 @@ describe('update center routes', () => { publishedAt: '2026-03-29T11:54:35.591877Z', url: null, } as const; + const dockerHubRecentTags = [ + { + source: 'docker-hub-tag', + rawVersion: 'dev', + normalizedVersion: 'dev', + tagName: 'dev', + digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + displayVersion: 'dev @ sha256:aaaaaaaaaaaa', + publishedAt: '2026-03-29T12:54:35.591877Z', + url: null, + }, + ] as const; const helperStatus = { ok: true, releaseName: 'metapi', @@ -161,7 +173,10 @@ describe('update center routes', () => { ], } as const; fetchLatestStableGitHubReleaseMock.mockResolvedValue(githubRelease); - fetchLatestDockerHubTagMock.mockResolvedValue(dockerHubTag); + fetchDockerHubTagCandidatesMock.mockResolvedValue({ + primary: dockerHubTag, + recentNonStable: dockerHubRecentTags, + }); getUpdateCenterHelperStatusMock.mockResolvedValue(helperStatus); const saveResponse = await app.inject({ @@ -216,6 +231,13 @@ describe('update center routes', () => { displayVersion: 'latest @ sha256:efb2ee655386', digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', }, + dockerHubRecentTags: [ + { + normalizedVersion: 'dev', + displayVersion: 'dev @ sha256:aaaaaaaaaaaa', + digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + }, + ], helper: { ok: true, healthy: true, @@ -242,11 +264,14 @@ describe('update center routes', () => { it('returns partial status when a single version source lookup fails', async () => { fetchLatestStableGitHubReleaseMock.mockRejectedValue(new Error('GitHub releases lookup timed out')); - fetchLatestDockerHubTagMock.mockResolvedValue({ - source: 'docker-hub-tag', - rawVersion: '1.3.1', - normalizedVersion: '1.3.1', - url: null, + fetchDockerHubTagCandidatesMock.mockResolvedValue({ + primary: { + source: 'docker-hub-tag', + rawVersion: '1.3.1', + normalizedVersion: '1.3.1', + url: null, + }, + recentNonStable: [], }); getUpdateCenterHelperStatusMock.mockResolvedValue({ ok: true, @@ -426,6 +451,18 @@ describe('update center routes', () => { publishedAt: '2026-03-31T09:00:00Z', url: null, }, + dockerHubRecentTags: [ + { + source: 'docker-hub-tag', + rawVersion: 'dev', + normalizedVersion: 'dev', + tagName: 'dev', + digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + displayVersion: 'dev @ sha256:aaaaaaaaaaaa', + publishedAt: '2026-03-31T09:05:00Z', + url: null, + }, + ], helper: { ok: true, releaseName: 'metapi', @@ -454,6 +491,12 @@ describe('update center routes', () => { dockerHubTag: { displayVersion: 'latest @ sha256:efb2ee655386', }, + dockerHubRecentTags: [ + { + normalizedVersion: 'dev', + displayVersion: 'dev @ sha256:aaaaaaaaaaaa', + }, + ], helper: { imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', }, @@ -462,7 +505,7 @@ describe('update center routes', () => { }, }); expect(fetchLatestStableGitHubReleaseMock).not.toHaveBeenCalled(); - expect(fetchLatestDockerHubTagMock).not.toHaveBeenCalled(); + expect(fetchDockerHubTagCandidatesMock).not.toHaveBeenCalled(); expect(getUpdateCenterHelperStatusMock).not.toHaveBeenCalled(); }); @@ -477,15 +520,29 @@ describe('update center routes', () => { 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, + fetchDockerHubTagCandidatesMock.mockResolvedValue({ + primary: { + 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, + }, + recentNonStable: [ + { + source: 'docker-hub-tag', + rawVersion: 'dev-20260417-f67ade2', + normalizedVersion: 'dev-20260417-f67ade2', + tagName: 'dev-20260417-f67ade2', + digest: 'sha256:abababababababababababababababababababababababababababababababab', + displayVersion: 'dev-20260417-f67ade2 @ sha256:abababababab', + publishedAt: '2026-03-31T10:05:00Z', + url: null, + }, + ], }); getUpdateCenterHelperStatusMock.mockResolvedValue({ ok: true, @@ -512,6 +569,12 @@ describe('update center routes', () => { dockerHubTag: { digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', }, + dockerHubRecentTags: [ + { + normalizedVersion: 'dev-20260417-f67ade2', + digest: 'sha256:abababababababababababababababababababababababababababababababab', + }, + ], helper: { revision: '13', }, @@ -520,7 +583,7 @@ describe('update center routes', () => { }, }); expect(fetchLatestStableGitHubReleaseMock).toHaveBeenCalledTimes(1); - expect(fetchLatestDockerHubTagMock).toHaveBeenCalledTimes(1); + expect(fetchDockerHubTagCandidatesMock).toHaveBeenCalledTimes(1); expect(getUpdateCenterHelperStatusMock).toHaveBeenCalledTimes(1); expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({ lastResolvedSource: 'github-release', @@ -532,6 +595,11 @@ describe('update center routes', () => { dockerHubTag: expect.objectContaining({ digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', }), + dockerHubRecentTags: [ + expect.objectContaining({ + normalizedVersion: 'dev-20260417-f67ade2', + }), + ], helper: expect.objectContaining({ revision: '13', }), diff --git a/src/server/services/updateCenterPollingService.test.ts b/src/server/services/updateCenterPollingService.test.ts index 2c50df7..c949cc3 100644 --- a/src/server/services/updateCenterPollingService.test.ts +++ b/src/server/services/updateCenterPollingService.test.ts @@ -97,6 +97,7 @@ describe('updateCenterPollingService', () => { publishedAt: '2026-03-31T12:00:00Z', }, dockerHubTag: null, + dockerHubRecentTags: [], helper: { ok: true, releaseName: 'metapi', @@ -115,6 +116,7 @@ describe('updateCenterPollingService', () => { currentVersion: '1.2.3', githubRelease: runtime.statusSnapshot.githubRelease, dockerHubTag: null, + dockerHubRecentTags: [], helper: runtime.statusSnapshot.helper, runtime, }, @@ -151,15 +153,16 @@ describe('updateCenterPollingService', () => { lastResolvedSource: 'github-release', lastResolvedCandidateKey: 'github-release:v1.3.0', lastNotifiedCandidateKey: 'github-release:v1.3.0', - statusSnapshot: { + statusSnapshot: expect.objectContaining({ githubRelease: expect.objectContaining({ normalizedVersion: '1.3.0', }), dockerHubTag: null, + dockerHubRecentTags: [], helper: expect.objectContaining({ imageTag: '1.2.3', }), - }, + }), })); await vi.advanceTimersByTimeAsync(60_000); @@ -205,6 +208,7 @@ describe('updateCenterPollingService', () => { publishedAt: '2026-03-31T12:01:00Z', }, dockerHubTag: null, + dockerHubRecentTags: [], helper: { ok: true, releaseName: 'metapi', @@ -223,6 +227,7 @@ describe('updateCenterPollingService', () => { currentVersion: '1.2.3', githubRelease: runtime.statusSnapshot.githubRelease, dockerHubTag: null, + dockerHubRecentTags: [], helper: runtime.statusSnapshot.helper, runtime, }, @@ -251,15 +256,16 @@ describe('updateCenterPollingService', () => { lastResolvedCandidateKey: 'github-release:v1.3.0', lastNotifiedCandidateKey: 'github-release:v1.3.0', lastNotifiedAt: expect.any(String), - statusSnapshot: { + statusSnapshot: expect.objectContaining({ githubRelease: expect.objectContaining({ normalizedVersion: '1.3.0', }), dockerHubTag: null, + dockerHubRecentTags: [], helper: expect.objectContaining({ imageTag: '1.2.3', }), - }, + }), })); }); }); diff --git a/src/server/services/updateCenterRuntimeStateService.test.ts b/src/server/services/updateCenterRuntimeStateService.test.ts index 80a4a83..8020b2e 100644 --- a/src/server/services/updateCenterRuntimeStateService.test.ts +++ b/src/server/services/updateCenterRuntimeStateService.test.ts @@ -88,6 +88,18 @@ describe('updateCenterRuntimeStateService', () => { displayVersion: 'latest @ sha256:efb2ee655386', publishedAt: '2026-03-30T20:30:00Z', }, + dockerHubRecentTags: [ + { + source: 'docker-hub-tag', + rawVersion: 'dev', + normalizedVersion: 'dev', + url: null, + tagName: 'dev', + digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + displayVersion: 'dev @ sha256:aaaaaaaaaaaa', + publishedAt: '2026-03-30T20:35:00Z', + }, + ], helper: { ok: true, releaseName: 'metapi', @@ -142,6 +154,18 @@ describe('updateCenterRuntimeStateService', () => { displayVersion: 'latest @ sha256:efb2ee655386', publishedAt: '2026-03-30T20:30:00Z', }, + dockerHubRecentTags: [ + { + source: 'docker-hub-tag', + rawVersion: 'dev', + normalizedVersion: 'dev', + url: null, + tagName: 'dev', + digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + displayVersion: 'dev @ sha256:aaaaaaaaaaaa', + publishedAt: '2026-03-30T20:35:00Z', + }, + ], helper: { ok: true, releaseName: 'metapi', diff --git a/src/server/services/updateCenterRuntimeStateService.ts b/src/server/services/updateCenterRuntimeStateService.ts index a3e7c31..e10b9b7 100644 --- a/src/server/services/updateCenterRuntimeStateService.ts +++ b/src/server/services/updateCenterRuntimeStateService.ts @@ -8,6 +8,7 @@ import type { UpdateCenterVersionCandidate, UpdateCenterVersionSource } from './ export type UpdateCenterStatusSnapshot = { githubRelease: UpdateCenterVersionCandidate | null; dockerHubTag: UpdateCenterVersionCandidate | null; + dockerHubRecentTags: UpdateCenterVersionCandidate[]; helper: UpdateCenterHelperStatus | null; }; @@ -66,6 +67,13 @@ function normalizeVersionCandidate(input: unknown): UpdateCenterVersionCandidate }; } +function normalizeVersionCandidates(input: unknown): UpdateCenterVersionCandidate[] { + if (!Array.isArray(input)) return []; + return input + .map((entry) => normalizeVersionCandidate(entry)) + .filter((entry): entry is UpdateCenterVersionCandidate => !!entry); +} + function normalizeHelperHistoryEntry(input: unknown): NonNullable[number] | null { if (!input || typeof input !== 'object') return null; const record = input as Record; @@ -109,6 +117,7 @@ function normalizeStatusSnapshot(input: unknown): UpdateCenterStatusSnapshot | n return { githubRelease: normalizeVersionCandidate(record.githubRelease), dockerHubTag: normalizeVersionCandidate(record.dockerHubTag), + dockerHubRecentTags: normalizeVersionCandidates(record.dockerHubRecentTags), helper: normalizeHelperSnapshot(record.helper), }; } diff --git a/src/server/services/updateCenterStatusService.ts b/src/server/services/updateCenterStatusService.ts index 73fb90a..d498a11 100644 --- a/src/server/services/updateCenterStatusService.ts +++ b/src/server/services/updateCenterStatusService.ts @@ -2,9 +2,10 @@ import { config as runtimeConfig } from '../config.js'; import { formatUtcSqlDateTime } from './localTimeService.js'; import { listBackgroundTasks } from './backgroundTaskService.js'; import { - fetchLatestDockerHubTag, + fetchDockerHubTagCandidates, fetchLatestStableGitHubRelease, getCurrentRuntimeVersion, + type DockerHubTagCandidates, type UpdateCenterVersionCandidate, } from './updateCenterVersionService.js'; import { @@ -71,6 +72,7 @@ export type UpdateCenterStatusResult = { config: UpdateCenterConfig; githubRelease: UpdateCenterVersionCandidate | null; dockerHubTag: UpdateCenterVersionCandidate | null; + dockerHubRecentTags: UpdateCenterVersionCandidate[]; helper: UpdateCenterHelperStatus; runningTask: ReturnType[number] | null; lastFinishedTask: ReturnType[number] | null; @@ -92,16 +94,17 @@ function buildUnavailableHelperStatus(error: string | null = null): UpdateCenter }; } -function buildStatusSnapshot(status: Pick): UpdateCenterStatusSnapshot { +function buildStatusSnapshot(status: Pick): UpdateCenterStatusSnapshot { return { githubRelease: status.githubRelease || null, dockerHubTag: status.dockerHubTag || null, + dockerHubRecentTags: status.dockerHubRecentTags || [], helper: status.helper || null, }; } function buildNextRuntimeState( - status: Pick, + status: Pick, previousRuntime: UpdateCenterRuntimeState, checkedAt: string, ): { candidate: UpdateReminderCandidate | null; nextRuntime: UpdateCenterRuntimeState } { @@ -137,6 +140,7 @@ function buildResponseFromState(config: UpdateCenterConfig, runtime: UpdateCente config, githubRelease: snapshot?.githubRelease || null, dockerHubTag: snapshot?.dockerHubTag || null, + dockerHubRecentTags: snapshot?.dockerHubRecentTags || [], helper: snapshot?.helper || buildUnavailableHelperStatus(runtime.lastCheckError), runningTask, lastFinishedTask, @@ -150,7 +154,7 @@ export async function buildUpdateCenterStatus(): Promise await fetchLatestStableGitHubRelease()), - settleOptional(config.dockerHubTagsEnabled, async () => await fetchLatestDockerHubTag()), + settleOptional(config.dockerHubTagsEnabled, async () => await fetchDockerHubTagCandidates()), settleOptional(!!config.helperBaseUrl, async () => { if (!helperToken) { throw new Error('DEPLOY_HELPER_TOKEN is required'); @@ -161,7 +165,9 @@ export async function buildUpdateCenterStatus(): Promise { import { compareStableSemVer, + fetchDockerHubTagCandidates, fetchLatestDockerHubTag, fetchLatestStableGitHubRelease, parseStableSemVer, resolvePreferredDeploySource, + selectDockerHubTagCandidates, selectLatestDockerHubTag, selectLatestStableGitHubRelease, + selectRecentNonStableDockerHubTags, } from './updateCenterVersionService.js'; describe('update center version service', () => { @@ -132,6 +135,84 @@ describe('update center version service', () => { }); }); + describe('selectRecentNonStableDockerHubTags', () => { + it('returns recent non-stable docker tags with dev-like tags prioritized ahead of generic branch tags', () => { + const candidates = selectRecentNonStableDockerHubTags([ + { + name: 'feature-login', + digest: 'sha256:1111111111111111111111111111111111111111111111111111111111111111', + tag_last_pushed: '2026-04-17T10:00:00Z', + }, + { + name: 'sha-a2c2ae6', + digest: 'sha256:2222222222222222222222222222222222222222222222222222222222222222', + tag_last_pushed: '2026-04-17T09:00:00Z', + }, + { + name: 'dev', + digest: 'sha256:3333333333333333333333333333333333333333333333333333333333333333', + tag_last_pushed: '2026-04-17T08:00:00Z', + }, + { + name: 'dev-20260417-f67ade2', + digest: 'sha256:4444444444444444444444444444444444444444444444444444444444444444', + tag_last_pushed: '2026-04-17T07:00:00Z', + }, + { + name: 'latest', + digest: 'sha256:5555555555555555555555555555555555555555555555555555555555555555', + tag_last_pushed: '2026-04-17T11:00:00Z', + }, + { + name: '1.10.0', + digest: 'sha256:6666666666666666666666666666666666666666666666666666666666666666', + tag_last_pushed: '2026-04-16T11:00:00Z', + }, + ]); + + expect(candidates).toHaveLength(4); + expect(candidates.map((candidate) => candidate.tagName)).toEqual([ + 'dev', + 'dev-20260417-f67ade2', + 'sha-a2c2ae6', + 'feature-login', + ]); + expect(candidates[0]).toMatchObject({ + displayVersion: 'dev @ sha256:333333333333', + }); + }); + }); + + describe('selectDockerHubTagCandidates', () => { + it('returns both the stable primary candidate and recent non-stable candidates from one tag list', () => { + const candidates = selectDockerHubTagCandidates([ + { + name: 'latest', + digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + tag_last_pushed: '2026-04-17T12:00:00Z', + }, + { + name: 'dev-20260417-f67ade2', + digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + tag_last_pushed: '2026-04-17T11:00:00Z', + }, + { + name: 'sha-f67ade2', + digest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + tag_last_pushed: '2026-04-17T10:00:00Z', + }, + ]); + + expect(candidates.primary).toMatchObject({ + tagName: 'latest', + }); + expect(candidates.recentNonStable.map((candidate) => candidate.tagName)).toEqual([ + 'dev-20260417-f67ade2', + 'sha-f67ade2', + ]); + }); + }); + describe('resolvePreferredDeploySource', () => { it('prefers the configured default source when both channels have valid candidates', () => { const preferred = resolvePreferredDeploySource({ @@ -263,4 +344,40 @@ describe('update center version service', () => { }); }); }); + + describe('fetchDockerHubTagCandidates', () => { + it('returns the stable docker candidate plus recent non-stable tags from one lookup', async () => { + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + results: [ + { + name: 'latest', + digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', + tag_last_pushed: '2026-03-29T11:54:35.591877Z', + }, + { + name: 'dev', + digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + tag_last_pushed: '2026-03-30T11:54:35.591877Z', + }, + { + name: 'sha-f67ade2', + digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + tag_last_pushed: '2026-03-30T10:54:35.591877Z', + }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const candidates = await fetchDockerHubTagCandidates(); + expect(candidates.primary).toMatchObject({ + tagName: 'latest', + }); + expect(candidates.recentNonStable.map((candidate) => candidate.tagName)).toEqual([ + 'dev', + 'sha-f67ade2', + ]); + }); + }); }); diff --git a/src/server/services/updateCenterVersionService.ts b/src/server/services/updateCenterVersionService.ts index 22e2186..fce5b03 100644 --- a/src/server/services/updateCenterVersionService.ts +++ b/src/server/services/updateCenterVersionService.ts @@ -39,11 +39,17 @@ export type DockerHubTagRecord = { digest?: string | null; }; +export type DockerHubTagCandidates = { + primary: UpdateCenterVersionCandidate | null; + recentNonStable: UpdateCenterVersionCandidate[]; +}; + const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i; const GITHUB_RELEASES_URL = 'https://api.github.com/repos/cita-777/metapi/releases'; const DOCKER_HUB_TAGS_URL = 'https://hub.docker.com/v2/repositories/1467078763/metapi/tags?page_size=100'; const UPDATE_CENTER_VERSION_FETCH_TIMEOUT_MS = 5_000; const PREFERRED_DOCKER_HUB_TAG_ALIASES = ['latest', 'main'] as const; +const MAX_RECENT_NON_STABLE_DOCKER_HUB_TAGS = 5; async function fetchJsonWithTimeout(url: string, init: UndiciRequestInit, timeoutLabel: string): Promise { const controller = new AbortController(); @@ -133,6 +139,21 @@ function normalizeDockerHubTagRecord(input: string | DockerHubTagRecord): Docker return input; } +function normalizeDockerHubTagName(input: string | null | undefined): string { + return String(input || '').trim(); +} + +function isPreferredDockerHubAlias(input: string | null | undefined): boolean { + const tag = normalizeDockerHubTagName(input); + return PREFERRED_DOCKER_HUB_TAG_ALIASES.includes(tag as typeof PREFERRED_DOCKER_HUB_TAG_ALIASES[number]); +} + +function isStableDockerHubTag(input: string | null | undefined): boolean { + const tag = normalizeDockerHubTagName(input); + if (!tag) return false; + return isPreferredDockerHubAlias(tag) || !!parseStableSemVer(tag); +} + function normalizeDockerDigest(input: string | null | undefined): string | null { const digest = String(input || '').trim(); return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : null; @@ -143,6 +164,22 @@ function getDockerHubTagPublishedAt(record: DockerHubTagRecord): string | null { return value || null; } +function getDockerHubTagPublishedTimestamp(record: DockerHubTagRecord): number { + const publishedAt = getDockerHubTagPublishedAt(record); + if (!publishedAt) return Number.NEGATIVE_INFINITY; + const timestamp = Date.parse(publishedAt); + return Number.isFinite(timestamp) ? timestamp : Number.NEGATIVE_INFINITY; +} + +function getRecentNonStableDockerHubPriority(input: string | null | undefined): number { + const tag = normalizeDockerHubTagName(input).toLowerCase(); + if (!tag) return 99; + if (tag === 'dev') return 0; + if (tag.startsWith('dev-')) return 1; + if (tag.startsWith('sha-')) return 2; + return 3; +} + function toShortDigest(digest: string | null | undefined): string | null { if (!digest) return null; return digest.slice(0, 'sha256:'.length + 12); @@ -194,6 +231,44 @@ export function selectLatestDockerHubTag(tags: Array, + limit = MAX_RECENT_NON_STABLE_DOCKER_HUB_TAGS, +): UpdateCenterVersionCandidate[] { + const records = tags + .map((tag) => normalizeDockerHubTagRecord(tag)) + .filter((record) => normalizeDockerHubTagName(record.name)) + .filter((record) => !isStableDockerHubTag(record.name)); + + const deduped = new Map(); + for (const record of records) { + const tagName = normalizeDockerHubTagName(record.name); + const previous = deduped.get(tagName); + if (!previous || getDockerHubTagPublishedTimestamp(record) > getDockerHubTagPublishedTimestamp(previous)) { + deduped.set(tagName, record); + } + } + + return Array.from(deduped.values()) + .sort((a, b) => { + const priorityDelta = getRecentNonStableDockerHubPriority(a.name) - getRecentNonStableDockerHubPriority(b.name); + if (priorityDelta !== 0) return priorityDelta; + const publishedDelta = getDockerHubTagPublishedTimestamp(b) - getDockerHubTagPublishedTimestamp(a); + if (publishedDelta !== 0) return publishedDelta; + return normalizeDockerHubTagName(a.name).localeCompare(normalizeDockerHubTagName(b.name)); + }) + .slice(0, Math.max(0, limit)) + .map((record) => buildDockerHubVersionCandidate(record, normalizeDockerHubTagName(record.name))) + .filter((candidate): candidate is UpdateCenterVersionCandidate => !!candidate); +} + +export function selectDockerHubTagCandidates(tags: Array): DockerHubTagCandidates { + return { + primary: selectLatestDockerHubTag(tags), + recentNonStable: selectRecentNonStableDockerHubTags(tags), + }; +} + export function resolvePreferredDeploySource(input: { defaultSource: UpdateCenterVersionSource; githubRelease: UpdateCenterVersionCandidate | null; @@ -216,13 +291,17 @@ export async function fetchLatestStableGitHubRelease(): Promise { + return (await fetchDockerHubTagCandidates()).primary; +} + +export async function fetchDockerHubTagCandidates(): Promise { const payload = await fetchJsonWithTimeout(DOCKER_HUB_TAGS_URL, { headers: { accept: 'application/json', 'user-agent': 'metapi-update-center/1.0', }, }, 'Docker Hub tag lookup') as { results?: DockerHubTagRecord[] }; - return selectLatestDockerHubTag(Array.isArray(payload?.results) ? payload.results : []); + return selectDockerHubTagCandidates(Array.isArray(payload?.results) ? payload.results : []); } export function getCurrentRuntimeVersion(): string { diff --git a/src/web/pages/settings/UpdateCenterSection.test.tsx b/src/web/pages/settings/UpdateCenterSection.test.tsx index 50dc477..553cb60 100644 --- a/src/web/pages/settings/UpdateCenterSection.test.tsx +++ b/src/web/pages/settings/UpdateCenterSection.test.tsx @@ -73,6 +73,22 @@ describe('UpdateCenterSection', () => { displayVersion: 'latest @ sha256:efb2ee655386', publishedAt: '2026-03-29T11:54:35.591877Z', }, + dockerHubRecentTags: [ + { + normalizedVersion: 'dev', + tagName: 'dev', + digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + displayVersion: 'dev @ sha256:aaaaaaaaaaaa', + publishedAt: '2026-03-30T11:54:35.591877Z', + }, + { + normalizedVersion: 'dev-20260417-f67ade2', + tagName: 'dev-20260417-f67ade2', + digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + displayVersion: 'dev-20260417-f67ade2 @ sha256:bbbbbbbbbbbb', + publishedAt: '2026-03-30T10:54:35.591877Z', + }, + ], helper: { ok: true, healthy: true, @@ -159,6 +175,15 @@ describe('UpdateCenterSection', () => { displayVersion: 'latest @ sha256:efb2ee655386', publishedAt: '2026-03-29T11:54:35.591877Z', }, + dockerHubRecentTags: [ + { + normalizedVersion: 'dev', + tagName: 'dev', + digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + displayVersion: 'dev @ sha256:aaaaaaaaaaaa', + publishedAt: '2026-03-30T11:54:35.591877Z', + }, + ], helper: { ok: true, healthy: true, @@ -431,6 +456,50 @@ describe('UpdateCenterSection', () => { } }); + it('deploys auto-discovered recent non-stable Docker Hub tags without manual input', async () => { + let root!: ReactTestRenderer; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await flushMicrotasks(); + + const recentTagCard = root.root.find((node) => ( + typeof node.props?.style === 'object' + && collectText(node).includes('dev-20260417-f67ade2 @ sha256:bbbbbbbbbbbb') + )); + expect(recentTagCard).toBeTruthy(); + + const deployRecentButton = root.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes('部署 dev-20260417-f67ade2') + )); + + expect(deployRecentButton.props.disabled).toBe(false); + + await act(async () => { + await deployRecentButton.props.onClick(); + }); + await flushMicrotasks(); + + expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({ + source: 'docker-hub-tag', + targetTag: 'dev-20260417-f67ade2', + targetDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + }); + expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object)); + } finally { + root?.unmount(); + } + }); + it('keeps rollback history compact and opens the full revision list in a centered modal', async () => { let root!: ReactTestRenderer; try { diff --git a/src/web/pages/settings/UpdateCenterSection.tsx b/src/web/pages/settings/UpdateCenterSection.tsx index d9187a7..98142ae 100644 --- a/src/web/pages/settings/UpdateCenterSection.tsx +++ b/src/web/pages/settings/UpdateCenterSection.tsx @@ -39,6 +39,13 @@ type UpdateCenterStatus = { digest?: string | null; publishedAt?: string | null; } | null; + dockerHubRecentTags?: Array<{ + normalizedVersion?: string; + displayVersion?: string; + tagName?: string; + digest?: string | null; + publishedAt?: string | null; + }> | null; helper?: { ok?: boolean; healthy?: boolean; @@ -214,6 +221,11 @@ function formatImageTarget(tag?: string | null, digest?: string | null) { return ''; } +function normalizeRecentDockerCandidates(input?: UpdateCenterStatus['dockerHubRecentTags'] | null) { + if (!Array.isArray(input)) return []; + return input.filter((entry) => String(entry?.tagName || entry?.normalizedVersion || '').trim()); +} + export default function UpdateCenterSection() { const toast = useToast(); const isMobile = useIsMobile(); @@ -432,6 +444,11 @@ export default function UpdateCenterSection() { const canDeployDocker = !deploying && dockerDeployState.canDeploy; const manualDockerTag = String(manualDockerTarget.tag || '').trim(); const manualDockerDigest = String(manualDockerTarget.digest || '').trim(); + const recentDockerCandidates = normalizeRecentDockerCandidates(status?.dockerHubRecentTags); + const canDeployDockerByTag = !deploying + && config.enabled + && config.dockerHubTagsEnabled + && helperHealthy; const canDeployManualDocker = !deploying && config.enabled && config.dockerHubTagsEnabled @@ -570,7 +587,7 @@ export default function UpdateCenterSection() { /> Docker Hub - 自动发现优先稳定标签;dev / 分支 / 临时标签可在下方手动部署。 + 自动发现会保留稳定主候选,并额外列出最近的 dev / 分支 / sha 标签。 @@ -731,7 +748,7 @@ export default function UpdateCenterSection() { ) : null} -
自动候选优先 latest / main / 稳定 SemVer;dev / 分支 / sha 标签可手动填写部署。
+
主候选优先 latest / main / 稳定 SemVer;下方还会自动列出最近的 dev / 分支 / sha 标签。
{status?.dockerHubTag?.displayVersion || status?.dockerHubTag?.normalizedVersion || '未发现'}
@@ -773,12 +790,82 @@ export default function UpdateCenterSection() { ) : null} +
+
+ 最近非稳定 Docker 标签 +
+
+ 自动列出最近推送的 dev / 分支 / sha 标签;点部署即可直接使用,不必手动输入 tag 和 digest。 +
+ {recentDockerCandidates.length ? ( +
+ {recentDockerCandidates.map((candidate) => { + const candidateTag = String(candidate.tagName || candidate.normalizedVersion || '').trim(); + const candidateDigest = String(candidate.digest || '').trim(); + const candidateLabel = candidate.displayVersion || candidate.normalizedVersion || candidateTag; + return ( +
+
+ {candidateLabel} +
+
+ 最近推送:{formatTaskTime(candidate.publishedAt)} +
+
+ + +
+
+ ); + })} +
+ ) : ( +
+ 暂未发现最近的 dev / 分支 / sha 标签;仍可在下方手动填写任意 Docker 标签。 +
+ )} +
手动部署 Docker Hub 标签
- 自动候选不会覆盖所有分支标签。若要切到 dev、feature、临时或 sha 标签,直接在这里填写。 + 自动发现已经覆盖最近的非稳定标签;如果你要部署更老或更特殊的 tag,仍可直接在这里填写。