Let operators deploy dev Docker Hub tags from the update center
The update center previously surfaced stable Docker Hub candidates but left dev and branch-tag rollouts stranded in SSH and Helm commands. This adds a repo-native manual Docker tag entrypoint in Settings so operators can deploy dev, branch, temporary, or sha tags through the existing helper flow without changing the backend deploy contract. Constraint: Keep the existing helper API and digest-aware Helm contract unchanged Constraint: Preserve stable Docker Hub candidate discovery for default reminders Rejected: Expand automatic Docker Hub discovery to all branch tags | would blur stable release reminders with operator-only rollout targets Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep automatic candidate detection stable-first; manual tag deploy is the escape hatch for non-stable tags Tested: update-center focused vitest suite; typecheck; build:web; docs:build; repo:drift-check Not-tested: Full npm test remains red due to unrelated tmp/sub2api frontend suites and an existing firstByteTimeout unhandled error
This commit is contained in:
@@ -308,6 +308,11 @@ helper 端使用:
|
|||||||
- `GitHub Releases`
|
- `GitHub Releases`
|
||||||
- `Docker Hub`
|
- `Docker Hub`
|
||||||
|
|
||||||
|
补充一点:
|
||||||
|
|
||||||
|
- `Docker Hub` 里的**自动发现**仍然优先 `latest` / `main` / 稳定 SemVer 标签
|
||||||
|
- 如果你要部署 `dev`、分支名、临时标签或 `sha-*` 这类非稳定标签,直接在 Docker Hub 卡片底部用“手动部署 Docker Hub 标签”填写即可
|
||||||
|
|
||||||
对已经跑起来的 K3s / Helm 用户来说,更新中心的日常配置主要就在这一页:
|
对已经跑起来的 K3s / Helm 用户来说,更新中心的日常配置主要就在这一页:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -325,7 +330,7 @@ helper 端使用:
|
|||||||
- 版本来源发现了可部署版本
|
- 版本来源发现了可部署版本
|
||||||
- 如果 Docker Hub 显示的是 `latest @ sha256:...`,说明页面已经识别到 alias tag 当前指向的具体镜像 digest
|
- 如果 Docker Hub 显示的是 `latest @ sha256:...`,说明页面已经识别到 alias tag 当前指向的具体镜像 digest
|
||||||
- Deploy Helper 显示健康
|
- Deploy Helper 显示健康
|
||||||
4. 再点部署按钮
|
4. 如果你要跟随稳定候选,直接点部署按钮;如果你要切到 `dev` / 分支 / 临时标签,就在 Docker Hub 卡片底部手动填写 tag,必要时连 digest 一起填
|
||||||
5. 在页面下方看部署日志
|
5. 在页面下方看部署日志
|
||||||
6. 如果升级后发现问题,可以直接在“回退历史”里点旧 revision 回滚;只要该 revision 当时记录了 digest,就会跟着一起回到对应镜像
|
6. 如果升级后发现问题,可以直接在“回退历史”里点旧 revision 回滚;只要该 revision 当时记录了 digest,就会跟着一起回到对应镜像
|
||||||
|
|
||||||
|
|||||||
@@ -375,6 +375,62 @@ describe('UpdateCenterSection', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('deploys manual Docker Hub tags so dev and branch images are reachable from the UI', async () => {
|
||||||
|
let root!: ReactTestRenderer;
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<MemoryRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<UpdateCenterSection />
|
||||||
|
</ToastProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
const manualTagInput = root.root.find((node) => (
|
||||||
|
node.type === 'input'
|
||||||
|
&& node.props.placeholder === 'dev / dev-20260417-f67ade2 / sha-f67ade2'
|
||||||
|
));
|
||||||
|
const manualDigestInput = root.root.find((node) => (
|
||||||
|
node.type === 'input'
|
||||||
|
&& node.props.placeholder === '可选 digest:sha256:...'
|
||||||
|
));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
manualTagInput.props.onChange({ target: { value: 'dev-20260417-f67ade2' } });
|
||||||
|
manualDigestInput.props.onChange({
|
||||||
|
target: {
|
||||||
|
value: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const customDeployButton = root.root.find((node) => (
|
||||||
|
node.type === 'button'
|
||||||
|
&& typeof node.props.onClick === 'function'
|
||||||
|
&& collectText(node).includes('部署自定义 Docker 标签')
|
||||||
|
));
|
||||||
|
|
||||||
|
expect(customDeployButton.props.disabled).toBe(false);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await customDeployButton.props.onClick();
|
||||||
|
});
|
||||||
|
await flushMicrotasks();
|
||||||
|
|
||||||
|
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
||||||
|
source: 'docker-hub-tag',
|
||||||
|
targetTag: 'dev-20260417-f67ade2',
|
||||||
|
targetDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
});
|
||||||
|
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 {
|
||||||
|
|||||||
@@ -223,6 +223,10 @@ export default function UpdateCenterSection() {
|
|||||||
const [deploying, setDeploying] = useState(false);
|
const [deploying, setDeploying] = useState(false);
|
||||||
const [status, setStatus] = useState<UpdateCenterStatus | null>(null);
|
const [status, setStatus] = useState<UpdateCenterStatus | null>(null);
|
||||||
const [config, setConfig] = useState(DEFAULT_CONFIG);
|
const [config, setConfig] = useState(DEFAULT_CONFIG);
|
||||||
|
const [manualDockerTarget, setManualDockerTarget] = useState({
|
||||||
|
tag: '',
|
||||||
|
digest: '',
|
||||||
|
});
|
||||||
const [logs, setLogs] = useState<string[]>([]);
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
const [taskStatus, setTaskStatus] = useState('');
|
const [taskStatus, setTaskStatus] = useState('');
|
||||||
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
||||||
@@ -426,6 +430,13 @@ export default function UpdateCenterSection() {
|
|||||||
});
|
});
|
||||||
const canDeployGithub = !deploying && githubDeployState.canDeploy;
|
const canDeployGithub = !deploying && githubDeployState.canDeploy;
|
||||||
const canDeployDocker = !deploying && dockerDeployState.canDeploy;
|
const canDeployDocker = !deploying && dockerDeployState.canDeploy;
|
||||||
|
const manualDockerTag = String(manualDockerTarget.tag || '').trim();
|
||||||
|
const manualDockerDigest = String(manualDockerTarget.digest || '').trim();
|
||||||
|
const canDeployManualDocker = !deploying
|
||||||
|
&& config.enabled
|
||||||
|
&& config.dockerHubTagsEnabled
|
||||||
|
&& helperHealthy
|
||||||
|
&& !!manualDockerTag;
|
||||||
const helperHistory = Array.isArray(status?.helper?.history) ? status.helper.history : [];
|
const helperHistory = Array.isArray(status?.helper?.history) ? status.helper.history : [];
|
||||||
const historyPreview = helperHistory.slice(0, 2);
|
const historyPreview = helperHistory.slice(0, 2);
|
||||||
const currentRevision = String(status?.helper?.revision || '').trim();
|
const currentRevision = String(status?.helper?.revision || '').trim();
|
||||||
@@ -559,7 +570,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}>从镜像标签中找出最新稳定版本,适合镜像优先的发布链路。</span>
|
<span style={fieldHintStyle}>自动发现优先稳定标签;dev / 分支 / 临时标签可在下方手动部署。</span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -720,7 +731,7 @@ export default function UpdateCenterSection() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>适合镜像标签领先于 release 的场景,直接跟随容器分发节奏。</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>
|
||||||
@@ -730,6 +741,7 @@ export default function UpdateCenterSection() {
|
|||||||
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
||||||
最近推送:{formatTaskTime(status?.dockerHubTag?.publishedAt)}
|
最近推送:{formatTaskTime(status?.dockerHubTag?.publishedAt)}
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -745,6 +757,71 @@ export default function UpdateCenterSection() {
|
|||||||
>
|
>
|
||||||
部署 Docker Hub 标签
|
部署 Docker Hub 标签
|
||||||
</button>
|
</button>
|
||||||
|
{status?.dockerHubTag?.tagName ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
onClick={() => {
|
||||||
|
setManualDockerTarget({
|
||||||
|
tag: status?.dockerHubTag?.tagName || '',
|
||||||
|
digest: status?.dockerHubTag?.digest || '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
填入当前候选
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</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 标签,直接在这里填写。
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||||
|
gap: 8,
|
||||||
|
marginBottom: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
value={manualDockerTarget.tag}
|
||||||
|
onChange={(e) => setManualDockerTarget((prev) => ({ ...prev, tag: e.target.value }))}
|
||||||
|
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||||
|
placeholder="dev / dev-20260417-f67ade2 / sha-f67ade2"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={manualDockerTarget.digest}
|
||||||
|
onChange={(e) => setManualDockerTarget((prev) => ({ ...prev, digest: e.target.value }))}
|
||||||
|
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||||
|
placeholder="可选 digest:sha256:..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ border: '1px solid var(--color-border)' }}
|
||||||
|
disabled={!canDeployManualDocker}
|
||||||
|
onClick={() => {
|
||||||
|
if (!canDeployManualDocker) return;
|
||||||
|
void runDeploy('docker-hub-tag', {
|
||||||
|
tag: manualDockerTag,
|
||||||
|
digest: manualDockerDigest || null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
部署自定义 Docker 标签
|
||||||
|
</button>
|
||||||
|
<span style={fieldHintStyle}>
|
||||||
|
digest 选填;如果 chart 当前锁定了旧 digest,建议把 tag 和 digest 一起填写。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user