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:
@@ -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,就会跟着一起回到对应镜像
|
||||
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<UpdateCenterHelperStatus['history']>[number] | null {
|
||||
if (!input || typeof input !== 'object') return null;
|
||||
const record = input as Record<string, unknown>;
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
githubRelease: status.githubRelease || null,
|
||||
dockerHubTag: status.dockerHubTag || null,
|
||||
dockerHubRecentTags: status.dockerHubRecentTags || [],
|
||||
helper: status.helper || null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNextRuntimeState(
|
||||
status: Pick<UpdateCenterStatusResult, 'currentVersion' | 'githubRelease' | 'dockerHubTag' | 'helper'>,
|
||||
status: Pick<UpdateCenterStatusResult, 'currentVersion' | 'githubRelease' | 'dockerHubTag' | 'dockerHubRecentTags' | 'helper'>,
|
||||
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<UpdateCenterStatusResul
|
||||
|
||||
const [githubLookup, dockerLookup, helperLookup, runtime] = await Promise.all([
|
||||
settleOptional(config.githubReleasesEnabled, async () => 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<UpdateCenterStatusResul
|
||||
]);
|
||||
|
||||
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 tasks = getDeployTasks();
|
||||
@@ -173,6 +179,7 @@ export async function buildUpdateCenterStatus(): Promise<UpdateCenterStatusResul
|
||||
config,
|
||||
githubRelease,
|
||||
dockerHubTag,
|
||||
dockerHubRecentTags,
|
||||
helper,
|
||||
runningTask,
|
||||
lastFinishedTask,
|
||||
|
||||
@@ -14,12 +14,15 @@ vi.mock('undici', async () => {
|
||||
|
||||
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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<unknown> {
|
||||
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<string | DockerHubTagRecord
|
||||
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: {
|
||||
defaultSource: UpdateCenterVersionSource;
|
||||
githubRelease: UpdateCenterVersionCandidate | null;
|
||||
@@ -216,13 +291,17 @@ export async function fetchLatestStableGitHubRelease(): Promise<UpdateCenterVers
|
||||
}
|
||||
|
||||
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, {
|
||||
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 {
|
||||
|
||||
@@ -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(
|
||||
<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 () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
|
||||
@@ -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() {
|
||||
/>
|
||||
<span style={{ display: 'grid', gap: 4 }}>
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
@@ -731,7 +748,7 @@ export default function UpdateCenterSection() {
|
||||
) : null}
|
||||
</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 }}>
|
||||
{status?.dockerHubTag?.displayVersion || status?.dockerHubTag?.normalizedVersion || '未发现'}
|
||||
</div>
|
||||
@@ -773,12 +790,82 @@ export default function UpdateCenterSection() {
|
||||
</button>
|
||||
) : null}
|
||||
</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={{ fontSize: 12, color: 'var(--color-text-primary)', fontWeight: 600, marginBottom: 6 }}>
|
||||
手动部署 Docker Hub 标签
|
||||
</div>
|
||||
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>
|
||||
自动候选不会覆盖所有分支标签。若要切到 dev、feature、临时或 sha 标签,直接在这里填写。
|
||||
自动发现已经覆盖最近的非稳定标签;如果你要部署更老或更特殊的 tag,仍可直接在这里填写。
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
|
||||
Reference in New Issue
Block a user