fix: restore frontend source code (src/web/) that was accidentally deleted

Frontend source is needed for development/building the SPA.
Only Node.js runtime server code should be removed.
This commit is contained in:
boos
2026-05-18 09:33:56 +08:00
parent 5d29f03d50
commit 0eb7c32585
274 changed files with 80446 additions and 0 deletions
@@ -0,0 +1,140 @@
import React from 'react';
import { createPortal } from 'react-dom';
type ModalPresence = {
shouldRender: boolean;
isVisible: boolean;
};
type DownstreamCreateForm = {
name: string;
key: string;
description: string;
maxCost: string;
maxRequests: string;
expiresAt: string;
selectedModels: string[];
selectedGroupRouteIds: number[];
};
type DownstreamApiKeyModalProps = {
presence: ModalPresence;
editingDownstreamId: number | null;
downstreamCreate: DownstreamCreateForm;
downstreamSaving: boolean;
inputStyle: React.CSSProperties;
onChange: (updater: (prev: DownstreamCreateForm) => DownstreamCreateForm) => void;
onOpenSelector: () => Promise<void> | void;
onClose: () => void;
onSave: () => Promise<void> | void;
};
export default function DownstreamApiKeyModal({
presence,
editingDownstreamId,
downstreamCreate,
downstreamSaving,
inputStyle,
onChange,
onOpenSelector,
onClose,
onSave,
}: DownstreamApiKeyModalProps) {
if (!presence.shouldRender) {
return null;
}
const modal = (
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
<div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth: 860 }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
{editingDownstreamId ? '编辑下游 API Key' : '新增下游 API Key'}
</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 10 }}>
<input
value={downstreamCreate.name}
onChange={(e) => onChange((prev) => ({ ...prev, name: e.target.value }))}
placeholder="Name (e.g. cc-project)"
style={inputStyle}
/>
<input
value={downstreamCreate.key}
onChange={(e) => onChange((prev) => ({ ...prev, key: e.target.value.trim() }))}
placeholder="sk-xxxx"
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
/>
<input
value={downstreamCreate.maxCost}
onChange={(e) => onChange((prev) => ({ ...prev, maxCost: e.target.value }))}
placeholder="最大费用(可选)"
type="number"
min={0}
step={0.000001}
style={inputStyle}
/>
<input
value={downstreamCreate.maxRequests}
onChange={(e) => onChange((prev) => ({ ...prev, maxRequests: e.target.value }))}
placeholder="最大请求数(可选)"
type="number"
min={0}
step={1}
style={inputStyle}
/>
<input
value={downstreamCreate.expiresAt}
onChange={(e) => onChange((prev) => ({ ...prev, expiresAt: e.target.value }))}
type="datetime-local"
placeholder="过期时间(可选)"
style={inputStyle}
/>
<input
value={downstreamCreate.description}
onChange={(e) => onChange((prev) => ({ ...prev, description: e.target.value }))}
placeholder="备注(可选)"
style={inputStyle}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
已选模型 {downstreamCreate.selectedModels.length} 个,已选群组 {downstreamCreate.selectedGroupRouteIds.length} 个
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<button
onClick={() => void onOpenSelector()}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
勾选模型和群组
</button>
{(downstreamCreate.selectedModels.length > 0 || downstreamCreate.selectedGroupRouteIds.length > 0) ? (
<button
onClick={() => onChange((prev) => ({ ...prev, selectedModels: [], selectedGroupRouteIds: [] }))}
className="btn btn-link btn-link-warning"
>
清空选择
</button>
) : null}
</div>
</div>
</div>
<div className="modal-footer">
<button onClick={onClose} className="btn btn-ghost">取消</button>
<button onClick={() => void onSave()} disabled={downstreamSaving} className="btn btn-primary">
{downstreamSaving
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 保存中...</>
: (editingDownstreamId ? '更新 API Key' : '新增 API Key')}
</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
@@ -0,0 +1,68 @@
import React from 'react';
import { createPortal } from 'react-dom';
type ModalPresence = {
shouldRender: boolean;
isVisible: boolean;
};
type FactoryResetModalProps = {
presence: ModalPresence;
factoryResetting: boolean;
factoryResetSecondsLeft: number;
adminToken: string;
onClose: () => void;
onConfirm: () => void;
};
export default function FactoryResetModal({
presence,
factoryResetting,
factoryResetSecondsLeft,
adminToken,
onClose,
onConfirm,
}: FactoryResetModalProps) {
if (!presence.shouldRender) {
return null;
}
const confirmLabel = factoryResetting
? '重新初始化中...'
: (factoryResetSecondsLeft > 0
? `确认重新初始化系统(${factoryResetSecondsLeft}s)`
: '确认重新初始化系统');
const modal = (
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
<div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth: 720, border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header" style={{ color: 'var(--color-danger)' }}>确认重新初始化系统</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
这是不可逆操作。系统会清空当前 BoosAPI 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 BoosAPI 数据。</div>
<div>• 系统随后会强制切回默认 SQLite。</div>
<div>• 管理员 Token 将重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{adminToken}</code>。</div>
<div>• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。</div>
</div>
</div>
<div className="modal-footer">
<button onClick={onClose} disabled={factoryResetting} className="btn btn-ghost">取消</button>
<button onClick={onConfirm} disabled={factoryResetting || factoryResetSecondsLeft > 0} className="btn btn-danger">
{factoryResetting
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> {confirmLabel}</>
: confirmLabel}
</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
@@ -0,0 +1,105 @@
import React from 'react';
import { createPortal } from 'react-dom';
type ModalPresence = {
shouldRender: boolean;
isVisible: boolean;
};
type ModelAvailabilityProbeConfirmModalProps = {
presence: ModalPresence;
confirmText: string;
confirmationInput: string;
saving: boolean;
onConfirmationInputChange: (value: string) => void;
onClose: () => void;
onConfirm: () => void;
};
export default function ModelAvailabilityProbeConfirmModal({
presence,
confirmText,
confirmationInput,
saving,
onConfirmationInputChange,
onClose,
onConfirm,
}: ModelAvailabilityProbeConfirmModalProps) {
if (!presence.shouldRender) {
return null;
}
const canConfirm = confirmationInput.trim() === confirmText;
const titleId = 'model-availability-probe-confirm-modal-title';
const descriptionId = 'model-availability-probe-confirm-modal-description';
const handleRequestClose = () => {
if (saving) return;
onClose();
};
const modal = (
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={handleRequestClose}>
<div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth: 760, border: '1px solid color-mix(in srgb, var(--color-danger) 35%, var(--color-border))' }}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descriptionId}
aria-busy={saving}
onClick={(e) => e.stopPropagation()}
>
<div id={titleId} className="modal-header" style={{ color: 'var(--color-danger)' }}>确认开启批量测活</div>
<div id={descriptionId} className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
开启后,BoosAPI 会在后台对活跃账号模型做最小化探测请求。这可能被部分中转站视为批量测活或异常行为,请务必先确认你的中转站明确允许此类探测。
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
请手动输入以下整句后再开启:
</div>
<div
style={{
padding: 12,
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border-light)',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 13,
lineHeight: 1.8,
}}
>
{confirmText}
</div>
<textarea
value={confirmationInput}
onChange={(e) => onConfirmationInputChange(e.target.value)}
placeholder="请输入上方确认语句"
spellCheck={false}
style={{
width: '100%',
minHeight: 96,
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
resize: 'vertical',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
}}
/>
</div>
<div className="modal-footer">
<button onClick={handleRequestClose} disabled={saving} className="btn btn-ghost">取消</button>
<button onClick={onConfirm} disabled={saving || !canConfirm} className="btn btn-danger">
{saving
? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 确认开启中...</>
: '确认开启批量测活'}
</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
@@ -0,0 +1,307 @@
import React from 'react';
import { createPortal } from 'react-dom';
import { BrandGlyph, InlineBrandIcon, getBrand } from '../../components/BrandIcon.js';
import {
resolveRouteBrand,
resolveRouteIcon,
resolveRouteTitle,
} from '../token-routes/utils.js';
type RouteSelectorItem = {
id: number;
modelPattern: string;
displayName?: string | null;
displayIcon?: string | null;
enabled: boolean;
};
type ModalPresence = {
shouldRender: boolean;
isVisible: boolean;
};
type DownstreamRouteSelection = {
selectedModels: string[];
selectedGroupRouteIds: number[];
};
type RouteSelectorModalProps = {
presence: ModalPresence;
loading: boolean;
exactModelOptions: string[];
filteredExactModelOptions: string[];
groupRouteOptions: RouteSelectorItem[];
filteredGroupRouteOptions: RouteSelectorItem[];
selectorModelSearch: string;
selectorGroupSearch: string;
onSelectorModelSearchChange: (value: string) => void;
onSelectorGroupSearchChange: (value: string) => void;
selection: DownstreamRouteSelection;
onToggleModelSelection: (modelName: string) => void;
onToggleGroupRouteSelection: (routeId: number) => void;
onClose: () => void;
inputStyle: React.CSSProperties;
};
export default function RouteSelectorModal({
presence,
loading,
exactModelOptions,
filteredExactModelOptions,
groupRouteOptions,
filteredGroupRouteOptions,
selectorModelSearch,
selectorGroupSearch,
onSelectorModelSearchChange,
onSelectorGroupSearchChange,
selection,
onToggleModelSelection,
onToggleGroupRouteSelection,
onClose,
inputStyle,
}: RouteSelectorModalProps) {
if (!presence.shouldRender) {
return null;
}
const modal = (
<div className={`modal-backdrop ${presence.isVisible ? '' : 'is-closing'}`.trim()} onClick={onClose}>
<div
className={`modal-content ${presence.isVisible ? '' : 'is-closing'}`.trim()}
style={{ maxWidth: 860 }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">勾选模型和群组</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
选择结果会保存到当前下游 API Key:精确模型用于模型白名单,群组用于路由范围限制。
</div>
{loading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, color: 'var(--color-text-muted)' }}>
<span className="spinner spinner-sm" />
加载路由中...
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: 12 }}>
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
精确模型 ({selectorModelSearch.trim()
? `${filteredExactModelOptions.length}/${exactModelOptions.length}`
: exactModelOptions.length})
</div>
<input
value={selectorModelSearch}
onChange={(e) => onSelectorModelSearchChange(e.target.value)}
placeholder="搜索精确模型(支持模糊匹配)"
style={{ ...inputStyle, padding: '8px 10px', fontSize: 12, marginBottom: 8 }}
/>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{exactModelOptions.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可选精确模型</div>
) : filteredExactModelOptions.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的精确模型</div>
) : filteredExactModelOptions.map((modelName) => {
const checked = selection.selectedModels.includes(modelName);
const brand = getBrand(modelName);
return (
<label
key={modelName}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
cursor: 'pointer',
border: `1px solid ${checked ? 'color-mix(in srgb, var(--color-primary) 45%, transparent)' : 'var(--color-border-light)'}`,
borderRadius: 10,
padding: '8px 10px',
background: checked
? 'color-mix(in srgb, var(--color-primary) 9%, var(--color-bg-card))'
: 'var(--color-bg-card)',
transition: 'all 0.15s ease',
}}
>
<input
type="checkbox"
checked={checked}
onChange={() => onToggleModelSelection(modelName)}
style={{ width: 18, height: 18, accentColor: 'var(--color-primary)', flexShrink: 0 }}
/>
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
<code
style={{
fontWeight: 600,
fontSize: 12,
background: 'var(--color-bg)',
padding: '4px 10px',
borderRadius: 8,
color: 'var(--color-text-primary)',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
maxWidth: '100%',
}}
>
{brand ? (
<InlineBrandIcon model={modelName} size={18} />
) : (
<span
style={{
width: 18,
height: 18,
borderRadius: 6,
background: 'var(--color-bg-card)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 10,
color: 'var(--color-text-muted)',
flexShrink: 0,
}}
>
{modelName.slice(0, 1).toUpperCase()}
</span>
)}
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{modelName}
</span>
</code>
{brand ? (
<div style={{ fontSize: 11, color: 'var(--color-text-muted)', paddingLeft: 6 }}>
{brand.name}
</div>
) : null}
</div>
</label>
);
})}
</div>
</div>
<div style={{ border: '1px solid var(--color-border-light)', borderRadius: 'var(--radius-sm)', padding: 10 }}>
<div style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>
群组 ({selectorGroupSearch.trim()
? `${filteredGroupRouteOptions.length}/${groupRouteOptions.length}`
: groupRouteOptions.length})
</div>
<input
value={selectorGroupSearch}
onChange={(e) => onSelectorGroupSearchChange(e.target.value)}
placeholder="Search groups (name / pattern)"
style={{ ...inputStyle, padding: '8px 10px', fontSize: 12, marginBottom: 8 }}
/>
<div style={{ maxHeight: 280, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 8 }}>
{groupRouteOptions.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>暂无可选群组</div>
) : filteredGroupRouteOptions.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>没有匹配的群组</div>
) : filteredGroupRouteOptions.map((route) => {
const checked = selection.selectedGroupRouteIds.includes(route.id);
const routeTitle = resolveRouteTitle(route);
const routeIcon = resolveRouteIcon(route);
const routeBrand = resolveRouteBrand(route);
return (
<label
key={route.id}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 10,
cursor: 'pointer',
border: `1px solid ${checked ? 'color-mix(in srgb, var(--color-primary) 45%, transparent)' : 'var(--color-border-light)'}`,
borderRadius: 10,
padding: '8px 10px',
background: checked
? 'color-mix(in srgb, var(--color-primary) 9%, var(--color-bg-card))'
: 'var(--color-bg-card)',
transition: 'all 0.15s ease',
}}
>
<input
type="checkbox"
style={{ marginTop: 4, width: 18, height: 18, accentColor: 'var(--color-primary)', flexShrink: 0 }}
checked={checked}
onChange={() => onToggleGroupRouteSelection(route.id)}
/>
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 4 }}>
<code
style={{
fontWeight: 600,
fontSize: 12,
background: 'var(--color-bg)',
padding: '4px 10px',
borderRadius: 8,
color: 'var(--color-text-primary)',
display: 'inline-flex',
alignItems: 'center',
gap: 6,
maxWidth: '100%',
}}
>
<span
style={{
width: 18,
height: 18,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 6,
background: 'var(--color-bg-card)',
flexShrink: 0,
overflow: 'hidden',
fontSize: 12,
lineHeight: 1,
}}
>
{routeIcon.kind === 'brand' ? (
<BrandGlyph
icon={routeIcon.value}
alt={routeTitle}
size={18}
fallbackText={routeTitle}
/>
) : routeIcon.kind === 'text' ? (
routeIcon.value
) : routeIcon.kind === 'auto' && routeBrand ? (
<BrandGlyph brand={routeBrand} alt={routeTitle} size={18} fallbackText={routeTitle} />
) : routeIcon.kind === 'auto' ? (
<InlineBrandIcon model={route.modelPattern} size={18} />
) : routeIcon.kind === 'none' ? null : null}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>
{routeTitle}
</span>
{!route.enabled ? (
<span
style={{
fontSize: 10,
padding: '1px 6px',
borderRadius: 999,
background: 'var(--color-danger-bg)',
color: 'var(--color-danger)',
}}
>
已禁用
</span>
) : null}
</code>
<code style={{ fontSize: 11, color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', paddingLeft: 6 }}>
{route.modelPattern}
</code>
</div>
</label>
);
})}
</div>
</div>
</div>
)}
</div>
<div className="modal-footer">
<button onClick={onClose} className="btn btn-ghost">关闭</button>
</div>
</div>
</div>
);
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
}
@@ -0,0 +1,83 @@
export type UpdateCenterHistoryEntry = {
revision?: string;
updatedAt?: string | null;
status?: string | null;
description?: string | null;
imageTag?: string | null;
imageDigest?: string | null;
};
type UpdateCenterHistoryEntryCardProps = {
entry: UpdateCenterHistoryEntry;
currentRevision: string;
helperHealthy: boolean;
deploying: boolean;
compact?: boolean;
formatTaskTime: (value?: string | null) => string;
formatImageTarget: (tag?: string | null, digest?: string | null) => string;
onRollback: (revision: string) => void;
};
export default function UpdateCenterHistoryEntryCard({
entry,
currentRevision,
helperHealthy,
deploying,
compact = false,
formatTaskTime,
formatImageTarget,
onRollback,
}: UpdateCenterHistoryEntryCardProps) {
const revision = String(entry?.revision || '').trim();
const isCurrentRevision = revision && revision === currentRevision;
return (
<div
style={{
border: '1px solid var(--color-border-light)',
borderRadius: 'var(--radius-sm)',
padding: compact ? 10 : 12,
display: 'grid',
gap: compact ? 5 : 6,
background: isCurrentRevision
? 'color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card))'
: 'var(--color-bg-card)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
revision {revision || '-'}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{entry?.status ? <span className="badge badge-muted">{entry.status}</span> : null}
{isCurrentRevision ? <span className="badge badge-info">当前运行</span> : null}
</div>
</div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', fontFamily: 'var(--font-mono)' }}>
{formatImageTarget(entry?.imageTag, entry?.imageDigest) || '未记录镜像信息'}
</div>
{entry?.description ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
{entry.description}
</div>
) : null}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
更新时间:{formatTaskTime(entry?.updatedAt)}
</div>
<div>
<button
type="button"
onClick={() => {
if (isCurrentRevision) return;
onRollback(revision);
}}
disabled={!helperHealthy || deploying || isCurrentRevision || !revision}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
回退到 revision {revision}
</button>
</div>
</div>
);
}
@@ -0,0 +1,65 @@
import CenteredModal from '../../components/CenteredModal.js';
import UpdateCenterHistoryEntryCard, { type UpdateCenterHistoryEntry } from './UpdateCenterHistoryEntryCard.js';
type UpdateCenterHistoryModalProps = {
open: boolean;
helperHealthy: boolean;
deploying: boolean;
currentRevision: string;
history: UpdateCenterHistoryEntry[];
formatTaskTime: (value?: string | null) => string;
formatImageTarget: (tag?: string | null, digest?: string | null) => string;
onClose: () => void;
onRollback: (revision: string) => void;
};
export default function UpdateCenterHistoryModal({
open,
helperHealthy,
deploying,
currentRevision,
history,
formatTaskTime,
formatImageTarget,
onClose,
onRollback,
}: UpdateCenterHistoryModalProps) {
return (
<CenteredModal
open={open}
onClose={onClose}
title="全部 revision"
maxWidth={880}
closeOnBackdrop
closeOnEscape
footer={(
<button type="button" className="btn btn-ghost" onClick={onClose}>
关闭
</button>
)}
>
<div style={{ display: 'grid', gap: 10 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
这里保留 helper 读到的全部 Helm revision。默认列表只显示最近几条,弹窗里再展开全部回退记录,避免设置页被历史卡片拉得过长。
</div>
<div style={{ display: 'grid', gap: 10, maxHeight: 520, overflowY: 'auto', paddingRight: 4 }}>
{history.map((entry) => {
const revision = String(entry?.revision || '').trim();
return (
<UpdateCenterHistoryEntryCard
key={revision || 'unknown-revision-modal'}
entry={entry}
currentRevision={currentRevision}
helperHealthy={helperHealthy}
deploying={deploying}
formatTaskTime={formatTaskTime}
formatImageTarget={formatImageTarget}
onRollback={onRollback}
/>
);
})}
</div>
</div>
</CenteredModal>
);
}
@@ -0,0 +1,778 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../../components/Toast.js';
import UpdateCenterSection from './UpdateCenterSection.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getUpdateCenterStatus: vi.fn(),
saveUpdateCenterConfig: vi.fn(),
checkUpdateCenter: vi.fn(),
deployUpdateCenter: vi.fn(),
rollbackUpdateCenter: vi.fn(),
streamUpdateCenterTaskLogs: vi.fn(),
},
}));
vi.mock('../../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('UpdateCenterSection', () => {
const originalDocument = globalThis.document;
beforeEach(() => {
vi.clearAllMocks();
globalThis.document = {
body: {
nodeType: 1,
style: {
overflow: '',
},
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as Document;
apiMock.getUpdateCenterStatus.mockResolvedValue({
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: 'github-release',
},
githubRelease: {
normalizedVersion: '1.3.0',
displayVersion: '1.3.0',
},
dockerHubTag: {
normalizedVersion: 'latest',
tagName: 'latest',
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
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,
revision: '17',
imageTag: 'latest',
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
history: [
{
revision: '17',
updatedAt: '2026-03-29T12:00:00Z',
status: 'deployed',
description: 'Upgrade complete',
imageRepository: '1467078763/metapi',
imageTag: 'latest',
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
},
{
revision: '16',
updatedAt: '2026-03-28T12:00:00Z',
status: 'superseded',
description: 'Rollback to stable digest',
imageRepository: '1467078763/metapi',
imageTag: 'main',
imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
{
revision: '15',
updatedAt: '2026-03-27T12:00:00Z',
status: 'superseded',
description: 'Earlier stable release',
imageRepository: '1467078763/metapi',
imageTag: '1.2.2',
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
},
],
},
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,
});
apiMock.saveUpdateCenterConfig.mockResolvedValue({
success: true,
config: {
enabled: true,
helperBaseUrl: 'http://updated-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: 'github-release',
},
});
apiMock.checkUpdateCenter.mockResolvedValue({
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: 'github-release',
},
githubRelease: {
normalizedVersion: '1.3.0',
displayVersion: '1.3.0',
},
dockerHubTag: {
normalizedVersion: 'latest',
tagName: 'latest',
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
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,
revision: '18',
imageTag: 'latest',
imageDigest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
history: [],
},
runtime: {
lastCheckedAt: '2026-03-30 20:35: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,
});
apiMock.deployUpdateCenter.mockResolvedValue({
success: true,
reused: false,
task: {
id: 'task-1',
},
});
apiMock.rollbackUpdateCenter.mockResolvedValue({
success: true,
reused: false,
task: {
id: 'task-2',
},
});
apiMock.streamUpdateCenterTaskLogs.mockImplementation(async (_taskId: string, handlers: { onLog?: (entry: { message: string }) => void; onDone?: (payload: { status: string }) => void }) => {
handlers.onLog?.({ message: 'Running helm upgrade' });
handlers.onLog?.({ message: 'Waiting for rollout' });
handlers.onDone?.({ status: 'succeeded' });
});
});
afterEach(() => {
vi.clearAllMocks();
globalThis.document = originalDocument;
});
it('loads status, saves config updates, and renders streamed deploy logs', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<UpdateCenterSection />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const helperInput = root.root.find((node) => (
node.type === 'input'
&& node.props.value === 'http://metapi-deploy-helper.ai.svc.cluster.local:9850'
));
await act(async () => {
helperInput.props.onChange({ target: { value: 'http://updated-helper.ai.svc.cluster.local:9850' } });
});
const checkboxInputs = root.root.findAll((node) => node.type === 'input' && node.props.type === 'checkbox');
await act(async () => {
checkboxInputs[1].props.onChange({ target: { checked: false } });
checkboxInputs[2].props.onChange({ target: { checked: false } });
});
const defaultSourceTrigger = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-trigger')
));
await act(async () => {
defaultSourceTrigger.props.onClick();
});
const dockerHubOption = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('modern-select-option')
&& collectText(node).includes('Docker Hub Tags')
));
await act(async () => {
dockerHubOption.props.onClick();
});
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('保存更新中心配置')
));
await act(async () => {
await saveButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.saveUpdateCenterConfig).toHaveBeenCalledWith(expect.objectContaining({
helperBaseUrl: 'http://updated-helper.ai.svc.cluster.local:9850',
githubReleasesEnabled: false,
dockerHubTagsEnabled: false,
defaultDeploySource: 'docker-hub-tag',
}));
const deployButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('部署 GitHub 稳定版')
));
await act(async () => {
await deployButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
source: 'github-release',
targetTag: '1.3.0',
targetDigest: null,
});
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
expect(apiMock.checkUpdateCenter).toHaveBeenCalledTimes(1);
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('任务状态 · 已完成');
} finally {
root?.unmount();
}
});
it('disables deploy actions when the helper is unhealthy', 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: 'github-release',
},
githubRelease: {
normalizedVersion: '1.3.0',
},
dockerHubTag: {
normalizedVersion: 'latest',
displayVersion: 'latest @ sha256:efb2ee655386',
},
helper: {
ok: false,
healthy: false,
error: 'helper unavailable',
},
runningTask: null,
lastFinishedTask: null,
});
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<UpdateCenterSection />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const githubDeployButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn')
&& collectText(node).includes('部署 GitHub 稳定版')
));
const dockerDeployButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn')
&& collectText(node).includes('部署 Docker Hub 标签')
));
expect(githubDeployButton.props.disabled).toBe(true);
expect(dockerDeployButton.props.disabled).toBe(true);
await act(async () => {
githubDeployButton.props.onClick?.();
dockerDeployButton.props.onClick?.();
});
expect(apiMock.deployUpdateCenter).not.toHaveBeenCalled();
} finally {
root?.unmount();
}
});
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('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 {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<UpdateCenterSection />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const compactText = collectText(root.root);
expect(compactText).toContain('展开全部 3 条');
expect(compactText).not.toContain('Earlier stable release');
expect(compactText).not.toContain('回退到 revision 15');
const openHistoryButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('展开全部 3 条')
));
await act(async () => {
await openHistoryButton.props.onClick();
});
await flushMicrotasks();
const modal = root.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('modal-content')
&& collectText(node).includes('全部 revision')
));
expect(collectText(modal)).toContain('Earlier stable release');
expect(collectText(modal)).toContain('回退到 revision 15');
} finally {
root?.unmount();
}
});
it('blocks Docker Hub deploys when the current helper image already matches the target digest', 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',
},
helper: {
ok: true,
healthy: true,
revision: '17',
imageTag: 'latest',
imageDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
},
runningTask: null,
lastFinishedTask: null,
});
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<UpdateCenterSection />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const dockerDeployButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn')
&& collectText(node).includes('部署 Docker Hub 标签')
));
expect(dockerDeployButton.props.disabled).toBe(true);
expect(collectText(root.root)).toContain('当前已运行该镜像');
} finally {
root?.unmount();
}
});
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 {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<UpdateCenterSection />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rollbackButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('回退到 revision 16')
));
await act(async () => {
await rollbackButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.rollbackUpdateCenter).toHaveBeenCalledWith({
targetRevision: '16',
});
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-2', expect.any(Object));
expect(apiMock.checkUpdateCenter).toHaveBeenCalledTimes(1);
const text = collectText(root.root);
expect(text).toContain('最近状态:succeeded');
} finally {
root?.unmount();
}
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
export const PAYLOAD_RULE_PROTOCOL_OPTIONS: Array<{ value: string; label: string }> = [
{ value: '', label: '全部平台' },
{ value: 'codex', label: 'Codex' },
{ value: 'sub2api', label: 'Sub2API' },
{ value: 'new-api', label: 'New API' },
{ value: 'one-api', label: 'One API' },
{ value: 'cliproxyapi', label: 'CLIProxyAPI' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'claude', label: 'Claude' },
{ value: 'gemini', label: 'Gemini' },
{ value: 'gemini-cli', label: 'Gemini CLI' },
{ value: 'antigravity', label: 'Antigravity' },
{ value: 'anyrouter', label: 'AnyRouter' },
{ value: 'done-hub', label: 'Done Hub' },
{ value: 'one-hub', label: 'One Hub' },
{ value: 'veloera', label: 'Veloera' },
];
@@ -0,0 +1,273 @@
export type PayloadRuleAction = 'default' | 'default-raw' | 'override' | 'override-raw' | 'filter';
export type VisualPayloadRuleValueMode = 'text' | 'json';
export type VisualPayloadRule = {
id: string;
action: PayloadRuleAction;
modelPattern: string;
protocol: string;
path: string;
value: string;
valueMode: VisualPayloadRuleValueMode;
};
let payloadRuleIdCounter = 0;
function nextPayloadRuleId(): string {
payloadRuleIdCounter += 1;
return `payload-rule-${payloadRuleIdCounter}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function asTrimmedString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function formatJsonValue(value: unknown): string {
return JSON.stringify(value, null, 2);
}
function formatRawValue(value: unknown): string {
return typeof value === 'string' ? value : formatJsonValue(value);
}
export function createVisualPayloadRule(input: Partial<VisualPayloadRule> = {}): VisualPayloadRule {
return {
id: input.id || nextPayloadRuleId(),
action: input.action || 'default',
modelPattern: input.modelPattern || '',
protocol: input.protocol || '',
path: input.path || '',
value: input.value || '',
valueMode: input.valueMode || 'text',
};
}
export function isVisualPayloadRuleBlank(rule: VisualPayloadRule): boolean {
return !rule.modelPattern.trim()
&& !rule.protocol.trim()
&& !rule.path.trim()
&& !rule.value.trim();
}
export function createCodexDefaultHighReasoningVisualPreset(): VisualPayloadRule[] {
return [
createVisualPayloadRule({
action: 'default',
modelPattern: 'gpt-*',
protocol: 'codex',
path: 'reasoning.effort',
value: 'high',
valueMode: 'text',
}),
];
}
export function payloadRulesToVisualRules(value: unknown): VisualPayloadRule[] {
if (!isRecord(value)) return [];
const rules: VisualPayloadRule[] = [];
const appendValueRules = (action: Exclude<PayloadRuleAction, 'filter'>, input: unknown) => {
if (!Array.isArray(input)) return;
for (const entry of input) {
if (!isRecord(entry)) continue;
const models = Array.isArray(entry.models) ? entry.models : [];
const params = isRecord(entry.params) ? entry.params : null;
if (!params) continue;
for (const model of models) {
if (!isRecord(model)) continue;
const modelPattern = asTrimmedString(model.name);
if (!modelPattern) continue;
const protocol = asTrimmedString(model.protocol);
for (const [path, rawValue] of Object.entries(params)) {
const normalizedPath = asTrimmedString(path);
if (!normalizedPath) continue;
if (action === 'default-raw' || action === 'override-raw') {
rules.push(createVisualPayloadRule({
action,
modelPattern,
protocol,
path: normalizedPath,
value: formatRawValue(rawValue),
valueMode: 'json',
}));
continue;
}
rules.push(createVisualPayloadRule({
action,
modelPattern,
protocol,
path: normalizedPath,
value: typeof rawValue === 'string' ? rawValue : formatJsonValue(rawValue),
valueMode: typeof rawValue === 'string' ? 'text' : 'json',
}));
}
}
}
};
const appendFilterRules = (input: unknown) => {
if (!Array.isArray(input)) return;
for (const entry of input) {
if (!isRecord(entry)) continue;
const models = Array.isArray(entry.models) ? entry.models : [];
const params = Array.isArray(entry.params) ? entry.params : [];
for (const model of models) {
if (!isRecord(model)) continue;
const modelPattern = asTrimmedString(model.name);
if (!modelPattern) continue;
const protocol = asTrimmedString(model.protocol);
for (const path of params) {
const normalizedPath = asTrimmedString(path);
if (!normalizedPath) continue;
rules.push(createVisualPayloadRule({
action: 'filter',
modelPattern,
protocol,
path: normalizedPath,
value: '',
valueMode: 'text',
}));
}
}
}
};
appendValueRules('default', value.default);
appendValueRules('default-raw', value.defaultRaw ?? value['default-raw']);
appendValueRules('override', value.override);
appendValueRules('override-raw', value.overrideRaw ?? value['override-raw']);
appendFilterRules(value.filter);
return rules;
}
type PayloadRulesSerializeSuccess = {
success: true;
value: Record<string, unknown>;
};
type PayloadRulesSerializeFailure = {
success: false;
message: string;
};
export type VisualPayloadRulesSerializeResult = PayloadRulesSerializeSuccess | PayloadRulesSerializeFailure;
type AggregatedValueRule = {
models: Array<{ name: string; protocol?: string }>;
params: Record<string, unknown>;
};
type AggregatedFilterRule = {
models: Array<{ name: string; protocol?: string }>;
params: string[];
};
function buildModelRule(rule: VisualPayloadRule): { name: string; protocol?: string } {
const protocol = rule.protocol.trim();
return protocol ? { name: rule.modelPattern.trim(), protocol } : { name: rule.modelPattern.trim() };
}
function normalizeValueForRule(rule: VisualPayloadRule): { success: true; value: unknown } | PayloadRulesSerializeFailure {
if (rule.action === 'filter') {
return { success: true, value: undefined };
}
if (rule.action === 'default-raw' || rule.action === 'override-raw') {
const trimmed = rule.value.trim();
if (!trimmed) {
return { success: false, message: '原始 JSON 值不能为空' };
}
try {
JSON.parse(trimmed);
} catch (error: any) {
return { success: false, message: `原始 JSON 无效:${error?.message || '解析失败'}` };
}
return { success: true, value: trimmed };
}
if (rule.valueMode === 'json') {
const trimmed = rule.value.trim();
if (!trimmed) {
return { success: false, message: 'JSON 值不能为空' };
}
try {
return { success: true, value: JSON.parse(trimmed) };
} catch (error: any) {
return { success: false, message: `JSON 值无效:${error?.message || '解析失败'}` };
}
}
if (!rule.value) {
return { success: false, message: '文本值不能为空' };
}
return { success: true, value: rule.value };
}
export function visualRulesToPayloadRules(rules: VisualPayloadRule[]): VisualPayloadRulesSerializeResult {
const sections = {
default: new Map<string, AggregatedValueRule>(),
'default-raw': new Map<string, AggregatedValueRule>(),
override: new Map<string, AggregatedValueRule>(),
'override-raw': new Map<string, AggregatedValueRule>(),
filter: new Map<string, AggregatedFilterRule>(),
};
for (let index = 0; index < rules.length; index += 1) {
const rule = rules[index];
if (isVisualPayloadRuleBlank(rule)) continue;
const modelPattern = rule.modelPattern.trim();
if (!modelPattern) {
return { success: false, message: `第 ${index + 1} 条规则缺少模型匹配` };
}
const path = rule.path.trim();
if (!path) {
return { success: false, message: `第 ${index + 1} 条规则缺少字段路径` };
}
const normalizedValue = normalizeValueForRule(rule);
if (!normalizedValue.success) {
return {
success: false,
message: `第 ${index + 1} 条规则保存失败:${normalizedValue.message}`,
};
}
const groupKey = `${modelPattern}\u0000${rule.protocol.trim()}`;
if (rule.action === 'filter') {
const existing = sections.filter.get(groupKey) || {
models: [buildModelRule(rule)],
params: [],
};
if (!existing.params.includes(path)) {
existing.params.push(path);
}
sections.filter.set(groupKey, existing);
continue;
}
const targetSection = sections[rule.action];
const existing = targetSection.get(groupKey) || {
models: [buildModelRule(rule)],
params: {},
};
existing.params[path] = normalizedValue.value;
targetSection.set(groupKey, existing);
}
return {
success: true,
value: {
default: Array.from(sections.default.values()),
'default-raw': Array.from(sections['default-raw'].values()),
override: Array.from(sections.override.values()),
'override-raw': Array.from(sections['override-raw'].values()),
filter: Array.from(sections.filter.values()),
},
};
}