Files
Genarrative/apps/preview-deployer-web/src/PreviewDeployerApp.tsx
T
kdletters 38ae9d7d07
Project CI / Repository checks (push) Successful in 1m6s
Project CI / Frontend tests (push) Successful in 2m55s
Project CI / Backend tests (push) Successful in 3m53s
Project CI / Native shell tests (push) Successful in 15m2s
完善预览发布记录管理
发布记录改用 Jenkins 构建编号并展示

失败与停止记录增加自动清理期限

构建详情改为局域网 Jenkins 地址

同步测试、部署配置和运维文档
2026-08-17 12:42:29 +08:00

1034 lines
30 KiB
TypeScript

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<DeploymentStatus, string> = {
queued: '排队中',
building: '构建中',
deploying: '发布中',
running: '运行中',
uninstalling: '卸载中',
stopped: '已卸载',
failed: '失败',
cancelled: '已取消',
};
const HEALTH_LABELS: Record<DeploymentHealth, string> = {
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<PreviewDeployment[]>([]);
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<SearchState<BranchRef>>(emptySearchState());
const [commitSearch, setCommitSearch] =
useState<SearchState<CommitRef>>(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<PreviewDeployment | null>(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<HTMLFormElement>) {
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<HTMLFormElement>) {
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 (
<main className="session-screen">
<LoaderCircle className="spin" size={25} />
<span>正在校验访问会话</span>
</main>
);
}
if (sessionStatus === 'guest') {
return (
<main className="session-screen session-login-screen">
<form
className="login-card"
onSubmit={(event) => void handleLogin(event)}
>
<span className="brand-mark login-brand-mark">
<Box size={21} />
</span>
<div>
<p className="eyebrow">PREVIEW DEPLOYER</p>
<h1>Docker 预览发布</h1>
</div>
<label>
<span>访问口令</span>
<div className="input-shell">
<Hash size={17} />
<input
autoFocus
autoComplete="current-password"
disabled={authenticating}
placeholder="请输入访问口令"
type="password"
value={accessToken}
onChange={(event) => setAccessToken(event.target.value)}
/>
</div>
</label>
{authError ? (
<div className="login-error">
<TriangleAlert size={16} />
{authError}
</div>
) : null}
<button
className="primary-button"
disabled={authenticating}
type="submit"
>
{authenticating ? (
<LoaderCircle className="spin" size={17} />
) : null}
{authenticating ? '正在进入' : '进入发布面板'}
</button>
</form>
</main>
);
}
return (
<main className="app-shell">
<header className="topbar">
<div className="brand">
<span className="brand-mark">
<Box size={20} />
</span>
<div>
<strong>Docker 预览发布</strong>
<span>内网环境</span>
</div>
</div>
<div className="topbar-actions">
<button
className="secondary-button"
type="button"
disabled={refreshing}
onClick={() => void refresh()}
>
<RefreshCw className={refreshing ? 'spin' : ''} size={16} />
刷新
</button>
<button
className="secondary-button"
type="button"
onClick={() => void handleLogout()}
>
退出
</button>
</div>
</header>
<div className="content">
<section className="hero">
<div>
<p className="eyebrow">PREVIEW DEPLOYER</p>
<h1>构建并发布一个分支</h1>
</div>
<div className="summary-row">
<Summary
icon={<Activity size={17} />}
label="进行中"
value={activeCount}
/>
<Summary
icon={<Server size={17} />}
label="运行中"
value={runningCount}
/>
</div>
</section>
<form
className="deploy-form"
onSubmit={(event) => void handleSubmit(event)}
>
<div className="search-field">
<label htmlFor="branch-input">分支名</label>
<div className="input-shell" role="presentation">
<GitBranch size={17} />
<input
aria-activedescendant={
branchSearch.open && branchActiveIndex >= 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,
)
}
/>
</div>
<SearchResults
id="branch-search-list"
type="branch"
state={branchSearch}
onSelect={(item) => {
setBranch(item.name);
setCommitHash('');
setBranchSearchEnabled(false);
setCommitSearchEnabled(false);
setBranchSearch((current) => ({ ...current, open: false }));
}}
activeIndex={branchActiveIndex}
/>
</div>
<div className="search-field">
<label htmlFor="commit-input">
Commit Hash <small>可选</small>
</label>
<div className="input-shell" role="presentation">
<Hash size={17} />
<input
aria-activedescendant={
commitSearch.open && commitActiveIndex >= 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,
)
}
/>
</div>
<SearchResults
id="commit-search-list"
type="commit"
state={commitSearch}
onSelect={(item) => {
setCommitHash(item.commitHash);
setCommitSearchEnabled(false);
setCommitSearch((current) => ({ ...current, open: false }));
}}
activeIndex={commitActiveIndex}
/>
</div>
<button
className="primary-button"
type="submit"
disabled={submitting}
>
{submitting ? (
<LoaderCircle className="spin" size={17} />
) : (
<Box size={17} />
)}
{submitting ? '正在提交' : '开始构建'}
</button>
</form>
{error ? (
<div className="flash flash-error">
<TriangleAlert size={17} />
{error}
</div>
) : null}
{notice ? (
<div className="flash flash-success">
<CheckCircle2 size={17} />
{notice}
</div>
) : null}
<section className="deployments-section">
<div className="section-heading">
<div>
<h2>发布记录</h2>
<p>状态每 5 秒自动刷新</p>
</div>
</div>
{loading ? (
<div className="empty-state">
<LoaderCircle className="spin" />
正在读取发布状态
</div>
) : deployments.length === 0 ? (
<div className="empty-state">
<Box />
还没有发布记录
</div>
) : (
<div className="deployment-list">
{deployments.map((deployment) => (
<DeploymentCard
key={
deployment.id ??
`${deployment.branch}-${deployment.createdAt}`
}
deployment={deployment}
uninstalling={uninstallingId === deployment.id}
onUninstall={() => setPendingUninstall(deployment)}
/>
))}
</div>
)}
</section>
</div>
{pendingUninstall ? (
<div
className="modal-backdrop"
role="presentation"
onMouseDown={() => setPendingUninstall(null)}
>
<section
aria-labelledby="uninstall-title"
aria-modal="true"
className="confirm-dialog"
role="dialog"
onMouseDown={(event) => event.stopPropagation()}
>
<span className="danger-icon">
<Trash2 size={21} />
</span>
<h2 id="uninstall-title">卸载这个预览?</h2>
<p>
将停止并移除 <strong>{pendingUninstall.branch}</strong>{' '}
对应的容器环境。
</p>
<div className="dialog-actions">
<button
className="secondary-button"
type="button"
onClick={() => setPendingUninstall(null)}
>
取消
</button>
<button
className="danger-button"
type="button"
onClick={() => void handleUninstall()}
>
<Trash2 size={16} />
确认卸载
</button>
</div>
</section>
</div>
) : null}
</main>
);
}
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 (
<article className="deployment-card">
<div className="card-main">
<div className="deployment-title-row">
<span className={`status-icon status-${deployment.status}`}>
<StatusIcon status={deployment.status} />
</span>
<div className="deployment-title">
<strong>
<GitBranch size={15} />
{deployment.branch}
</strong>
<span>
{displayedCommit ? shortCommit(displayedCommit) : '分支最新提交'}
</span>
</div>
</div>
<div className="badges">
<span className={`badge badge-${deployment.status}`}>
{STATUS_LABELS[deployment.status]}
</span>
<span className={`badge health-${deployment.health}`}>
<span className="health-dot" />
{HEALTH_LABELS[deployment.health]}
</span>
{deployment.webPort ? (
<span className="badge port-badge">端口 {deployment.webPort}</span>
) : null}
<span className="badge record-badge">
构建 #{deployment.id || '待分配'}
</span>
</div>
</div>
<div className="deployment-meta">
<span>
<Clock3 size={14} />
{formatTime(deployment.updatedAt || deployment.createdAt)}
</span>
{deployment.message ? (
<span className="deployment-message">{deployment.message}</span>
) : null}
</div>
<div className="card-actions">
{deployment.webUrl ? (
<a
className="primary-link"
href={deployment.webUrl}
target="_blank"
rel="noreferrer"
>
打开 Web <ExternalLink size={15} />
</a>
) : (
<span className="url-placeholder">Web 地址等待发布完成</span>
)}
<div className="right-actions">
{deployment.jenkinsBuildUrl ? (
<a
className="text-link"
href={deployment.jenkinsBuildUrl}
target="_blank"
rel="noreferrer"
>
构建详情 <ExternalLink size={13} />
</a>
) : null}
{canUninstall && deployment.id ? (
<button
className="icon-danger-button"
type="button"
disabled={uninstalling}
onClick={onUninstall}
title="卸载容器"
>
{uninstalling ? (
<LoaderCircle className="spin" size={16} />
) : (
<Trash2 size={16} />
)}
<span>卸载</span>
</button>
) : null}
</div>
</div>
</article>
);
}
function StatusIcon({ status }: { status: DeploymentStatus }) {
if (['queued', 'building', 'deploying', 'uninstalling'].includes(status)) {
return <LoaderCircle className="spin" size={18} />;
}
if (status === 'running') {
return <CheckCircle2 size={18} />;
}
if (status === 'failed' || status === 'cancelled') {
return <XCircle size={18} />;
}
return <Box size={18} />;
}
function Summary({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: number;
}) {
return (
<span className="summary-chip">
{icon}
<strong>{value}</strong>
<span>{label}</span>
</span>
);
}
type SearchState<T> = {
items: T[];
loading: boolean;
open: boolean;
error: string;
};
function emptySearchState<T>(): SearchState<T> {
return { items: [], loading: false, open: false, error: '' };
}
function handleSearchKeyDown<T>(
event: ReactKeyboardEvent<HTMLInputElement>,
state: SearchState<T>,
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<BranchRef>;
activeIndex: number;
onSelect: (item: BranchRef) => void;
}
| {
id: string;
type: 'commit';
state: SearchState<CommitRef>;
activeIndex: number;
onSelect: (item: CommitRef) => void;
}) {
if (!state.open) return null;
return (
<div className="search-results" id={id} role="listbox">
{state.loading ? (
<div className="search-state">
<LoaderCircle className="spin" size={15} />
正在搜索
</div>
) : state.error ? (
<div className="search-state search-state-error">{state.error}</div>
) : state.items.length === 0 ? (
<div className="search-state">没有匹配结果</div>
) : type === 'branch' ? (
state.items.map((item, index) => (
<button
aria-selected={index === activeIndex}
className={`search-option${index === activeIndex ? ' active' : ''}`}
id={`branch-option-${index}`}
key={item.name}
role="option"
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => onSelect(item)}
>
<GitBranch size={15} />
<span>{item.name}</span>
{item.commitHash ? (
<small>{shortCommit(item.commitHash)}</small>
) : null}
</button>
))
) : (
state.items.map((item, index) => (
<button
aria-selected={index === activeIndex}
className={`search-option${index === activeIndex ? ' active' : ''}`}
id={`commit-option-${index}`}
key={item.commitHash}
role="option"
type="button"
onMouseDown={(event) => event.preventDefault()}
onClick={() => onSelect(item)}
>
<Hash size={15} />
<span>
<strong>{item.shortHash || shortCommit(item.commitHash)}</strong>
{item.subject ? <small>{item.subject}</small> : null}
</span>
</button>
))
)}
</div>
);
}
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;
}