Keep per-site probe settings reliable before merge

Refresh the PR onto current main and close the remaining probe-setting review gaps: the primary site save now persists the latency threshold, the site row type carries that field without an any-cast, active probe streams are aborted on unmount, and all-model one-shot probes get the longer timeout they need.

Constraint: PR #510 must preserve the per-site model probe feature while merging cleanly into current main.
Rejected: Leave threshold persistence behind the separate probe-settings button | the primary save path would still silently drop user changes.
Rejected: Keep the SiteRow any-cast | it hides API/schema drift for the new persisted field.
Confidence: high
Scope-risk: narrow
Directive: Keep probe setting fields represented in the typed site save payload and covered by focused web API/editor tests.
Tested: npm run typecheck; npm run repo:drift-check; npx vitest run --root . src/server/db/schemaContract.test.ts src/server/db/schemaArtifactGenerator.test.ts src/server/db/schemaIntrospection.test.ts src/server/db/schemaParity.test.ts src/web/api.test.ts src/web/pages/helpers/sitesEditor.test.ts src/web/pages/sites.disabled-models-save.test.tsx src/web/pages/sites.edit-scroll.test.tsx
Not-tested: Full npm test.
This commit is contained in:
cita-777
2026-05-08 16:51:05 +08:00
38 changed files with 2466 additions and 124 deletions
+8 -1
View File
@@ -67,6 +67,7 @@ type SiteRow = {
postRefreshProbeEnabled?: boolean;
postRefreshProbeModel?: string | null;
postRefreshProbeScope?: string | null;
postRefreshProbeLatencyThresholdMs?: number | null;
apiEndpoints?: Array<{
id?: number;
url: string;
@@ -334,6 +335,11 @@ export default function Sites() {
}
}, [editor]);
useEffect(() => () => {
probeAbortRef.current?.abort();
probeAbortRef.current = null;
}, []);
const disabledModelSet = useMemo(() => new Set(disabledModels), [disabledModels]);
const brandGroups = useMemo(() => {
@@ -492,7 +498,7 @@ export default function Sites() {
setProbeEnabled(!!site.postRefreshProbeEnabled);
setProbeModel(typeof site.postRefreshProbeModel === 'string' ? site.postRefreshProbeModel : '');
setProbeScope(site.postRefreshProbeScope === 'all' ? 'all' : 'single');
setProbeLatencyThreshold(String((site as any).postRefreshProbeLatencyThresholdMs ?? 0));
setProbeLatencyThreshold(String(site.postRefreshProbeLatencyThresholdMs ?? 0));
setProbeLog([]);
setProbeCompleted(false);
probeAbortRef.current?.abort();
@@ -743,6 +749,7 @@ export default function Sites() {
postRefreshProbeEnabled: probeEnabled,
postRefreshProbeModel: probeModel.trim(),
postRefreshProbeScope: probeScope,
postRefreshProbeLatencyThresholdMs: Math.max(0, parseInt(probeLatencyThreshold, 10) || 0),
};
if (!payload.name || !payload.url) {
toast.error('请填写站点名称和 URL');
@@ -26,6 +26,10 @@ describe('buildSiteSaveAction', () => {
customHeaders: '{"x-site-token":"alpha"}',
useSystemProxy: false,
globalWeight: 1.2,
postRefreshProbeEnabled: true,
postRefreshProbeModel: 'gpt-4o',
postRefreshProbeScope: 'single',
postRefreshProbeLatencyThresholdMs: 2500,
},
);
@@ -44,6 +48,10 @@ describe('buildSiteSaveAction', () => {
customHeaders: '{"x-site-token":"alpha"}',
useSystemProxy: false,
globalWeight: 1.2,
postRefreshProbeEnabled: true,
postRefreshProbeModel: 'gpt-4o',
postRefreshProbeScope: 'single',
postRefreshProbeLatencyThresholdMs: 2500,
},
});
});
+4
View File
@@ -42,6 +42,10 @@ export type SiteSavePayload = {
}>;
customHeaders: string;
globalWeight: number;
postRefreshProbeEnabled?: boolean;
postRefreshProbeModel?: string;
postRefreshProbeScope?: 'single' | 'all';
postRefreshProbeLatencyThresholdMs?: number;
};
type SiteSaveAction =
@@ -73,6 +73,28 @@ 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',
},
{
normalizedVersion: 'feature-without-raw-tag',
digest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
displayVersion: 'feature-without-raw-tag @ sha256:cccccccccccc',
publishedAt: '2026-03-30T09:54:35.591877Z',
},
],
helper: {
ok: true,
healthy: true,
@@ -159,6 +181,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 +462,153 @@ 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();
expect(collectText(root.root)).toContain('Digest:sha256:bbbbbbbbbbbb');
expect(collectText(root.root)).not.toContain('feature-without-raw-tag');
expect(collectText(root.root)).toContain('dev-20260417-f67ade2 @ sha256:bbbbbbbbbbbb');
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('disables only the recent Docker candidate that already matches the running helper image', async () => {
apiMock.getUpdateCenterStatus.mockResolvedValueOnce({
currentVersion: '1.2.3',
config: {
enabled: true,
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
namespace: 'ai',
releaseName: 'metapi',
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
imageRepository: '1467078763/metapi',
githubReleasesEnabled: true,
dockerHubTagsEnabled: true,
defaultDeploySource: 'docker-hub-tag',
},
githubRelease: {
normalizedVersion: '1.3.0',
displayVersion: '1.3.0',
},
dockerHubTag: {
normalizedVersion: 'latest',
tagName: 'latest',
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
displayVersion: 'latest @ sha256:efb2ee655386',
},
dockerHubRecentTags: [
{
normalizedVersion: 'dev',
tagName: 'dev',
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
publishedAt: '2026-03-30T11:54:35.591877Z',
},
{
normalizedVersion: 'dev-20260417-next',
tagName: 'dev-20260417-next',
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
displayVersion: 'dev-20260417-next @ sha256:bbbbbbbbbbbb',
publishedAt: '2026-03-30T10:54:35.591877Z',
},
],
helper: {
ok: true,
healthy: true,
revision: '17',
imageTag: 'dev',
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
history: [],
},
runtime: {
lastCheckedAt: '2026-03-30 20:30:00',
lastCheckError: null,
lastResolvedSource: 'docker-hub-tag',
lastResolvedDisplayVersion: 'dev',
lastResolvedCandidateKey: 'docker-hub-tag:dev@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
lastNotifiedCandidateKey: null,
lastNotifiedAt: null,
},
runningTask: null,
lastFinishedTask: null,
});
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<UpdateCenterSection />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const runningCandidateButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node) === '部署 dev'
));
const nextCandidateButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node) === '部署 dev-20260417-next'
));
expect(runningCandidateButton.props.disabled).toBe(true);
expect(runningCandidateButton.props.title).toContain('当前已运行该镜像');
expect(nextCandidateButton.props.disabled).toBe(false);
await act(async () => {
await nextCandidateButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
source: 'docker-hub-tag',
targetTag: 'dev-20260417-next',
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 {
+107 -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,17 @@ function formatImageTarget(tag?: string | null, digest?: string | null) {
return '';
}
type RecentDockerCandidate = NonNullable<NonNullable<UpdateCenterStatus['dockerHubRecentTags']>[number]>;
function normalizeRecentDockerCandidates(
input?: UpdateCenterStatus['dockerHubRecentTags'] | null,
): Array<RecentDockerCandidate & { tagName: string }> {
if (!Array.isArray(input)) return [];
return input.filter(
(entry): entry is RecentDockerCandidate & { tagName: string } => !!String(entry?.tagName || '').trim(),
);
}
export default function UpdateCenterSection() {
const toast = useToast();
const isMobile = useIsMobile();
@@ -432,6 +450,7 @@ 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 canDeployManualDocker = !deploying
&& config.enabled
&& config.dockerHubTagsEnabled
@@ -570,7 +589,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 +750,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 +792,97 @@ 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 || '').trim();
const candidateDigest = String(candidate.digest || '').trim();
const candidateLabel = candidate.displayVersion || candidate.normalizedVersion || candidateTag;
const candidateDeployState = describeDockerDeployState({
enabled: config.enabled && config.dockerHubTagsEnabled,
helperHealthy,
helperError: status?.helper?.error,
currentVersion: status?.currentVersion,
helper: status?.helper,
candidate,
});
const canDeployCandidate = !deploying && candidateDeployState.canDeploy;
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>
{candidateDigest ? (
<div style={{ ...fieldHintStyle, fontFamily: 'var(--font-mono)' }} title={candidateDigest}>
Digest:{formatShortDigest(candidateDigest)}
</div>
) : null}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
disabled={!canDeployCandidate || !candidateTag}
title={candidateDeployState.reason}
onClick={() => {
if (!canDeployCandidate || !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={{