forked from boos/BoosAPI
[codex] add automatic update-center reminders (#333)
* add automatic update-center reminders * fix update-center reminder review issues
This commit is contained in:
@@ -76,6 +76,12 @@ describe('navigationFocus helpers', () => {
|
||||
type: 'proxy',
|
||||
})).toBe('/routes');
|
||||
|
||||
expect(buildEventNavigationPath({
|
||||
relatedType: 'update_center',
|
||||
relatedId: null,
|
||||
type: 'status',
|
||||
})).toBe('/settings');
|
||||
|
||||
expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs');
|
||||
expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin');
|
||||
expect(buildEventNavigationPath({ type: 'site_notice' })).toBe('/site-announcements');
|
||||
|
||||
@@ -98,6 +98,9 @@ export function buildEventNavigationPath(event: {
|
||||
if (relatedType === 'site_announcement' && relatedId) {
|
||||
return buildAnnouncementFocusPath(relatedId);
|
||||
}
|
||||
if (relatedType === 'update_center') {
|
||||
return '/settings';
|
||||
}
|
||||
if (relatedType === 'route') {
|
||||
return '/routes';
|
||||
}
|
||||
|
||||
@@ -40,4 +40,44 @@ describe('updateCenterPresentation', () => {
|
||||
expect(state.canDeploy).toBe(true);
|
||||
expect(state.badgeLabel).toBe('发现新 digest');
|
||||
});
|
||||
|
||||
it('treats semver tags with and without a v prefix as the same Docker digest target', () => {
|
||||
const state = describeDockerDeployState({
|
||||
enabled: true,
|
||||
helperHealthy: true,
|
||||
currentVersion: '1.2.3',
|
||||
helper: {
|
||||
imageTag: 'v1.2.3',
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
candidate: {
|
||||
normalizedVersion: '1.2.3',
|
||||
tagName: '1.2.3',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.kind).toBe('new-digest');
|
||||
expect(state.canDeploy).toBe(true);
|
||||
});
|
||||
|
||||
it('does not advertise an older GitHub reminder when the helper is already ahead', () => {
|
||||
expect(buildUpdateReminder({
|
||||
currentVersion: '1.2.3',
|
||||
helper: {
|
||||
imageTag: '1.4.0',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
displayVersion: '1.3.0',
|
||||
tagName: 'v1.3.0',
|
||||
},
|
||||
dockerHubTag: null,
|
||||
})).toEqual({
|
||||
label: '已是最新',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
detail: '当前运行版本与已发现的部署目标没有明显差异。',
|
||||
highlight: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
export type UpdateVersionCandidateLike = {
|
||||
normalizedVersion?: string | null;
|
||||
displayVersion?: string | null;
|
||||
tagName?: string | null;
|
||||
digest?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateHelperRuntimeLike = {
|
||||
imageTag?: string | null;
|
||||
imageDigest?: string | null;
|
||||
};
|
||||
import {
|
||||
compareStableVersions,
|
||||
isSameImageTarget,
|
||||
resolveUpdateReminderCandidate,
|
||||
type UpdateHelperRuntimeLike,
|
||||
type UpdateVersionCandidateLike,
|
||||
} from '../../../shared/updateCenterReminder.js';
|
||||
|
||||
export type UpdateDeployState = {
|
||||
kind: 'disabled' | 'missing' | 'helper-unhealthy' | 'same-version' | 'same-image' | 'new-version' | 'new-digest' | 'available';
|
||||
@@ -26,8 +22,6 @@ export type UpdateReminder = {
|
||||
highlight: boolean;
|
||||
};
|
||||
|
||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
||||
|
||||
function normalizeString(value?: string | null): string {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
@@ -37,53 +31,6 @@ function normalizeDigest(value?: string | null): string {
|
||||
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function normalizeStableVersion(value?: string | null): string {
|
||||
const raw = normalizeString(value);
|
||||
if (!raw) return '';
|
||||
const match = raw.match(STABLE_SEMVER_PATTERN);
|
||||
if (!match) return '';
|
||||
return [
|
||||
Number.parseInt(match[1], 10),
|
||||
Number.parseInt(match[2], 10),
|
||||
Number.parseInt(match[3], 10),
|
||||
].join('.');
|
||||
}
|
||||
|
||||
export function compareStableVersions(left?: string | null, right?: string | null): number | null {
|
||||
const normalizedLeft = normalizeStableVersion(left);
|
||||
const normalizedRight = normalizeStableVersion(right);
|
||||
if (!normalizedLeft || !normalizedRight) return null;
|
||||
|
||||
const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10));
|
||||
const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10));
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (leftParts[index] === rightParts[index]) continue;
|
||||
return leftParts[index] < rightParts[index] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isSameImageTarget(
|
||||
current: UpdateHelperRuntimeLike | null | undefined,
|
||||
target: { tag?: string | null; digest?: string | null },
|
||||
): boolean {
|
||||
const currentDigest = normalizeDigest(current?.imageDigest);
|
||||
const targetDigest = normalizeDigest(target.digest);
|
||||
if (currentDigest && targetDigest) {
|
||||
return currentDigest === targetDigest;
|
||||
}
|
||||
|
||||
const currentTag = normalizeString(current?.imageTag);
|
||||
const targetTag = normalizeString(target.tag);
|
||||
if (!currentTag || !targetTag || currentTag !== targetTag) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentVersion = normalizeStableVersion(currentTag);
|
||||
const targetVersion = normalizeStableVersion(targetTag);
|
||||
return !!currentVersion && currentVersion === targetVersion;
|
||||
}
|
||||
|
||||
export function describeGitHubDeployState(input: {
|
||||
enabled: boolean;
|
||||
helperHealthy: boolean;
|
||||
@@ -127,14 +74,17 @@ export function describeGitHubDeployState(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const versionCompare = compareStableVersions(input.currentVersion, candidateVersion || candidateTag);
|
||||
const helperVersionCompare = compareStableVersions(input.helperImageTag, candidateTag);
|
||||
if (versionCompare === 0 || helperVersionCompare === 0) {
|
||||
const candidateTargetVersion = candidateVersion || candidateTag;
|
||||
const versionCompare = compareStableVersions(input.currentVersion, candidateTargetVersion);
|
||||
const helperVersionCompare = compareStableVersions(input.helperImageTag, candidateTargetVersion);
|
||||
if (versionCompare === 0 || helperVersionCompare === 0 || helperVersionCompare === 1) {
|
||||
return {
|
||||
kind: 'same-version',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
badgeLabel: '当前运行',
|
||||
reason: '当前已运行该版本,无需重复部署。',
|
||||
reason: helperVersionCompare === 1
|
||||
? 'Deploy Helper 已指向更高版本,无需回退到较旧的 GitHub 稳定版。'
|
||||
: '当前已运行该版本,无需重复部署。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
@@ -216,8 +166,21 @@ export function describeDockerDeployState(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const versionCompare = compareStableVersions(input.currentVersion, candidateVersion || candidateTag);
|
||||
if (versionCompare === -1) {
|
||||
const candidateTargetVersion = candidateVersion || candidateTag;
|
||||
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, candidateTargetVersion);
|
||||
if (helperVersionCompare === 1) {
|
||||
return {
|
||||
kind: 'same-version',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
badgeLabel: '当前运行',
|
||||
reason: 'Deploy Helper 已指向更高版本,无需回退到较旧镜像。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
const versionCompare = compareStableVersions(input.currentVersion, candidateTargetVersion);
|
||||
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
|
||||
return {
|
||||
kind: 'new-version',
|
||||
badgeClassName: 'badge badge-success',
|
||||
@@ -228,9 +191,18 @@ export function describeDockerDeployState(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const helperTag = normalizeString(input.helper?.imageTag);
|
||||
const helperDigest = normalizeDigest(input.helper?.imageDigest);
|
||||
if (candidateDigest && helperTag && helperTag === candidateTag && helperDigest && helperDigest !== candidateDigest) {
|
||||
const hasSameStableTag = isSameImageTarget(
|
||||
{
|
||||
imageTag: input.helper?.imageTag,
|
||||
imageDigest: null,
|
||||
},
|
||||
{
|
||||
tag: candidateTag,
|
||||
digest: null,
|
||||
},
|
||||
);
|
||||
if (candidateDigest && helperDigest && hasSameStableTag && helperDigest !== candidateDigest) {
|
||||
return {
|
||||
kind: 'new-digest',
|
||||
badgeClassName: 'badge badge-success',
|
||||
@@ -277,37 +249,22 @@ export function buildUpdateReminder(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const githubState = describeGitHubDeployState({
|
||||
enabled: true,
|
||||
helperHealthy: true,
|
||||
currentVersion: input.currentVersion,
|
||||
helperImageTag: input.helper?.imageTag,
|
||||
candidate: input.githubRelease,
|
||||
});
|
||||
if (githubState.kind === 'new-version') {
|
||||
return {
|
||||
label: '发现新版本',
|
||||
badgeClassName: githubState.badgeClassName,
|
||||
detail: `GitHub 稳定版 ${normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion)} 已可部署。`,
|
||||
highlight: true,
|
||||
};
|
||||
}
|
||||
|
||||
const dockerState = describeDockerDeployState({
|
||||
enabled: true,
|
||||
helperHealthy: true,
|
||||
const candidate = resolveUpdateReminderCandidate({
|
||||
currentVersion: input.currentVersion,
|
||||
helper: input.helper,
|
||||
candidate: input.dockerHubTag,
|
||||
githubRelease: input.githubRelease,
|
||||
dockerHubTag: input.dockerHubTag,
|
||||
});
|
||||
if (dockerState.kind === 'new-version' || dockerState.kind === 'new-digest') {
|
||||
if (candidate) {
|
||||
return {
|
||||
label: dockerState.badgeLabel,
|
||||
badgeClassName: dockerState.badgeClassName,
|
||||
detail: dockerState.kind === 'new-digest'
|
||||
label: candidate.kind === 'new-digest' ? '发现新 digest' : '发现新版本',
|
||||
badgeClassName: 'badge badge-success',
|
||||
detail: candidate.kind === 'new-digest'
|
||||
? 'Docker Hub 的 alias tag 已指向新 digest,可按需部署。'
|
||||
: `Docker Hub ${normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion)} 已可部署。`,
|
||||
highlight: dockerState.highlight,
|
||||
: candidate.source === 'github-release'
|
||||
? `GitHub 稳定版 ${normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion)} 已可部署。`
|
||||
: `Docker Hub ${normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion)} 已可部署。`,
|
||||
highlight: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,15 @@ describe('UpdateCenterSection', () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
runtime: {
|
||||
lastCheckedAt: '2026-03-30 20:30:00',
|
||||
lastCheckError: null,
|
||||
lastResolvedSource: 'github-release',
|
||||
lastResolvedDisplayVersion: '1.3.0',
|
||||
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
||||
lastNotifiedCandidateKey: 'github-release:v1.3.0',
|
||||
lastNotifiedAt: '2026-03-30 20:31:00',
|
||||
},
|
||||
runningTask: null,
|
||||
lastFinishedTask: null,
|
||||
});
|
||||
@@ -254,6 +263,8 @@ describe('UpdateCenterSection', () => {
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('latest @ sha256:efb2ee655386');
|
||||
expect(text).toContain('发现新版本');
|
||||
expect(text).toContain('后台检查');
|
||||
expect(text).toContain('1.3.0');
|
||||
expect(text).toContain('Running helm upgrade');
|
||||
expect(text).toContain('Waiting for rollout');
|
||||
expect(text).toContain('任务状态 · 已完成');
|
||||
@@ -437,6 +448,30 @@ describe('UpdateCenterSection', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('parses timezone-less SQL runtime timestamps as UTC before formatting', async () => {
|
||||
const parseSpy = vi.spyOn(Date, 'parse');
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const parseInputs = parseSpy.mock.calls.map(([value]) => String(value));
|
||||
expect(parseInputs).toContain('2026-03-30T20:30:00Z');
|
||||
expect(parseInputs).not.toContain('2026-03-30 20:30:00');
|
||||
} finally {
|
||||
parseSpy.mockRestore();
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders rollback history and triggers rollback tasks for previous revisions', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
|
||||
@@ -65,6 +65,15 @@ type UpdateCenterStatus = {
|
||||
status?: string;
|
||||
finishedAt?: string | null;
|
||||
} | null;
|
||||
runtime?: {
|
||||
lastCheckedAt?: string | null;
|
||||
lastCheckError?: string | null;
|
||||
lastResolvedSource?: 'github-release' | 'docker-hub-tag' | null;
|
||||
lastResolvedDisplayVersion?: string | null;
|
||||
lastResolvedCandidateKey?: string | null;
|
||||
lastNotifiedCandidateKey?: string | null;
|
||||
lastNotifiedAt?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: NonNullable<UpdateCenterStatus['config']> = {
|
||||
@@ -137,7 +146,10 @@ const fieldHintStyle: CSSProperties = {
|
||||
|
||||
function formatTaskTime(value?: string | null) {
|
||||
if (!value) return '暂无完成记录';
|
||||
const timestamp = Date.parse(value);
|
||||
const normalizedValue = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(value)
|
||||
? `${value.replace(' ', 'T')}Z`
|
||||
: value;
|
||||
const timestamp = Date.parse(normalizedValue);
|
||||
if (!Number.isFinite(timestamp)) return value;
|
||||
return new Date(timestamp).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
@@ -402,6 +414,7 @@ export default function UpdateCenterSection() {
|
||||
const helperHistory = Array.isArray(status?.helper?.history) ? status.helper.history : [];
|
||||
const historyPreview = helperHistory.slice(0, 2);
|
||||
const currentRevision = String(status?.helper?.revision || '').trim();
|
||||
const runtimeStatus = status?.runtime || null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -475,6 +488,19 @@ export default function UpdateCenterSection() {
|
||||
: formatTaskTime(status?.lastFinishedTask?.finishedAt)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={summaryLabelStyle}>后台检查</div>
|
||||
<div style={summaryValueStyle}>
|
||||
{runtimeStatus?.lastCheckedAt ? formatTaskTime(runtimeStatus.lastCheckedAt) : '尚无检查记录'}
|
||||
</div>
|
||||
<div style={fieldHintStyle}>
|
||||
{runtimeStatus?.lastCheckError
|
||||
? `最近错误:${runtimeStatus.lastCheckError}`
|
||||
: runtimeStatus?.lastResolvedDisplayVersion
|
||||
? `最近发现:${runtimeStatus.lastResolvedDisplayVersion}`
|
||||
: '后台会定时检查新版本,并在首次发现时提醒一次。'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -494,7 +520,7 @@ export default function UpdateCenterSection() {
|
||||
/>
|
||||
<span style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>启用更新中心</span>
|
||||
<span style={fieldHintStyle}>允许系统检查版本,并在配置齐全时触发 K3s 部署。</span>
|
||||
<span style={fieldHintStyle}>允许通过本页触发 K3s 部署。后台版本提醒会按已启用来源持续检查。</span>
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ ...sectionPanelStyle, display: 'flex', gap: 10, alignItems: 'flex-start', cursor: 'pointer' }}>
|
||||
|
||||
Reference in New Issue
Block a user