import { Activity, Box, CheckCircle2, Clock3, ExternalLink, GitBranch, Hash, LoaderCircle, RefreshCw, Server, Trash2, TriangleAlert, XCircle, } from 'lucide-react'; import { FormEvent, KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { type BranchRef, type CommitRef, createDeployment, createSession, deleteSession, getSession, listDeployments, PreviewDeployerApiError, searchBranches, searchCommits, uninstallDeployment, } from './api'; import type { DeploymentHealth, DeploymentStatus, PreviewDeployment, } from './types'; import { validateBranch, validateCommitHash } from './validation'; const POLL_INTERVAL_MS = 5000; const SEARCH_DEBOUNCE_MS = 300; const STATUS_LABELS: Record = { queued: '排队中', building: '构建中', deploying: '发布中', running: '运行中', uninstalling: '卸载中', stopped: '已卸载', failed: '失败', cancelled: '已取消', }; const HEALTH_LABELS: Record = { pending: '等待检查', healthy: '健康', unhealthy: '不健康', unknown: '未知', }; export function PreviewDeployerApp() { const [sessionStatus, setSessionStatus] = useState< 'checking' | 'guest' | 'authenticated' >('checking'); const [accessToken, setAccessToken] = useState(''); const [authenticating, setAuthenticating] = useState(false); const [authError, setAuthError] = useState(''); const [branch, setBranch] = useState('master'); const [commitHash, setCommitHash] = useState(''); const [deployments, setDeployments] = useState([]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [submitting, setSubmitting] = useState(false); const [notice, setNotice] = useState(''); const [error, setError] = useState(''); const [branchSearch, setBranchSearch] = useState>(emptySearchState()); const [commitSearch, setCommitSearch] = useState>(emptySearchState()); const [branchActiveIndex, setBranchActiveIndex] = useState(-1); const [commitActiveIndex, setCommitActiveIndex] = useState(-1); const [branchSearchEnabled, setBranchSearchEnabled] = useState(false); const [commitSearchEnabled, setCommitSearchEnabled] = useState(false); const branchRequestId = useRef(0); const commitRequestId = useRef(0); const [pendingUninstall, setPendingUninstall] = useState(null); const [uninstallingId, setUninstallingId] = useState(''); const refresh = useCallback(async (background = false) => { if (!background) { setRefreshing(true); } try { const nextDeployments = await listDeployments(); setDeployments(sortDeployments(nextDeployments)); setError(''); } catch (refreshError) { if (isUnauthorized(refreshError)) { setSessionStatus('guest'); setDeployments([]); setError(''); return; } setError(formatError(refreshError)); } finally { setLoading(false); setRefreshing(false); } }, []); useEffect(() => { const controller = new AbortController(); void getSession(controller.signal) .then((session) => { if (session.authenticated) { setSessionStatus('authenticated'); void refresh(true); } else { setSessionStatus('guest'); setLoading(false); } }) .catch((sessionError) => { if (isUnauthorized(sessionError)) { setSessionStatus('guest'); setLoading(false); return; } setAuthError(formatError(sessionError)); setSessionStatus('guest'); setLoading(false); }); return () => controller.abort(); }, [refresh]); useEffect(() => { if (sessionStatus !== 'authenticated') { return; } const interval = window.setInterval(() => { if (document.visibilityState === 'visible') { void refresh(true); } }, POLL_INTERVAL_MS); return () => window.clearInterval(interval); }, [refresh, sessionStatus]); useEffect(() => { const query = branch.trim(); const requestId = ++branchRequestId.current; const controller = new AbortController(); if (sessionStatus !== 'authenticated' || !branchSearchEnabled || !query) { setBranchSearch(emptySearchState()); setBranchActiveIndex(-1); return () => controller.abort(); } setBranchSearch({ items: [], loading: true, open: true, error: '' }); setBranchActiveIndex(-1); const timer = window.setTimeout(() => { void searchBranches(query, controller.signal) .then((items) => { if (requestId !== branchRequestId.current) return; setBranchSearch({ items, loading: false, open: true, error: '' }); }) .catch((searchError) => { if ( controller.signal.aborted || requestId !== branchRequestId.current ) return; if (isUnauthorized(searchError)) { handleUnauthorized(); return; } setBranchSearch({ items: [], loading: false, open: true, error: formatError(searchError), }); }); }, SEARCH_DEBOUNCE_MS); return () => { window.clearTimeout(timer); controller.abort(); }; }, [branch, branchSearchEnabled, sessionStatus]); useEffect(() => { const query = commitHash.trim(); const selectedBranch = branch.trim(); const requestId = ++commitRequestId.current; const controller = new AbortController(); if ( sessionStatus !== 'authenticated' || !commitSearchEnabled || !query || validateBranch(selectedBranch) ) { setCommitSearch(emptySearchState()); setCommitActiveIndex(-1); return () => controller.abort(); } setCommitSearch({ items: [], loading: true, open: true, error: '' }); setCommitActiveIndex(-1); const timer = window.setTimeout(() => { void searchCommits(selectedBranch, query, controller.signal) .then((items) => { if (requestId !== commitRequestId.current) return; setCommitSearch({ items, loading: false, open: true, error: '' }); }) .catch((searchError) => { if ( controller.signal.aborted || requestId !== commitRequestId.current ) return; if (isUnauthorized(searchError)) { handleUnauthorized(); return; } setCommitSearch({ items: [], loading: false, open: true, error: formatError(searchError), }); }); }, SEARCH_DEBOUNCE_MS); return () => { window.clearTimeout(timer); controller.abort(); }; }, [branch, commitHash, commitSearchEnabled, sessionStatus]); const runningCount = useMemo( () => deployments.filter((deployment) => deployment.status === 'running') .length, [deployments], ); const activeCount = useMemo( () => deployments.filter((deployment) => isActive(deployment.status)).length, [deployments], ); async function handleSubmit(event: FormEvent) { event.preventDefault(); const branchError = validateBranch(branch); const commitError = validateCommitHash(commitHash); if (branchError || commitError) { setError(branchError || commitError); return; } setSubmitting(true); setError(''); setNotice(''); try { const trimmedCommit = commitHash.trim(); await createDeployment({ branch: branch.trim(), ...(trimmedCommit ? { commitHash: trimmedCommit } : {}), }); setCommitHash(''); setNotice('构建请求已提交'); await refresh(true); } catch (submitError) { if (isUnauthorized(submitError)) { handleUnauthorized(); return; } setError(formatError(submitError)); } finally { setSubmitting(false); } } async function handleUninstall() { if (!pendingUninstall) { return; } const deployment = pendingUninstall; if (!deployment.id) { return; } setPendingUninstall(null); setUninstallingId(deployment.id); setError(''); setNotice(''); try { await uninstallDeployment(deployment.id); setNotice(`已提交 ${deployment.branch} 的卸载请求`); await refresh(true); } catch (uninstallError) { if (isUnauthorized(uninstallError)) { handleUnauthorized(); return; } setError(formatError(uninstallError)); } finally { setUninstallingId(''); } } async function handleLogin(event: FormEvent) { event.preventDefault(); const token = accessToken.trim(); if (!token) { setAuthError('请输入访问口令'); return; } setAuthenticating(true); setAuthError(''); try { const session = await createSession(token); if (!session.authenticated) { throw new Error('访问口令无效'); } setAccessToken(''); setSessionStatus('authenticated'); setLoading(true); await refresh(true); } catch (loginError) { setAuthError( isUnauthorized(loginError) ? '访问口令无效' : formatError(loginError), ); } finally { setAuthenticating(false); } } async function handleLogout() { try { await deleteSession(); } finally { handleUnauthorized(); } } function handleUnauthorized() { setSessionStatus('guest'); setDeployments([]); setAccessToken(''); setError(''); setNotice(''); setPendingUninstall(null); } if (sessionStatus === 'checking') { return (
正在校验访问会话
); } if (sessionStatus === 'guest') { return (
void handleLogin(event)} >

PREVIEW DEPLOYER

Docker 预览发布

{authError ? (
{authError}
) : null}
); } return (
Docker 预览发布 内网环境

PREVIEW DEPLOYER

构建并发布一个分支

} label="进行中" value={activeCount} /> } label="运行中" value={runningCount} />
void handleSubmit(event)} >
= 0 ? `branch-option-${branchActiveIndex}` : undefined } aria-autocomplete="list" aria-controls="branch-search-list" aria-expanded={branchSearch.open} aria-haspopup="listbox" aria-label="分支名" role="combobox" id="branch-input" autoComplete="off" disabled={submitting} maxLength={200} placeholder="master" value={branch} onChange={(event) => { setBranchSearchEnabled(true); setBranch(event.target.value); if (commitHash) { setCommitHash(''); setCommitSearchEnabled(false); } }} onFocus={() => { setBranchSearchEnabled(true); setBranchSearch((current) => ({ ...current, open: true })); }} onKeyDown={(event) => { handleSearchKeyDown( event, branchSearch, branchActiveIndex, (index) => setBranchActiveIndex(index), (item) => { setBranch(item.name); setCommitHash(''); setBranchSearchEnabled(false); setCommitSearchEnabled(false); setBranchSearch((current) => ({ ...current, open: false, })); }, () => setBranchSearch((current) => ({ ...current, open: false, })), ); }} onBlur={() => window.setTimeout( () => setBranchSearch((current) => ({ ...current, open: false, })), 120, ) } />
{ setBranch(item.name); setCommitHash(''); setBranchSearchEnabled(false); setCommitSearchEnabled(false); setBranchSearch((current) => ({ ...current, open: false })); }} activeIndex={branchActiveIndex} />
= 0 ? `commit-option-${commitActiveIndex}` : undefined } aria-autocomplete="list" aria-controls="commit-search-list" aria-expanded={commitSearch.open} aria-haspopup="listbox" aria-label="Commit Hash" role="combobox" id="commit-input" autoComplete="off" disabled={submitting} maxLength={40} placeholder="留空则构建分支最新提交" value={commitHash} onChange={(event) => { setCommitSearchEnabled(true); setCommitHash(event.target.value); }} onFocus={() => { setCommitSearchEnabled(true); setCommitSearch((current) => ({ ...current, open: true })); }} onKeyDown={(event) => { handleSearchKeyDown( event, commitSearch, commitActiveIndex, (index) => setCommitActiveIndex(index), (item) => { setCommitHash(item.commitHash); setCommitSearchEnabled(false); setCommitSearch((current) => ({ ...current, open: false, })); }, () => setCommitSearch((current) => ({ ...current, open: false, })), ); }} onBlur={() => window.setTimeout( () => setCommitSearch((current) => ({ ...current, open: false, })), 120, ) } />
{ setCommitHash(item.commitHash); setCommitSearchEnabled(false); setCommitSearch((current) => ({ ...current, open: false })); }} activeIndex={commitActiveIndex} />
{error ? (
{error}
) : null} {notice ? (
{notice}
) : null}

发布记录

状态每 5 秒自动刷新

{loading ? (
正在读取发布状态
) : deployments.length === 0 ? (
还没有发布记录
) : (
{deployments.map((deployment) => ( setPendingUninstall(deployment)} /> ))}
)}
{pendingUninstall ? (
setPendingUninstall(null)} >
event.stopPropagation()} >

卸载这个预览?

将停止并移除 {pendingUninstall.branch}{' '} 对应的容器环境。

) : null}
); } function DeploymentCard({ deployment, uninstalling, onUninstall, }: { deployment: PreviewDeployment; uninstalling: boolean; onUninstall: () => void; }) { const displayedCommit = deployment.resolvedCommit || deployment.commitHash; const canUninstall = deployment.canUninstall ?? !['stopped', 'uninstalling', 'cancelled'].includes(deployment.status); return (
{deployment.branch} {displayedCommit ? shortCommit(displayedCommit) : '分支最新提交'}
{STATUS_LABELS[deployment.status]} {HEALTH_LABELS[deployment.health]} {deployment.webPort ? ( 端口 {deployment.webPort} ) : null} 构建 #{deployment.id || '待分配'}
{formatTime(deployment.updatedAt || deployment.createdAt)} {deployment.message ? ( {deployment.message} ) : null}
{deployment.webUrl && deployment.webPublicUrl ? ( 打开公网预览 ) : null} {deployment.webUrl ? ( 打开 Web ) : ( Web 地址等待发布完成 )}
{deployment.jenkinsBuildUrl ? ( 构建详情 ) : null} {canUninstall && deployment.id ? ( ) : null}
); } function StatusIcon({ status }: { status: DeploymentStatus }) { if (['queued', 'building', 'deploying', 'uninstalling'].includes(status)) { return ; } if (status === 'running') { return ; } if (status === 'failed' || status === 'cancelled') { return ; } return ; } function Summary({ icon, label, value, }: { icon: React.ReactNode; label: string; value: number; }) { return ( {icon} {value} {label} ); } type SearchState = { items: T[]; loading: boolean; open: boolean; error: string; }; function emptySearchState(): SearchState { return { items: [], loading: false, open: false, error: '' }; } function handleSearchKeyDown( event: ReactKeyboardEvent, state: SearchState, activeIndex: number, setActiveIndex: (index: number) => void, onSelect: (item: T) => void, onClose: () => void, ) { if (!state.open || (!state.items.length && event.key !== 'Escape')) { return; } if (event.key === 'ArrowDown') { event.preventDefault(); setActiveIndex((activeIndex + 1) % state.items.length); } else if (event.key === 'ArrowUp') { event.preventDefault(); setActiveIndex(activeIndex <= 0 ? state.items.length - 1 : activeIndex - 1); } else if (event.key === 'Enter' && activeIndex >= 0) { event.preventDefault(); const item = state.items[activeIndex]; if (item) onSelect(item); } else if (event.key === 'Escape') { event.preventDefault(); setActiveIndex(-1); onClose(); } } function SearchResults({ id, type, state, activeIndex, onSelect, }: | { id: string; type: 'branch'; state: SearchState; activeIndex: number; onSelect: (item: BranchRef) => void; } | { id: string; type: 'commit'; state: SearchState; activeIndex: number; onSelect: (item: CommitRef) => void; }) { if (!state.open) return null; return (
{state.loading ? (
正在搜索
) : state.error ? (
{state.error}
) : state.items.length === 0 ? (
没有匹配结果
) : type === 'branch' ? ( state.items.map((item, index) => ( )) ) : ( state.items.map((item, index) => ( )) )}
); } function isActive(status: DeploymentStatus) { return ['queued', 'building', 'deploying', 'uninstalling'].includes(status); } function sortDeployments(deployments: PreviewDeployment[]) { return [...deployments].sort( (left, right) => toTimestamp(right.createdAt) - toTimestamp(left.createdAt), ); } function shortCommit(value: string) { return value.slice(0, 10); } function formatTime(value: string | number) { const timestamp = toTimestamp(value); if (Number.isNaN(timestamp)) { return String(value || '时间未知'); } return new Intl.DateTimeFormat('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }).format(timestamp); } function toTimestamp(value: string | number) { if (typeof value === 'number') { return value < 10_000_000_000 ? value * 1000 : value; } return Date.parse(value); } function formatError(error: unknown) { return error instanceof Error && error.message.trim() ? error.message : '请求失败'; } function isUnauthorized(error: unknown) { return error instanceof PreviewDeployerApiError && error.status === 401; }