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
@@ -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 {
+90 -3
View File
@@ -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={{