Let operators deploy recent dev Docker tags without manual lookup

The update center already allowed manual Docker tag deploys, but operators
still had to know and paste dev or branch tags by hand. This change keeps the
stable Docker Hub candidate as the main path while automatically surfacing the
most recent non-stable tags and their digests for one-click deployment.

Constraint: Stable Docker Hub discovery must remain the default candidate path
Constraint: Existing deploy API and helper protocol should stay unchanged
Rejected: Promote dev tags into the primary stable candidate slot | would blur stable and experimental release semantics
Rejected: Add a brand new backend deploy endpoint for recent tags | existing targetTag/targetDigest deploy path already covers the need
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep stable and recent non-stable Docker tag discovery distinct unless product semantics deliberately change
Tested: Focused vitest for update-center services/routes/UI, npm run typecheck, npm run repo:drift-check, npm run build:web, npm run docs:build, git diff --check
Not-tested: Full npm test remains red because tmp/sub2api frontend suites are still broken in this repo snapshot
This commit is contained in:
cita-777
2026-04-18 15:28:03 +08:00
parent 87fb5149e6
commit c94e3148dd
10 changed files with 504 additions and 37 deletions
+4 -3
View File
@@ -310,8 +310,9 @@ helper 端使用:
补充一点: 补充一点:
- `Docker Hub` 里的**自动发现**仍然优先 `latest` / `main` / 稳定 SemVer 标签 - `Docker Hub` 里的**主候选**仍然优先 `latest` / `main` / 稳定 SemVer 标签
- 如果你要部署 `dev`、分支名、临时标签或 `sha-*` 这类非稳定标签,直接在 Docker Hub 卡片底部用“手动部署 Docker Hub 标签”填写即可 - 页面还会自动列出最近推送的 `dev`、分支名、临时标签或 `sha-*` 这类**非稳定标签**,并一起带出 digest
- 如果你要部署一个更老或更特殊、没出现在自动列表里的标签,仍然可以在 Docker Hub 卡片底部用“手动部署 Docker Hub 标签”填写
对已经跑起来的 K3s / Helm 用户来说,更新中心的日常配置主要就在这一页: 对已经跑起来的 K3s / Helm 用户来说,更新中心的日常配置主要就在这一页:
@@ -330,7 +331,7 @@ helper 端使用:
- 版本来源发现了可部署版本 - 版本来源发现了可部署版本
- 如果 Docker Hub 显示的是 `latest @ sha256:...`,说明页面已经识别到 alias tag 当前指向的具体镜像 digest - 如果 Docker Hub 显示的是 `latest @ sha256:...`,说明页面已经识别到 alias tag 当前指向的具体镜像 digest
- Deploy Helper 显示健康 - Deploy Helper 显示健康
4. 如果你要跟随稳定候选,直接点部署按钮;如果你要切到 `dev` / 分支 / 临时标签,就在 Docker Hub 卡片底部手动填写 tag,必要时连 digest 一起填 4. 如果你要跟随稳定候选,直接点部署按钮;如果你要切到最近的 `dev` / 分支 / 临时标签,可以直接点自动列出的那一项;只有更特殊的 tag 才需要手动填写,必要时连 digest 一起填
5. 在页面下方看部署日志 5. 在页面下方看部署日志
6. 如果升级后发现问题,可以直接在“回退历史”里点旧 revision 回滚;只要该 revision 当时记录了 digest,就会跟着一起回到对应镜像 6. 如果升级后发现问题,可以直接在“回退历史”里点旧 revision 回滚;只要该 revision 当时记录了 digest,就会跟着一起回到对应镜像
+89 -21
View File
@@ -9,13 +9,13 @@ import { waitForBackgroundTaskToReachTerminalState } from '../../test-fixtures/b
const { const {
fetchLatestStableGitHubReleaseMock, fetchLatestStableGitHubReleaseMock,
fetchLatestDockerHubTagMock, fetchDockerHubTagCandidatesMock,
getUpdateCenterHelperStatusMock, getUpdateCenterHelperStatusMock,
streamUpdateCenterDeployMock, streamUpdateCenterDeployMock,
streamUpdateCenterRollbackMock, streamUpdateCenterRollbackMock,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
fetchLatestStableGitHubReleaseMock: vi.fn(), fetchLatestStableGitHubReleaseMock: vi.fn(),
fetchLatestDockerHubTagMock: vi.fn(), fetchDockerHubTagCandidatesMock: vi.fn(),
getUpdateCenterHelperStatusMock: vi.fn(), getUpdateCenterHelperStatusMock: vi.fn(),
streamUpdateCenterDeployMock: vi.fn(), streamUpdateCenterDeployMock: vi.fn(),
streamUpdateCenterRollbackMock: vi.fn(), streamUpdateCenterRollbackMock: vi.fn(),
@@ -26,7 +26,7 @@ vi.mock('../../services/updateCenterVersionService.js', async () => {
return { return {
...actual, ...actual,
fetchLatestStableGitHubRelease: (...args: unknown[]) => fetchLatestStableGitHubReleaseMock(...args), 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 () => { beforeEach(async () => {
fetchLatestStableGitHubReleaseMock.mockReset(); fetchLatestStableGitHubReleaseMock.mockReset();
fetchLatestDockerHubTagMock.mockReset(); fetchDockerHubTagCandidatesMock.mockReset();
getUpdateCenterHelperStatusMock.mockReset(); getUpdateCenterHelperStatusMock.mockReset();
streamUpdateCenterDeployMock.mockReset(); streamUpdateCenterDeployMock.mockReset();
streamUpdateCenterRollbackMock.mockReset(); streamUpdateCenterRollbackMock.mockReset();
@@ -139,6 +139,18 @@ describe('update center routes', () => {
publishedAt: '2026-03-29T11:54:35.591877Z', publishedAt: '2026-03-29T11:54:35.591877Z',
url: null, url: null,
} as const; } 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 = { const helperStatus = {
ok: true, ok: true,
releaseName: 'metapi', releaseName: 'metapi',
@@ -161,7 +173,10 @@ describe('update center routes', () => {
], ],
} as const; } as const;
fetchLatestStableGitHubReleaseMock.mockResolvedValue(githubRelease); fetchLatestStableGitHubReleaseMock.mockResolvedValue(githubRelease);
fetchLatestDockerHubTagMock.mockResolvedValue(dockerHubTag); fetchDockerHubTagCandidatesMock.mockResolvedValue({
primary: dockerHubTag,
recentNonStable: dockerHubRecentTags,
});
getUpdateCenterHelperStatusMock.mockResolvedValue(helperStatus); getUpdateCenterHelperStatusMock.mockResolvedValue(helperStatus);
const saveResponse = await app.inject({ const saveResponse = await app.inject({
@@ -216,6 +231,13 @@ describe('update center routes', () => {
displayVersion: 'latest @ sha256:efb2ee655386', displayVersion: 'latest @ sha256:efb2ee655386',
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35', digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
}, },
dockerHubRecentTags: [
{
normalizedVersion: 'dev',
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
},
],
helper: { helper: {
ok: true, ok: true,
healthy: true, healthy: true,
@@ -242,11 +264,14 @@ describe('update center routes', () => {
it('returns partial status when a single version source lookup fails', async () => { it('returns partial status when a single version source lookup fails', async () => {
fetchLatestStableGitHubReleaseMock.mockRejectedValue(new Error('GitHub releases lookup timed out')); fetchLatestStableGitHubReleaseMock.mockRejectedValue(new Error('GitHub releases lookup timed out'));
fetchLatestDockerHubTagMock.mockResolvedValue({ fetchDockerHubTagCandidatesMock.mockResolvedValue({
source: 'docker-hub-tag', primary: {
rawVersion: '1.3.1', source: 'docker-hub-tag',
normalizedVersion: '1.3.1', rawVersion: '1.3.1',
url: null, normalizedVersion: '1.3.1',
url: null,
},
recentNonStable: [],
}); });
getUpdateCenterHelperStatusMock.mockResolvedValue({ getUpdateCenterHelperStatusMock.mockResolvedValue({
ok: true, ok: true,
@@ -426,6 +451,18 @@ describe('update center routes', () => {
publishedAt: '2026-03-31T09:00:00Z', publishedAt: '2026-03-31T09:00:00Z',
url: null, 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: { helper: {
ok: true, ok: true,
releaseName: 'metapi', releaseName: 'metapi',
@@ -454,6 +491,12 @@ describe('update center routes', () => {
dockerHubTag: { dockerHubTag: {
displayVersion: 'latest @ sha256:efb2ee655386', displayVersion: 'latest @ sha256:efb2ee655386',
}, },
dockerHubRecentTags: [
{
normalizedVersion: 'dev',
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
},
],
helper: { helper: {
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
}, },
@@ -462,7 +505,7 @@ describe('update center routes', () => {
}, },
}); });
expect(fetchLatestStableGitHubReleaseMock).not.toHaveBeenCalled(); expect(fetchLatestStableGitHubReleaseMock).not.toHaveBeenCalled();
expect(fetchLatestDockerHubTagMock).not.toHaveBeenCalled(); expect(fetchDockerHubTagCandidatesMock).not.toHaveBeenCalled();
expect(getUpdateCenterHelperStatusMock).not.toHaveBeenCalled(); expect(getUpdateCenterHelperStatusMock).not.toHaveBeenCalled();
}); });
@@ -477,15 +520,29 @@ describe('update center routes', () => {
publishedAt: '2026-03-31T10:00:00Z', publishedAt: '2026-03-31T10:00:00Z',
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.1', url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.1',
}); });
fetchLatestDockerHubTagMock.mockResolvedValue({ fetchDockerHubTagCandidatesMock.mockResolvedValue({
source: 'docker-hub-tag', primary: {
rawVersion: 'latest', source: 'docker-hub-tag',
normalizedVersion: 'latest', rawVersion: 'latest',
tagName: 'latest', normalizedVersion: 'latest',
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', tagName: 'latest',
displayVersion: 'latest @ sha256:dddddddddddd', digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
publishedAt: '2026-03-31T10:00:00Z', displayVersion: 'latest @ sha256:dddddddddddd',
url: null, 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({ getUpdateCenterHelperStatusMock.mockResolvedValue({
ok: true, ok: true,
@@ -512,6 +569,12 @@ describe('update center routes', () => {
dockerHubTag: { dockerHubTag: {
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
}, },
dockerHubRecentTags: [
{
normalizedVersion: 'dev-20260417-f67ade2',
digest: 'sha256:abababababababababababababababababababababababababababababababab',
},
],
helper: { helper: {
revision: '13', revision: '13',
}, },
@@ -520,7 +583,7 @@ describe('update center routes', () => {
}, },
}); });
expect(fetchLatestStableGitHubReleaseMock).toHaveBeenCalledTimes(1); expect(fetchLatestStableGitHubReleaseMock).toHaveBeenCalledTimes(1);
expect(fetchLatestDockerHubTagMock).toHaveBeenCalledTimes(1); expect(fetchDockerHubTagCandidatesMock).toHaveBeenCalledTimes(1);
expect(getUpdateCenterHelperStatusMock).toHaveBeenCalledTimes(1); expect(getUpdateCenterHelperStatusMock).toHaveBeenCalledTimes(1);
expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({ expect(await loadUpdateCenterRuntimeState()).toEqual(expect.objectContaining({
lastResolvedSource: 'github-release', lastResolvedSource: 'github-release',
@@ -532,6 +595,11 @@ describe('update center routes', () => {
dockerHubTag: expect.objectContaining({ dockerHubTag: expect.objectContaining({
digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd', digest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
}), }),
dockerHubRecentTags: [
expect.objectContaining({
normalizedVersion: 'dev-20260417-f67ade2',
}),
],
helper: expect.objectContaining({ helper: expect.objectContaining({
revision: '13', revision: '13',
}), }),
@@ -97,6 +97,7 @@ describe('updateCenterPollingService', () => {
publishedAt: '2026-03-31T12:00:00Z', publishedAt: '2026-03-31T12:00:00Z',
}, },
dockerHubTag: null, dockerHubTag: null,
dockerHubRecentTags: [],
helper: { helper: {
ok: true, ok: true,
releaseName: 'metapi', releaseName: 'metapi',
@@ -115,6 +116,7 @@ describe('updateCenterPollingService', () => {
currentVersion: '1.2.3', currentVersion: '1.2.3',
githubRelease: runtime.statusSnapshot.githubRelease, githubRelease: runtime.statusSnapshot.githubRelease,
dockerHubTag: null, dockerHubTag: null,
dockerHubRecentTags: [],
helper: runtime.statusSnapshot.helper, helper: runtime.statusSnapshot.helper,
runtime, runtime,
}, },
@@ -151,15 +153,16 @@ describe('updateCenterPollingService', () => {
lastResolvedSource: 'github-release', lastResolvedSource: 'github-release',
lastResolvedCandidateKey: 'github-release:v1.3.0', lastResolvedCandidateKey: 'github-release:v1.3.0',
lastNotifiedCandidateKey: 'github-release:v1.3.0', lastNotifiedCandidateKey: 'github-release:v1.3.0',
statusSnapshot: { statusSnapshot: expect.objectContaining({
githubRelease: expect.objectContaining({ githubRelease: expect.objectContaining({
normalizedVersion: '1.3.0', normalizedVersion: '1.3.0',
}), }),
dockerHubTag: null, dockerHubTag: null,
dockerHubRecentTags: [],
helper: expect.objectContaining({ helper: expect.objectContaining({
imageTag: '1.2.3', imageTag: '1.2.3',
}), }),
}, }),
})); }));
await vi.advanceTimersByTimeAsync(60_000); await vi.advanceTimersByTimeAsync(60_000);
@@ -205,6 +208,7 @@ describe('updateCenterPollingService', () => {
publishedAt: '2026-03-31T12:01:00Z', publishedAt: '2026-03-31T12:01:00Z',
}, },
dockerHubTag: null, dockerHubTag: null,
dockerHubRecentTags: [],
helper: { helper: {
ok: true, ok: true,
releaseName: 'metapi', releaseName: 'metapi',
@@ -223,6 +227,7 @@ describe('updateCenterPollingService', () => {
currentVersion: '1.2.3', currentVersion: '1.2.3',
githubRelease: runtime.statusSnapshot.githubRelease, githubRelease: runtime.statusSnapshot.githubRelease,
dockerHubTag: null, dockerHubTag: null,
dockerHubRecentTags: [],
helper: runtime.statusSnapshot.helper, helper: runtime.statusSnapshot.helper,
runtime, runtime,
}, },
@@ -251,15 +256,16 @@ describe('updateCenterPollingService', () => {
lastResolvedCandidateKey: 'github-release:v1.3.0', lastResolvedCandidateKey: 'github-release:v1.3.0',
lastNotifiedCandidateKey: 'github-release:v1.3.0', lastNotifiedCandidateKey: 'github-release:v1.3.0',
lastNotifiedAt: expect.any(String), lastNotifiedAt: expect.any(String),
statusSnapshot: { statusSnapshot: expect.objectContaining({
githubRelease: expect.objectContaining({ githubRelease: expect.objectContaining({
normalizedVersion: '1.3.0', normalizedVersion: '1.3.0',
}), }),
dockerHubTag: null, dockerHubTag: null,
dockerHubRecentTags: [],
helper: expect.objectContaining({ helper: expect.objectContaining({
imageTag: '1.2.3', imageTag: '1.2.3',
}), }),
}, }),
})); }));
}); });
}); });
@@ -88,6 +88,18 @@ describe('updateCenterRuntimeStateService', () => {
displayVersion: 'latest @ sha256:efb2ee655386', displayVersion: 'latest @ sha256:efb2ee655386',
publishedAt: '2026-03-30T20:30:00Z', 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: { helper: {
ok: true, ok: true,
releaseName: 'metapi', releaseName: 'metapi',
@@ -142,6 +154,18 @@ describe('updateCenterRuntimeStateService', () => {
displayVersion: 'latest @ sha256:efb2ee655386', displayVersion: 'latest @ sha256:efb2ee655386',
publishedAt: '2026-03-30T20:30:00Z', 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: { helper: {
ok: true, ok: true,
releaseName: 'metapi', releaseName: 'metapi',
@@ -8,6 +8,7 @@ import type { UpdateCenterVersionCandidate, UpdateCenterVersionSource } from './
export type UpdateCenterStatusSnapshot = { export type UpdateCenterStatusSnapshot = {
githubRelease: UpdateCenterVersionCandidate | null; githubRelease: UpdateCenterVersionCandidate | null;
dockerHubTag: UpdateCenterVersionCandidate | null; dockerHubTag: UpdateCenterVersionCandidate | null;
dockerHubRecentTags: UpdateCenterVersionCandidate[];
helper: UpdateCenterHelperStatus | null; 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<UpdateCenterHelperStatus['history']>[number] | null { function normalizeHelperHistoryEntry(input: unknown): NonNullable<UpdateCenterHelperStatus['history']>[number] | null {
if (!input || typeof input !== 'object') return null; if (!input || typeof input !== 'object') return null;
const record = input as Record<string, unknown>; const record = input as Record<string, unknown>;
@@ -109,6 +117,7 @@ function normalizeStatusSnapshot(input: unknown): UpdateCenterStatusSnapshot | n
return { return {
githubRelease: normalizeVersionCandidate(record.githubRelease), githubRelease: normalizeVersionCandidate(record.githubRelease),
dockerHubTag: normalizeVersionCandidate(record.dockerHubTag), dockerHubTag: normalizeVersionCandidate(record.dockerHubTag),
dockerHubRecentTags: normalizeVersionCandidates(record.dockerHubRecentTags),
helper: normalizeHelperSnapshot(record.helper), helper: normalizeHelperSnapshot(record.helper),
}; };
} }
@@ -2,9 +2,10 @@ import { config as runtimeConfig } from '../config.js';
import { formatUtcSqlDateTime } from './localTimeService.js'; import { formatUtcSqlDateTime } from './localTimeService.js';
import { listBackgroundTasks } from './backgroundTaskService.js'; import { listBackgroundTasks } from './backgroundTaskService.js';
import { import {
fetchLatestDockerHubTag, fetchDockerHubTagCandidates,
fetchLatestStableGitHubRelease, fetchLatestStableGitHubRelease,
getCurrentRuntimeVersion, getCurrentRuntimeVersion,
type DockerHubTagCandidates,
type UpdateCenterVersionCandidate, type UpdateCenterVersionCandidate,
} from './updateCenterVersionService.js'; } from './updateCenterVersionService.js';
import { import {
@@ -71,6 +72,7 @@ export type UpdateCenterStatusResult = {
config: UpdateCenterConfig; config: UpdateCenterConfig;
githubRelease: UpdateCenterVersionCandidate | null; githubRelease: UpdateCenterVersionCandidate | null;
dockerHubTag: UpdateCenterVersionCandidate | null; dockerHubTag: UpdateCenterVersionCandidate | null;
dockerHubRecentTags: UpdateCenterVersionCandidate[];
helper: UpdateCenterHelperStatus; helper: UpdateCenterHelperStatus;
runningTask: ReturnType<typeof getDeployTasks>[number] | null; runningTask: ReturnType<typeof getDeployTasks>[number] | null;
lastFinishedTask: ReturnType<typeof getDeployTasks>[number] | null; lastFinishedTask: ReturnType<typeof getDeployTasks>[number] | null;
@@ -92,16 +94,17 @@ function buildUnavailableHelperStatus(error: string | null = null): UpdateCenter
}; };
} }
function buildStatusSnapshot(status: Pick<UpdateCenterStatusResult, 'githubRelease' | 'dockerHubTag' | 'helper'>): UpdateCenterStatusSnapshot { function buildStatusSnapshot(status: Pick<UpdateCenterStatusResult, 'githubRelease' | 'dockerHubTag' | 'dockerHubRecentTags' | 'helper'>): UpdateCenterStatusSnapshot {
return { return {
githubRelease: status.githubRelease || null, githubRelease: status.githubRelease || null,
dockerHubTag: status.dockerHubTag || null, dockerHubTag: status.dockerHubTag || null,
dockerHubRecentTags: status.dockerHubRecentTags || [],
helper: status.helper || null, helper: status.helper || null,
}; };
} }
function buildNextRuntimeState( function buildNextRuntimeState(
status: Pick<UpdateCenterStatusResult, 'currentVersion' | 'githubRelease' | 'dockerHubTag' | 'helper'>, status: Pick<UpdateCenterStatusResult, 'currentVersion' | 'githubRelease' | 'dockerHubTag' | 'dockerHubRecentTags' | 'helper'>,
previousRuntime: UpdateCenterRuntimeState, previousRuntime: UpdateCenterRuntimeState,
checkedAt: string, checkedAt: string,
): { candidate: UpdateReminderCandidate | null; nextRuntime: UpdateCenterRuntimeState } { ): { candidate: UpdateReminderCandidate | null; nextRuntime: UpdateCenterRuntimeState } {
@@ -137,6 +140,7 @@ function buildResponseFromState(config: UpdateCenterConfig, runtime: UpdateCente
config, config,
githubRelease: snapshot?.githubRelease || null, githubRelease: snapshot?.githubRelease || null,
dockerHubTag: snapshot?.dockerHubTag || null, dockerHubTag: snapshot?.dockerHubTag || null,
dockerHubRecentTags: snapshot?.dockerHubRecentTags || [],
helper: snapshot?.helper || buildUnavailableHelperStatus(runtime.lastCheckError), helper: snapshot?.helper || buildUnavailableHelperStatus(runtime.lastCheckError),
runningTask, runningTask,
lastFinishedTask, lastFinishedTask,
@@ -150,7 +154,7 @@ export async function buildUpdateCenterStatus(): Promise<UpdateCenterStatusResul
const [githubLookup, dockerLookup, helperLookup, runtime] = await Promise.all([ const [githubLookup, dockerLookup, helperLookup, runtime] = await Promise.all([
settleOptional(config.githubReleasesEnabled, async () => await fetchLatestStableGitHubRelease()), settleOptional(config.githubReleasesEnabled, async () => await fetchLatestStableGitHubRelease()),
settleOptional(config.dockerHubTagsEnabled, async () => await fetchLatestDockerHubTag()), settleOptional(config.dockerHubTagsEnabled, async () => await fetchDockerHubTagCandidates()),
settleOptional(!!config.helperBaseUrl, async () => { settleOptional(!!config.helperBaseUrl, async () => {
if (!helperToken) { if (!helperToken) {
throw new Error('DEPLOY_HELPER_TOKEN is required'); throw new Error('DEPLOY_HELPER_TOKEN is required');
@@ -161,7 +165,9 @@ export async function buildUpdateCenterStatus(): Promise<UpdateCenterStatusResul
]); ]);
const githubRelease = githubLookup.value as UpdateCenterVersionCandidate | null; const githubRelease = githubLookup.value as UpdateCenterVersionCandidate | null;
const dockerHubTag = dockerLookup.value as UpdateCenterVersionCandidate | null; const dockerHubCandidates = dockerLookup.value as DockerHubTagCandidates | null;
const dockerHubTag = dockerHubCandidates?.primary || null;
const dockerHubRecentTags = dockerHubCandidates?.recentNonStable || [];
const helper = (helperLookup.value as UpdateCenterHelperStatus | null) || buildUnavailableHelperStatus(helperLookup.error); const helper = (helperLookup.value as UpdateCenterHelperStatus | null) || buildUnavailableHelperStatus(helperLookup.error);
const tasks = getDeployTasks(); const tasks = getDeployTasks();
@@ -173,6 +179,7 @@ export async function buildUpdateCenterStatus(): Promise<UpdateCenterStatusResul
config, config,
githubRelease, githubRelease,
dockerHubTag, dockerHubTag,
dockerHubRecentTags,
helper, helper,
runningTask, runningTask,
lastFinishedTask, lastFinishedTask,
@@ -14,12 +14,15 @@ vi.mock('undici', async () => {
import { import {
compareStableSemVer, compareStableSemVer,
fetchDockerHubTagCandidates,
fetchLatestDockerHubTag, fetchLatestDockerHubTag,
fetchLatestStableGitHubRelease, fetchLatestStableGitHubRelease,
parseStableSemVer, parseStableSemVer,
resolvePreferredDeploySource, resolvePreferredDeploySource,
selectDockerHubTagCandidates,
selectLatestDockerHubTag, selectLatestDockerHubTag,
selectLatestStableGitHubRelease, selectLatestStableGitHubRelease,
selectRecentNonStableDockerHubTags,
} from './updateCenterVersionService.js'; } from './updateCenterVersionService.js';
describe('update center version service', () => { 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', () => { describe('resolvePreferredDeploySource', () => {
it('prefers the configured default source when both channels have valid candidates', () => { it('prefers the configured default source when both channels have valid candidates', () => {
const preferred = resolvePreferredDeploySource({ 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',
]);
});
});
}); });
@@ -39,11 +39,17 @@ export type DockerHubTagRecord = {
digest?: string | null; digest?: string | null;
}; };
export type DockerHubTagCandidates = {
primary: UpdateCenterVersionCandidate | null;
recentNonStable: UpdateCenterVersionCandidate[];
};
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i; const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
const GITHUB_RELEASES_URL = 'https://api.github.com/repos/cita-777/metapi/releases'; 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 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 UPDATE_CENTER_VERSION_FETCH_TIMEOUT_MS = 5_000;
const PREFERRED_DOCKER_HUB_TAG_ALIASES = ['latest', 'main'] as const; 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<unknown> { async function fetchJsonWithTimeout(url: string, init: UndiciRequestInit, timeoutLabel: string): Promise<unknown> {
const controller = new AbortController(); const controller = new AbortController();
@@ -133,6 +139,21 @@ function normalizeDockerHubTagRecord(input: string | DockerHubTagRecord): Docker
return input; 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 { function normalizeDockerDigest(input: string | null | undefined): string | null {
const digest = String(input || '').trim(); const digest = String(input || '').trim();
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : null; 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; 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 { function toShortDigest(digest: string | null | undefined): string | null {
if (!digest) return null; if (!digest) return null;
return digest.slice(0, 'sha256:'.length + 12); return digest.slice(0, 'sha256:'.length + 12);
@@ -194,6 +231,44 @@ export function selectLatestDockerHubTag(tags: Array<string | DockerHubTagRecord
return buildDockerHubVersionCandidate(selected.record, selected.semver.normalized); return buildDockerHubVersionCandidate(selected.record, selected.semver.normalized);
} }
export function selectRecentNonStableDockerHubTags(
tags: Array<string | DockerHubTagRecord>,
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<string, DockerHubTagRecord>();
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<string | DockerHubTagRecord>): DockerHubTagCandidates {
return {
primary: selectLatestDockerHubTag(tags),
recentNonStable: selectRecentNonStableDockerHubTags(tags),
};
}
export function resolvePreferredDeploySource(input: { export function resolvePreferredDeploySource(input: {
defaultSource: UpdateCenterVersionSource; defaultSource: UpdateCenterVersionSource;
githubRelease: UpdateCenterVersionCandidate | null; githubRelease: UpdateCenterVersionCandidate | null;
@@ -216,13 +291,17 @@ export async function fetchLatestStableGitHubRelease(): Promise<UpdateCenterVers
} }
export async function fetchLatestDockerHubTag(): Promise<UpdateCenterVersionCandidate | null> { export async function fetchLatestDockerHubTag(): Promise<UpdateCenterVersionCandidate | null> {
return (await fetchDockerHubTagCandidates()).primary;
}
export async function fetchDockerHubTagCandidates(): Promise<DockerHubTagCandidates> {
const payload = await fetchJsonWithTimeout(DOCKER_HUB_TAGS_URL, { const payload = await fetchJsonWithTimeout(DOCKER_HUB_TAGS_URL, {
headers: { headers: {
accept: 'application/json', accept: 'application/json',
'user-agent': 'metapi-update-center/1.0', 'user-agent': 'metapi-update-center/1.0',
}, },
}, 'Docker Hub tag lookup') as { results?: DockerHubTagRecord[] }; }, '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 { export function getCurrentRuntimeVersion(): string {
@@ -73,6 +73,22 @@ describe('UpdateCenterSection', () => {
displayVersion: 'latest @ sha256:efb2ee655386', displayVersion: 'latest @ sha256:efb2ee655386',
publishedAt: '2026-03-29T11:54:35.591877Z', 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: { helper: {
ok: true, ok: true,
healthy: true, healthy: true,
@@ -159,6 +175,15 @@ describe('UpdateCenterSection', () => {
displayVersion: 'latest @ sha256:efb2ee655386', displayVersion: 'latest @ sha256:efb2ee655386',
publishedAt: '2026-03-29T11:54:35.591877Z', 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: { helper: {
ok: true, ok: true,
healthy: 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(
<MemoryRouter>
<ToastProvider>
<UpdateCenterSection />
</ToastProvider>
</MemoryRouter>,
);
});
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 () => { it('keeps rollback history compact and opens the full revision list in a centered modal', async () => {
let root!: ReactTestRenderer; let root!: ReactTestRenderer;
try { try {
+90 -3
View File
@@ -39,6 +39,13 @@ type UpdateCenterStatus = {
digest?: string | null; digest?: string | null;
publishedAt?: string | null; publishedAt?: string | null;
} | null; } | null;
dockerHubRecentTags?: Array<{
normalizedVersion?: string;
displayVersion?: string;
tagName?: string;
digest?: string | null;
publishedAt?: string | null;
}> | null;
helper?: { helper?: {
ok?: boolean; ok?: boolean;
healthy?: boolean; healthy?: boolean;
@@ -214,6 +221,11 @@ function formatImageTarget(tag?: string | null, digest?: string | null) {
return ''; 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() { export default function UpdateCenterSection() {
const toast = useToast(); const toast = useToast();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -432,6 +444,11 @@ export default function UpdateCenterSection() {
const canDeployDocker = !deploying && dockerDeployState.canDeploy; const canDeployDocker = !deploying && dockerDeployState.canDeploy;
const manualDockerTag = String(manualDockerTarget.tag || '').trim(); const manualDockerTag = String(manualDockerTarget.tag || '').trim();
const manualDockerDigest = String(manualDockerTarget.digest || '').trim(); const manualDockerDigest = String(manualDockerTarget.digest || '').trim();
const recentDockerCandidates = normalizeRecentDockerCandidates(status?.dockerHubRecentTags);
const canDeployDockerByTag = !deploying
&& config.enabled
&& config.dockerHubTagsEnabled
&& helperHealthy;
const canDeployManualDocker = !deploying const canDeployManualDocker = !deploying
&& config.enabled && config.enabled
&& config.dockerHubTagsEnabled && config.dockerHubTagsEnabled
@@ -570,7 +587,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)' }}>Docker Hub</span> <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>Docker Hub</span>
<span style={fieldHintStyle}>自动发现优先稳定标签;dev / 分支 / 临时标签可在下方手动部署。</span> <span style={fieldHintStyle}>自动发现会保留稳定主候选,并额外列出最近的 dev / 分支 / sha 标签。</span>
</span> </span>
</label> </label>
</div> </div>
@@ -731,7 +748,7 @@ export default function UpdateCenterSection() {
) : null} ) : null}
</div> </div>
</div> </div>
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>自动候选优先 latest / main / 稳定 SemVer;dev / 分支 / sha 标签可手动填写部署。</div> <div style={{ ...fieldHintStyle, marginBottom: 10 }}>主候选优先 latest / main / 稳定 SemVer;下方还会自动列出最近的 dev / 分支 / sha 标签。</div>
<div className={dockerDeployState.highlight ? 'stat-value-glow' : ''} style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}> <div className={dockerDeployState.highlight ? 'stat-value-glow' : ''} style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
{status?.dockerHubTag?.displayVersion || status?.dockerHubTag?.normalizedVersion || '未发现'} {status?.dockerHubTag?.displayVersion || status?.dockerHubTag?.normalizedVersion || '未发现'}
</div> </div>
@@ -773,12 +790,82 @@ export default function UpdateCenterSection() {
</button> </button>
) : null} ) : null}
</div> </div>
<div style={{ borderTop: '1px dashed var(--color-border-light)', marginTop: 4, paddingTop: 12, marginBottom: 12 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-primary)', fontWeight: 600, marginBottom: 6 }}>
最近非稳定 Docker 标签
</div>
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>
自动列出最近推送的 dev / 分支 / sha 标签;点部署即可直接使用,不必手动输入 tag 和 digest。
</div>
{recentDockerCandidates.length ? (
<div style={{ display: 'grid', gap: 8 }}>
{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 (
<div
key={`${candidateTag}:${candidateDigest || 'no-digest'}`}
style={{
border: '1px solid var(--color-border-light)',
borderRadius: 'var(--radius-sm)',
padding: 10,
display: 'grid',
gap: 8,
}}
>
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 0 }}>
{candidateLabel}
</div>
<div style={fieldHintStyle}>
最近推送:{formatTaskTime(candidate.publishedAt)}
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
disabled={!canDeployDockerByTag || !candidateTag}
onClick={() => {
if (!canDeployDockerByTag || !candidateTag) return;
void runDeploy('docker-hub-tag', {
tag: candidateTag,
digest: candidateDigest || null,
});
}}
>
部署 {candidateTag}
</button>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
onClick={() => {
setManualDockerTarget({
tag: candidateTag,
digest: candidateDigest,
});
}}
>
填入手动区
</button>
</div>
</div>
);
})}
</div>
) : (
<div style={fieldHintStyle}>
暂未发现最近的 dev / 分支 / sha 标签;仍可在下方手动填写任意 Docker 标签。
</div>
)}
</div>
<div style={{ borderTop: '1px dashed var(--color-border-light)', marginTop: 4, paddingTop: 12 }}> <div style={{ borderTop: '1px dashed var(--color-border-light)', marginTop: 4, paddingTop: 12 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-primary)', fontWeight: 600, marginBottom: 6 }}> <div style={{ fontSize: 12, color: 'var(--color-text-primary)', fontWeight: 600, marginBottom: 6 }}>
手动部署 Docker Hub 标签 手动部署 Docker Hub 标签
</div> </div>
<div style={{ ...fieldHintStyle, marginBottom: 10 }}> <div style={{ ...fieldHintStyle, marginBottom: 10 }}>
自动候选不会覆盖所有分支标签。若要切到 dev、feature、临时或 sha 标签,直接在这里填写。 自动发现已经覆盖最近的非稳定标签;如果你要部署更老或更特殊的 tag,仍可直接在这里填写。
</div> </div>
<div <div
style={{ style={{