feat(pages): 支持 GitHub Release 部署源

增加 latest/tag 手动检查与同步、ETag 与限流退避、资源替换确认,以及对应的前端来源管理和部署来源展示。
This commit is contained in:
deqiying
2026-07-19 18:31:42 +08:00
parent 38b0516937
commit c39a3edcc3
34 changed files with 5751 additions and 278 deletions
@@ -45,7 +45,7 @@ const SOURCE_LABELS: Record<PagesDeployment['source_type'], string> = {
manual_upload: '本地上传',
manual_url: 'URL 导入',
remote_url: 'Remote URL',
github_release: 'GitHub Release',
github_release: 'GitHub',
};
const TRIGGER_LABELS: Record<PagesDeployment['trigger_type'], string> = {
@@ -172,7 +172,7 @@ export function DeploymentHistory({
) : deployments.length === 0 ? (
<EmptyStateWithBorder
title='暂无部署'
description='上传本地部署包,或配置 Remote URL 来源后同步发布。'
description='上传本地部署包,或配置 Remote URL / GitHub Release 来源后同步发布。'
/>
) : (
deployments.map((deployment) => {
@@ -1,11 +1,28 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Download, Pencil, RefreshCw, RotateCcw } from 'lucide-react';
import {
Download,
Github,
Pencil,
RefreshCw,
RotateCcw,
Search,
} from 'lucide-react';
import { toast } from 'sonner';
import { ErrorInline } from '@/components/layout/error';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -21,12 +38,11 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Spinner } from '@/components/ui/spinner';
import { AdminTaskService } from '@/lib/services/admin';
import {
type PagesSource,
type PagesSourceActionPayload,
type PagesSourceActionReceipt,
type PagesSourceStatus,
PagesService,
} from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import {
deploymentsQueryKey,
@@ -34,7 +50,11 @@ import {
projectsQueryKey,
sourceQueryKey,
} from '../../components/pages-utils';
import { PagesSourceDialog } from './pages-source-dialog';
import { type PagesSourceMode, PagesSourceDialog } from './pages-source-dialog';
import {
GitHubSourceDetails,
RemoteSourceDetails,
} from './pages-source-details';
const ACTION_POLL_INTERVAL = 2_000;
const ACTION_MAX_WAIT = 16 * 60 * 1_000;
@@ -59,10 +79,8 @@ interface ActiveAction {
startedAt: number;
}
function revisionSummary(source: PagesSource) {
if (source.source_type === 'manual' || !source.last_applied)
return '尚未应用';
return `${source.last_applied.label} · ${source.last_applied.revision.slice(0, 12)}`;
function sourceActionLabel(action: PagesSourceActionReceipt['action']) {
return action === 'check' ? '检查' : '同步并发布';
}
export function PagesSourceCard({ projectId }: { projectId: number }) {
@@ -70,11 +88,10 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
const handledExecutionID = useRef<string | null>(null);
const sourcePollingStartedAt = useRef<number | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<'manual' | 'remote_url'>(
'manual',
);
const [dialogMode, setDialogMode] = useState<PagesSourceMode>('manual');
const [activeAction, setActiveAction] = useState<ActiveAction | null>(null);
const [actionTimedOut, setActionTimedOut] = useState(false);
const [attentionDialogOpen, setAttentionDialogOpen] = useState(false);
const sourceQuery = useQuery({
queryKey: sourceQueryKey(projectId),
@@ -102,8 +119,11 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
'task-execution',
activeAction?.receipt.execution_id ?? '',
],
queryFn: () =>
AdminTaskService.getTaskExecution(activeAction!.receipt.execution_id),
queryFn: () => {
const executionID = activeAction?.receipt.execution_id;
if (!executionID) throw new Error('缺少任务执行 ID');
return AdminTaskService.getTaskExecution(executionID);
},
enabled: Boolean(activeAction) && !actionTimedOut,
refetchInterval: (query) => {
if (actionTimedOut) return false;
@@ -114,6 +134,15 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
},
});
const beginActionPolling = useCallback(
(receipt: PagesSourceActionReceipt) => {
handledExecutionID.current = null;
setActiveAction({ receipt, startedAt: Date.now() });
setActionTimedOut(false);
},
[],
);
useEffect(() => {
if (!activeAction || actionTimedOut) return;
const elapsed = Date.now() - activeAction.startedAt;
@@ -124,7 +153,11 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
useEffect(() => {
const execution = executionQuery.data;
if (!execution || !['succeeded', 'failed'].includes(execution.status)) {
if (
!activeAction ||
!execution ||
!['succeeded', 'failed'].includes(execution.status)
) {
return;
}
if (handledExecutionID.current === execution.id) return;
@@ -142,21 +175,36 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
]);
const actionLabel = sourceActionLabel(activeAction.receipt.action);
if (execution.status === 'succeeded') {
toast.success('部署源同步并发布完成');
toast.success(`部署源${actionLabel}完成`);
} else {
toast.error(execution.error_message || '部署源同步失败');
toast.error(execution.error_message || `部署源${actionLabel}失败`);
}
setActiveAction(null);
setActionTimedOut(false);
}, [executionQuery.data, projectId, queryClient]);
}, [activeAction, executionQuery.data, projectId, queryClient]);
const checkMutation = useMutation({
mutationFn: () => PagesService.checkSource(projectId),
onSuccess: async (receipt) => {
beginActionPolling(receipt);
await queryClient.invalidateQueries({
queryKey: sourceQueryKey(projectId),
});
toast.success('检查任务已提交');
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '检查任务提交失败');
},
});
const syncMutation = useMutation({
mutationFn: () => PagesService.syncSource(projectId, {}),
mutationFn: (payload: PagesSourceActionPayload) =>
PagesService.syncSource(projectId, payload),
onSuccess: async (receipt) => {
handledExecutionID.current = null;
setActiveAction({ receipt, startedAt: Date.now() });
setActionTimedOut(false);
setAttentionDialogOpen(false);
beginActionPolling(receipt);
await queryClient.invalidateQueries({
queryKey: sourceQueryKey(projectId),
});
@@ -176,13 +224,38 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
const sourceBusy =
source?.source_type !== 'manual' &&
(source?.sync_status === 'checking' || source?.sync_status === 'syncing');
const actionsDisabled = syncMutation.isPending || executionBusy || sourceBusy;
const actionsDisabled =
checkMutation.isPending ||
syncMutation.isPending ||
executionBusy ||
sourceBusy;
const checkBusy =
checkMutation.isPending ||
(executionBusy && activeAction?.receipt.action === 'check') ||
(source?.source_type !== 'manual' && source?.sync_status === 'checking');
const syncBusy =
syncMutation.isPending ||
(executionBusy && activeAction?.receipt.action === 'sync') ||
(source?.source_type !== 'manual' && source?.sync_status === 'syncing');
const dispatchError = checkMutation.error ?? syncMutation.error;
const openSourceDialog = (mode: 'manual' | 'remote_url') => {
const openSourceDialog = (mode: PagesSourceMode) => {
setDialogMode(mode);
setDialogOpen(true);
};
const dispatchSync = () => {
checkMutation.reset();
if (
source?.source_type === 'github_release' &&
source.sync_status === 'attention'
) {
setAttentionDialogOpen(true);
return;
}
syncMutation.mutate({});
};
if (sourceQuery.isLoading) {
return (
<Card>
@@ -219,10 +292,22 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
);
}
const effectiveSourceStatus = executionBusy
? activeAction?.receipt.action === 'check'
? 'checking'
: 'syncing'
: source.source_type === 'manual'
? undefined
: (source.sync_status ?? 'idle');
const status =
source.source_type === 'manual'
? null
: SOURCE_STATUS[source.sync_status ?? 'idle'];
: SOURCE_STATUS[effectiveSourceStatus ?? 'idle'];
const attentionRevision =
source.source_type === 'github_release' &&
source.sync_status === 'attention'
? source.last_seen
: undefined;
return (
<>
@@ -250,47 +335,20 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
</p>
</div>
) : source.source_type === 'remote_url' ? (
<div className='grid gap-4 md:grid-cols-2'>
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
<span className='text-xs text-muted-foreground'>脱敏地址</span>
<code className='truncate text-sm'>{source.display_url}</code>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>网络策略</span>
<span className='text-sm font-medium'>
{source.remote_network_policy === 'trusted_internal'
? '受信内网模式'
: '公网安全模式'}
</span>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>最近同步</span>
<span className='text-sm font-medium'>
{source.last_synced_at
? formatDateTime(source.last_synced_at)
: '尚未同步'}
</span>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
<span className='text-xs text-muted-foreground'>
已应用 revision
</span>
<span className='font-mono text-sm'>
{revisionSummary(source)}
</span>
</div>
{source.last_error ? (
<div className='md:col-span-2'>
<ErrorInline message={source.last_error} />
</div>
) : null}
</div>
<RemoteSourceDetails source={source} />
) : (
<div className='rounded-lg border p-4 text-sm text-muted-foreground'>
当前版本暂不提供该来源类型的编辑界面。
</div>
<GitHubSourceDetails source={source} />
)}
{dispatchError ? (
<ErrorInline
message={
dispatchError instanceof Error
? dispatchError.message
: '来源任务提交失败'
}
/>
) : null}
{executionQuery.isError ? (
<ErrorInline
message={
@@ -327,30 +385,61 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
<CardFooter className='flex flex-wrap gap-2 border-t'>
{source.source_type === 'manual' ? (
<Button
type='button'
onClick={() => openSourceDialog('remote_url')}
>
<Download data-icon='inline-start' />
配置 Remote URL
</Button>
) : source.source_type === 'remote_url' ? (
<>
<Button
type='button'
variant='outline'
onClick={() => openSourceDialog('remote_url')}
>
<Download data-icon='inline-start' />
配置 Remote URL
</Button>
<Button
type='button'
onClick={() => openSourceDialog('github_release')}
>
<Github data-icon='inline-start' />
配置 GitHub Release
</Button>
</>
) : (
<>
<Button
type='button'
variant='outline'
disabled={actionsDisabled}
onClick={() => openSourceDialog('remote_url')}
onClick={() => openSourceDialog(source.source_type)}
>
<Pencil data-icon='inline-start' />
编辑来源
</Button>
{source.source_type === 'github_release' ? (
<Button
type='button'
variant='outline'
disabled={actionsDisabled}
onClick={() => {
syncMutation.reset();
checkMutation.mutate();
}}
>
{checkBusy ? (
<Spinner data-icon='inline-start' />
) : (
<Search data-icon='inline-start' />
)}
检查更新
</Button>
) : null}
<Button
type='button'
disabled={actionsDisabled}
onClick={() => syncMutation.mutate()}
disabled={
actionsDisabled ||
(source.sync_status === 'attention' && !attentionRevision)
}
onClick={dispatchSync}
>
{actionsDisabled ? (
{syncBusy ? (
<Spinner data-icon='inline-start' />
) : (
<Download data-icon='inline-start' />
@@ -367,7 +456,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
切换回手动
</Button>
</>
) : null}
)}
<Button
type='button'
variant='ghost'
@@ -387,7 +476,48 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
projectId={projectId}
source={source}
initialMode={dialogMode}
onActionDispatched={beginActionPolling}
/>
<AlertDialog
open={attentionDialogOpen}
onOpenChange={(open) => {
if (!syncMutation.isPending) setAttentionDialogOpen(open);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认发布当前 GitHub revision</AlertDialogTitle>
<AlertDialogDescription>
<span>这将发布卡片当前显示的精确 revision:</span>{' '}
<span className='break-all font-mono'>
{attentionRevision?.revision ??
'当前 revision 已不可用,请刷新后重试'}
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={syncMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
disabled={syncMutation.isPending || !attentionRevision}
onClick={(event) => {
event.preventDefault();
if (!attentionRevision) return;
syncMutation.mutate({
confirmed_revision: attentionRevision.revision,
});
}}
>
{syncMutation.isPending ? (
<Spinner data-icon='inline-start' />
) : null}
确认并发布
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,137 @@
import { TriangleAlert } from 'lucide-react';
import { ErrorInline } from '@/components/layout/error';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import {
type PagesGitHubReleaseSource,
type PagesRemoteURLSource,
type PagesSourceRevision,
} from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
function revisionSummary(revision?: PagesSourceRevision) {
if (!revision) return '尚无记录';
return `${revision.label} · ${revision.revision.slice(0, 12)}`;
}
export function RemoteSourceDetails({
source,
}: {
source: PagesRemoteURLSource;
}) {
return (
<div className='grid gap-4 md:grid-cols-2'>
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
<span className='text-xs text-muted-foreground'>脱敏地址</span>
<code className='truncate text-sm'>{source.display_url}</code>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>网络策略</span>
<span className='text-sm font-medium'>
{source.remote_network_policy === 'trusted_internal'
? '受信内网模式'
: '公网安全模式'}
</span>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>最近同步</span>
<span className='text-sm font-medium'>
{source.last_synced_at
? formatDateTime(source.last_synced_at)
: '尚未同步'}
</span>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
<span className='text-xs text-muted-foreground'>已应用 revision</span>
<span className='font-mono text-sm'>
{revisionSummary(source.last_applied)}
</span>
</div>
{source.last_error ? (
<div className='md:col-span-2'>
<ErrorInline message={source.last_error} />
</div>
) : null}
</div>
);
}
export function GitHubSourceDetails({
source,
}: {
source: PagesGitHubReleaseSource;
}) {
const attentionRevision =
source.sync_status === 'attention' ? source.last_seen : undefined;
return (
<div className='flex flex-col gap-4'>
{attentionRevision ? (
<Alert variant='destructive'>
<TriangleAlert />
<AlertTitle>Release Asset 发生变化,需要显式确认</AlertTitle>
<AlertDescription>
<p>
当前远端 revision
与已发布内容不一致。请核对版本和资源后,再确认发布这一精确
revision。
</p>
<code className='break-all'>{attentionRevision.revision}</code>
</AlertDescription>
</Alert>
) : null}
<div className='grid gap-4 md:grid-cols-2'>
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
<span className='text-xs text-muted-foreground'>GitHub 仓库</span>
<code className='truncate text-sm'>{source.github_repository}</code>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>Release 选择</span>
<span className='text-sm font-medium'>
{source.release_selector === 'latest'
? '最新 Release'
: `固定 Tag · ${source.release_tag ?? '未提供'}`}
</span>
</div>
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>Release Asset</span>
<code className='truncate text-sm'>{source.asset_name}</code>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>远端已发现</span>
<span className='font-mono text-sm'>
{revisionSummary(source.last_seen)}
</span>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>当前已应用</span>
<span className='font-mono text-sm'>
{revisionSummary(source.last_applied)}
</span>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>最近检查</span>
<span className='text-sm font-medium'>
{source.last_checked_at
? formatDateTime(source.last_checked_at)
: '尚未检查'}
</span>
</div>
<div className='flex flex-col gap-1 rounded-lg border p-4'>
<span className='text-xs text-muted-foreground'>最近同步</span>
<span className='text-sm font-medium'>
{source.last_synced_at
? formatDateTime(source.last_synced_at)
: '尚未同步'}
</span>
</div>
{source.last_error ? (
<div className='md:col-span-2'>
<ErrorInline message={source.last_error} />
</div>
) : null}
</div>
</div>
);
}
@@ -2,6 +2,7 @@
import { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import {
@@ -36,6 +37,8 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
type PagesRemoteNetworkPolicy,
type PagesSource,
type PagesSourceActionReceipt,
type PagesSourceUpdatePayload,
PagesService,
} from '@/lib/services/openflare';
@@ -45,8 +48,19 @@ import {
projectsQueryKey,
sourceQueryKey,
} from '../../components/pages-utils';
import {
type PagesGitHubSourceFormErrors,
type PagesGitHubSourceFormValue,
PagesSourceGitHubFields,
} from './pages-source-github-fields';
import {
validGitHubAssetName,
validGitHubReleaseTag,
validGitHubRepositoryURL,
} from './pages-source-validation';
export type PagesSourceMode = 'manual' | 'remote_url' | 'github_release';
type SourceMode = 'manual' | 'remote_url';
type Confirmation = 'trusted_internal' | 'manual' | null;
interface PagesSourceDialogProps {
@@ -54,7 +68,21 @@ interface PagesSourceDialogProps {
onOpenChange: (open: boolean) => void;
projectId: number;
source: PagesSource;
initialMode?: SourceMode;
initialMode?: PagesSourceMode;
onActionDispatched?: (receipt: PagesSourceActionReceipt) => void;
}
const DEFAULT_GITHUB_ASSET = 'dist.zip';
const DEFAULT_GITHUB_CHECK_INTERVAL = 60;
const EMPTY_GITHUB_ERRORS: PagesGitHubSourceFormErrors = {
repository: '',
releaseTag: '',
assetName: '',
};
function githubRepositoryURL(repository: string) {
const value = repository.trim();
return value ? `https://github.com/${value}` : '';
}
export function PagesSourceDialog({
@@ -63,21 +91,33 @@ export function PagesSourceDialog({
projectId,
source,
initialMode,
onActionDispatched,
}: PagesSourceDialogProps) {
const queryClient = useQueryClient();
const [mode, setMode] = useState<SourceMode>('manual');
const [mode, setMode] = useState<PagesSourceMode>('manual');
const [networkPolicy, setNetworkPolicy] =
useState<PagesRemoteNetworkPolicy>('public');
const [replaceURL, setReplaceURL] = useState(false);
const [remoteURL, setRemoteURL] = useState('');
const [revealRemoteURL, setRevealRemoteURL] = useState(false);
const [urlError, setURLError] = useState('');
const [githubForm, setGitHubForm] = useState<PagesGitHubSourceFormValue>({
repositoryURL: '',
releaseSelector: 'latest',
releaseTag: '',
assetName: DEFAULT_GITHUB_ASSET,
});
const [githubErrors, setGitHubErrors] =
useState<PagesGitHubSourceFormErrors>(EMPTY_GITHUB_ERRORS);
const [confirmation, setConfirmation] = useState<Confirmation>(null);
useEffect(() => {
if (!open) return;
const nextMode =
initialMode ??
(source.source_type === 'remote_url' ? 'remote_url' : 'manual');
if (!open) {
setRemoteURL('');
setRevealRemoteURL(false);
return;
}
const nextMode = initialMode ?? source.source_type;
setMode(nextMode);
setNetworkPolicy(
source.source_type === 'remote_url'
@@ -86,7 +126,27 @@ export function PagesSourceDialog({
);
setReplaceURL(source.source_type !== 'remote_url');
setRemoteURL('');
setRevealRemoteURL(false);
setURLError('');
setGitHubForm({
repositoryURL:
source.source_type === 'github_release'
? githubRepositoryURL(source.github_repository)
: '',
releaseSelector:
source.source_type === 'github_release'
? source.release_selector
: 'latest',
releaseTag:
source.source_type === 'github_release'
? (source.release_tag ?? '')
: '',
assetName:
source.source_type === 'github_release'
? source.asset_name
: DEFAULT_GITHUB_ASSET,
});
setGitHubErrors(EMPTY_GITHUB_ERRORS);
setConfirmation(null);
}, [initialMode, open, source]);
@@ -102,19 +162,17 @@ export function PagesSourceDialog({
};
const updateMutation = useMutation({
mutationFn: () =>
PagesService.updateSource(projectId, {
source_type: 'remote_url',
remote_url_set: replaceURL,
remote_url: replaceURL ? remoteURL.trim() : '',
remote_network_policy: networkPolicy,
}),
mutationFn: (payload: PagesSourceUpdatePayload) =>
PagesService.updateSource(projectId, payload),
onSuccess: async (result) => {
queryClient.setQueryData(sourceQueryKey(projectId), result.source);
if (result.check_task) onActionDispatched?.(result.check_task);
await invalidateSourceState();
toast.success('部署源已更新');
if (result.warning) toast.warning(result.warning);
setConfirmation(null);
setRemoteURL('');
setRevealRemoteURL(false);
onOpenChange(false);
},
onError: (error) => {
@@ -129,6 +187,8 @@ export function PagesSourceDialog({
await invalidateSourceState();
toast.success('已切换回手动部署');
setConfirmation(null);
setRemoteURL('');
setRevealRemoteURL(false);
onOpenChange(false);
},
onError: (error) => {
@@ -138,6 +198,13 @@ export function PagesSourceDialog({
const isPending = updateMutation.isPending || deleteMutation.isPending;
const remotePayload = (): PagesSourceUpdatePayload => ({
source_type: 'remote_url',
remote_url_set: replaceURL,
remote_url: replaceURL ? remoteURL.trim() : '',
remote_network_policy: networkPolicy,
});
const submitRemote = () => {
if (replaceURL) {
const value = remoteURL.trim();
@@ -158,21 +225,86 @@ export function PagesSourceDialog({
setConfirmation('trusted_internal');
return;
}
updateMutation.mutate();
updateMutation.mutate(remotePayload());
};
const submitGitHub = () => {
const normalizedRepositoryURL = githubForm.repositoryURL.trim();
const nextRepositoryError = validGitHubRepositoryURL(
normalizedRepositoryURL,
)
? ''
: '请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址';
const nextReleaseTagError =
githubForm.releaseSelector === 'tag' &&
!validGitHubReleaseTag(githubForm.releaseTag)
? 'Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)'
: '';
const nextAssetNameError = validGitHubAssetName(githubForm.assetName)
? ''
: 'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符';
setGitHubErrors({
repository: nextRepositoryError,
releaseTag: nextReleaseTagError,
assetName: nextAssetNameError,
});
if (nextRepositoryError || nextReleaseTagError || nextAssetNameError) {
return;
}
const payload: PagesSourceUpdatePayload =
githubForm.releaseSelector === 'latest'
? {
source_type: 'github_release',
repository_url: normalizedRepositoryURL,
release_selector: 'latest',
release_tag: '',
asset_name: githubForm.assetName,
auto_update_enabled: false,
check_interval_minutes:
source.source_type === 'github_release' &&
source.release_selector === 'latest' &&
source.check_interval_minutes
? source.check_interval_minutes
: DEFAULT_GITHUB_CHECK_INTERVAL,
}
: {
source_type: 'github_release',
repository_url: normalizedRepositoryURL,
release_selector: 'tag',
release_tag: githubForm.releaseTag,
asset_name: githubForm.assetName,
auto_update_enabled: false,
check_interval_minutes: 0,
};
updateMutation.mutate(payload);
};
const handleSubmit = () => {
if (mode === 'manual') {
if (source.source_type === 'manual') {
onOpenChange(false);
} else {
setConfirmation('manual');
}
return;
switch (mode) {
case 'manual':
if (source.source_type === 'manual') {
onOpenChange(false);
} else {
setConfirmation('manual');
}
return;
case 'remote_url':
submitRemote();
return;
case 'github_release':
submitGitHub();
}
submitRemote();
};
const submitLabel =
mode === 'manual'
? '使用手动部署'
: mode === 'remote_url'
? '保存 Remote 来源'
: '保存 GitHub 来源';
return (
<>
<Dialog
@@ -185,8 +317,7 @@ export function PagesSourceDialog({
<DialogHeader>
<DialogTitle>部署源设置</DialogTitle>
<DialogDescription>
手动部署与 Remote URL
各自保持独立配置;后续仓库构建来源会作为新的来源类型接入。
手动部署、Remote URL 与 GitHub Release 使用独立配置。
</DialogDescription>
</DialogHeader>
@@ -198,10 +329,19 @@ export function PagesSourceDialog({
variant='outline'
value={mode}
aria-labelledby='pages-source-mode'
className='grid w-full grid-cols-2'
className='grid w-full grid-cols-1 sm:grid-cols-3'
onValueChange={(value) => {
if (value === 'manual' || value === 'remote_url') {
if (
value === 'manual' ||
value === 'remote_url' ||
value === 'github_release'
) {
setMode(value);
if (value !== 'remote_url') {
setRemoteURL('');
setRevealRemoteURL(false);
setURLError('');
}
}
}}
>
@@ -211,9 +351,17 @@ export function PagesSourceDialog({
<ToggleGroupItem value='remote_url' className='w-full'>
Remote URL
</ToggleGroupItem>
<ToggleGroupItem value='github_release' className='w-full'>
GitHub Release
</ToggleGroupItem>
</ToggleGroup>
<FieldDescription>
手动部署由管理员上传本地包;Remote URL 通过显式同步下载并发布。
<span>
远端来源只负责发现内容,发布结果始终保留为不可变部署。
</span>
<span className='block'>
仓库源码构建将在后续作为独立来源类型提供。
</span>
</FieldDescription>
</Field>
@@ -224,7 +372,7 @@ export function PagesSourceDialog({
保留现有部署与当前生产版本,后续通过“上传部署包”创建新部署。
</div>
</Field>
) : (
) : mode === 'remote_url' ? (
<>
<Field data-invalid={Boolean(urlError)}>
<FieldLabel htmlFor='pages-remote-url'>Remote URL</FieldLabel>
@@ -240,24 +388,48 @@ export function PagesSourceDialog({
onClick={() => {
setReplaceURL(true);
setRemoteURL('');
setRevealRemoteURL(false);
}}
>
更换地址
</Button>
</div>
) : (
<Input
id='pages-remote-url'
type='url'
placeholder='https://artifacts.example.com/site.zip?token=...'
value={remoteURL}
aria-invalid={Boolean(urlError)}
autoComplete='off'
onChange={(event) => {
setRemoteURL(event.target.value);
setURLError('');
}}
/>
<div className='flex gap-2'>
<Input
id='pages-remote-url'
type={revealRemoteURL ? 'url' : 'password'}
placeholder='https://artifacts.example.com/site.zip?token=...'
value={remoteURL}
aria-invalid={Boolean(urlError)}
autoComplete='off'
className='min-w-0 flex-1'
onChange={(event) => {
setRemoteURL(event.target.value);
setURLError('');
}}
/>
<Button
type='button'
variant='outline'
size='icon'
aria-label={
revealRemoteURL
? '隐藏 Remote URL'
: '显示 Remote URL'
}
title={
revealRemoteURL
? '隐藏 Remote URL'
: '显示 Remote URL'
}
onClick={() =>
setRevealRemoteURL((visible) => !visible)
}
>
{revealRemoteURL ? <EyeOff /> : <Eye />}
</Button>
</div>
)}
<FieldDescription>
{urlError ||
@@ -274,6 +446,7 @@ export function PagesSourceDialog({
onClick={() => {
setReplaceURL(false);
setRemoteURL('');
setRevealRemoteURL(false);
setURLError('');
}}
>
@@ -313,6 +486,14 @@ export function PagesSourceDialog({
</FieldDescription>
</Field>
</>
) : (
<PagesSourceGitHubFields
value={githubForm}
errors={githubErrors}
defaultAssetName={DEFAULT_GITHUB_ASSET}
onChange={setGitHubForm}
onErrorsChange={setGitHubErrors}
/>
)}
</FieldGroup>
@@ -321,13 +502,17 @@ export function PagesSourceDialog({
type='button'
variant='outline'
disabled={isPending}
onClick={() => onOpenChange(false)}
onClick={() => {
setRemoteURL('');
setRevealRemoteURL(false);
onOpenChange(false);
}}
>
取消
</Button>
<Button type='button' disabled={isPending} onClick={handleSubmit}>
{isPending ? <Spinner data-icon='inline-start' /> : null}
{mode === 'manual' ? '使用手动部署' : '保存 Remote 来源'}
{submitLabel}
</Button>
</DialogFooter>
</DialogContent>
@@ -361,7 +546,7 @@ export function PagesSourceDialog({
if (confirmation === 'manual') {
deleteMutation.mutate();
} else if (confirmation === 'trusted_internal') {
updateMutation.mutate();
updateMutation.mutate(remotePayload());
}
}}
>
@@ -0,0 +1,130 @@
import {
Field,
FieldDescription,
FieldLabel,
FieldTitle,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import type { PagesGitHubReleaseSelector } from '@/lib/services/openflare';
export interface PagesGitHubSourceFormValue {
repositoryURL: string;
releaseSelector: PagesGitHubReleaseSelector;
releaseTag: string;
assetName: string;
}
export interface PagesGitHubSourceFormErrors {
repository: string;
releaseTag: string;
assetName: string;
}
interface PagesSourceGitHubFieldsProps {
value: PagesGitHubSourceFormValue;
errors: PagesGitHubSourceFormErrors;
defaultAssetName: string;
onChange: (value: PagesGitHubSourceFormValue) => void;
onErrorsChange: (errors: PagesGitHubSourceFormErrors) => void;
}
export function PagesSourceGitHubFields({
value,
errors,
defaultAssetName,
onChange,
onErrorsChange,
}: PagesSourceGitHubFieldsProps) {
return (
<>
<Field data-invalid={Boolean(errors.repository)}>
<FieldLabel htmlFor='pages-github-repository'>
GitHub 仓库 URL
</FieldLabel>
<Input
id='pages-github-repository'
type='url'
placeholder='https://github.com/owner/repo'
value={value.repositoryURL}
aria-invalid={Boolean(errors.repository)}
autoComplete='off'
onChange={(event) => {
onChange({ ...value, repositoryURL: event.target.value });
onErrorsChange({ ...errors, repository: '' });
}}
/>
<FieldDescription>
{errors.repository || '仅支持公开 github.com 仓库。'}
</FieldDescription>
</Field>
<Field>
<FieldTitle id='pages-github-selector'>Release 选择</FieldTitle>
<ToggleGroup
type='single'
variant='outline'
value={value.releaseSelector}
aria-labelledby='pages-github-selector'
className='grid w-full grid-cols-2'
onValueChange={(selector) => {
if (selector === 'latest' || selector === 'tag') {
onChange({ ...value, releaseSelector: selector });
onErrorsChange({ ...errors, releaseTag: '' });
}
}}
>
<ToggleGroupItem value='latest' className='w-full'>
最新 Release
</ToggleGroupItem>
<ToggleGroupItem value='tag' className='w-full'>
固定 Tag
</ToggleGroupItem>
</ToggleGroup>
<FieldDescription>
当前阶段由管理员手动检查并决定是否发布。
</FieldDescription>
</Field>
{value.releaseSelector === 'tag' ? (
<Field data-invalid={Boolean(errors.releaseTag)}>
<FieldLabel htmlFor='pages-github-tag'>Release tag</FieldLabel>
<Input
id='pages-github-tag'
placeholder='v1.2.3'
value={value.releaseTag}
aria-invalid={Boolean(errors.releaseTag)}
autoComplete='off'
onChange={(event) => {
onChange({ ...value, releaseTag: event.target.value });
onErrorsChange({ ...errors, releaseTag: '' });
}}
/>
<FieldDescription>
{errors.releaseTag || '精确检查并同步指定 tag。'}
</FieldDescription>
</Field>
) : null}
<Field data-invalid={Boolean(errors.assetName)}>
<FieldLabel htmlFor='pages-github-asset'>
Release Asset 文件名
</FieldLabel>
<Input
id='pages-github-asset'
placeholder={defaultAssetName}
value={value.assetName}
aria-invalid={Boolean(errors.assetName)}
autoComplete='off'
onChange={(event) => {
onChange({ ...value, assetName: event.target.value });
onErrorsChange({ ...errors, assetName: '' });
}}
/>
<FieldDescription>
{errors.assetName || '按文件名精确匹配已上传的 Release Asset。'}
</FieldDescription>
</Field>
</>
);
}
@@ -0,0 +1,68 @@
function hasWellFormedUnicode(value: string) {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index);
if (code >= 0xd800 && code <= 0xdbff) {
const next = value.charCodeAt(index + 1);
if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
index += 1;
} else if (code >= 0xdc00 && code <= 0xdfff) {
return false;
}
}
return true;
}
function validGitHubSafeText(value: string) {
return (
value !== '' &&
new TextEncoder().encode(value).byteLength <= 255 &&
!/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u.test(
value,
) &&
hasWellFormedUnicode(value)
);
}
export function validGitHubRepositoryURL(raw: string) {
const match = /^https:\/\/([^/]+)\/([^/]+)\/([^/]+)$/u.exec(raw);
if (!match) return false;
const [, host, owner, rawRepository] = match;
const repository = rawRepository.replace(/\.git$/u, '');
return (
host.toLowerCase() === 'github.com' &&
/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/iu.test(owner) &&
/^[a-z0-9._-]+$/iu.test(repository) &&
repository.length <= 100 &&
!['.', '..'].includes(repository)
);
}
export function validGitHubAssetName(value: string) {
return (
validGitHubSafeText(value) &&
value !== '.' &&
value !== '..' &&
!value.includes('/') &&
!value.includes('\\')
);
}
export function validGitHubReleaseTag(value: string) {
const components = value.split('/');
return (
validGitHubSafeText(value) &&
!value.endsWith('.') &&
!value.includes('..') &&
!value.includes('@{') &&
![' ', '~', '^', ':', '?', '*', '[', '\\'].some((character) =>
value.includes(character),
) &&
components.every(
(component) =>
component !== '' &&
!component.startsWith('.') &&
!component.endsWith('.lock'),
)
);
}
+66
View File
@@ -0,0 +1,66 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
{
variants: {
variant: {
default: 'bg-card text-card-foreground',
destructive:
'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current',
},
},
defaultVariants: {
variant: 'default',
},
},
);
function Alert({
className,
variant,
...props
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot='alert'
role='alert'
className={cn(alertVariants({ variant }), className)}
{...props}
/>
);
}
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-title'
className={cn(
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
className,
)}
{...props}
/>
);
}
function AlertDescription({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot='alert-description'
className={cn(
'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
className,
)}
{...props}
/>
);
}
export { Alert, AlertDescription, AlertTitle };
+5
View File
@@ -109,7 +109,11 @@ export type {
OriginMutationPayload,
PagesDeployment,
PagesDeploymentFile,
PagesGitHubLatestSourceUpdatePayload,
PagesGitHubReleaseSelector,
PagesGitHubReleaseSource,
PagesGitHubSourceUpdatePayload,
PagesGitHubTagSourceUpdatePayload,
PagesManualSource,
PagesProject,
PagesProjectPayload,
@@ -121,6 +125,7 @@ export type {
PagesSourceActionReceipt,
PagesSourceRevision,
PagesSourceStatus,
PagesSourceUpdatePayload,
PagesSourceUpdateResult,
AcmeAccountItem,
DnsAccountItem,
@@ -12,10 +12,10 @@ import type {
PagesDeploymentUploadPayload,
PagesProject,
PagesProjectPayload,
PagesRemoteSourceUpdatePayload,
PagesSource,
PagesSourceActionPayload,
PagesSourceActionReceipt,
PagesSourceUpdatePayload,
PagesSourceUpdateResult,
} from './types';
@@ -51,7 +51,7 @@ export class PagesService extends OpenFlareBaseService {
static updateSource(
projectId: number,
payload: PagesRemoteSourceUpdatePayload,
payload: PagesSourceUpdatePayload,
): Promise<PagesSourceUpdateResult> {
return this.post<PagesSourceUpdateResult>(
`/${projectId}/source/update`,
+36 -5
View File
@@ -462,6 +462,8 @@ export type PagesSourceStatus =
export type PagesRemoteNetworkPolicy = 'public' | 'trusted_internal';
export type PagesGitHubReleaseSelector = 'latest' | 'tag';
export interface PagesSourceRevision {
revision: string;
label: string;
@@ -493,15 +495,15 @@ export interface PagesRemoteURLSource extends PagesSourceRuntimeView {
export interface PagesGitHubReleaseSource extends PagesSourceRuntimeView {
source_type: 'github_release';
github_repository: string;
release_selector: 'latest' | 'tag';
release_tag: string;
release_selector: PagesGitHubReleaseSelector;
release_tag?: string;
asset_name: string;
auto_update_enabled: boolean;
check_interval_minutes: number;
auto_update_enabled?: false;
check_interval_minutes?: number;
}
/**
* 部署源使用判别联合,后续仓库构建来源只需增加独立 variant,
* 部署源使用判别联合,后续仓库构建来源只需增加独立 git_repository variant,
* 不需要向 Remote 或 GitHub Release 填入构建字段。
*/
export type PagesSource =
@@ -514,6 +516,35 @@ export interface PagesRemoteSourceUpdatePayload {
remote_network_policy: PagesRemoteNetworkPolicy;
}
interface PagesGitHubSourceUpdateBase {
source_type: 'github_release';
repository_url: string;
asset_name: string;
auto_update_enabled: false;
}
export interface PagesGitHubLatestSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
release_selector: 'latest';
release_tag: '';
check_interval_minutes: number;
}
export interface PagesGitHubTagSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
release_selector: 'tag';
release_tag: string;
check_interval_minutes: 0;
}
export type PagesGitHubSourceUpdatePayload =
PagesGitHubLatestSourceUpdatePayload | PagesGitHubTagSourceUpdatePayload;
/**
* Source 更新请求保持 Provider 判别联合;未来仓库拉取构建使用独立 git_repository variant,
* 不向 Remote URL 或 GitHub Release payload 混入构建字段。
*/
export type PagesSourceUpdatePayload =
PagesRemoteSourceUpdatePayload | PagesGitHubSourceUpdatePayload;
export interface PagesSourceActionPayload {
confirmed_revision?: string;
}
@@ -91,6 +91,74 @@ describe('PagesService', () => {
);
});
it('sends the complete GitHub latest discriminator payload', async () => {
vi.mocked(apiClient.post).mockResolvedValue(
response({
source: { source_type: 'manual' },
check_task: null,
warning: '',
}),
);
await PagesService.updateSource(12, {
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'latest',
release_tag: '',
asset_name: 'dist.zip',
auto_update_enabled: false,
check_interval_minutes: 60,
});
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/d/pages/12/source/update',
{
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'latest',
release_tag: '',
asset_name: 'dist.zip',
auto_update_enabled: false,
check_interval_minutes: 60,
},
undefined,
);
});
it('sends safe disabled defaults with the GitHub tag discriminator', async () => {
vi.mocked(apiClient.post).mockResolvedValue(
response({
source: { source_type: 'manual' },
check_task: null,
warning: '',
}),
);
await PagesService.updateSource(12, {
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'tag',
release_tag: 'v1.2.3',
asset_name: 'site.tar.gz',
auto_update_enabled: false,
check_interval_minutes: 0,
});
expect(apiClient.post).toHaveBeenCalledWith(
'/api/v1/d/pages/12/source/update',
{
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'tag',
release_tag: 'v1.2.3',
asset_name: 'site.tar.gz',
auto_update_enabled: false,
check_interval_minutes: 0,
},
undefined,
);
});
it('uploads only the package multipart field', async () => {
vi.mocked(apiClient.post).mockResolvedValue(response({}));
const file = new File(['site'], 'site.zip', {
+446 -21
View File
@@ -1,13 +1,19 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { DeploymentUploadDialog } from '@/app/(main)/pages/components/deployment-upload-dialog';
import { DeploymentHistory } from '@/app/(main)/pages/detail/components/deployment-history';
import { PagesSourceCard } from '@/app/(main)/pages/detail/components/pages-source-card';
import { AdminTaskService } from '@/lib/services/admin';
import {
AdminTaskService,
type TaskExecution,
type TaskExecutionStatus,
} from '@/lib/services/admin';
import {
type PagesDeployment,
type PagesGitHubReleaseSource,
type PagesRemoteURLSource,
PagesService,
} from '@/lib/services/openflare';
@@ -21,6 +27,7 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => {
getSource: vi.fn(),
updateSource: vi.fn(),
deleteSource: vi.fn(),
checkSource: vi.fn(),
syncSource: vi.fn(),
listDeployments: vi.fn(),
listDeploymentFiles: vi.fn(),
@@ -67,11 +74,73 @@ const remoteSource: PagesRemoteURLSource = {
last_error: '',
};
const githubLatestSource: PagesGitHubReleaseSource = {
source_type: 'github_release',
github_repository: 'openflare/site',
release_selector: 'latest',
asset_name: 'dist.zip',
auto_update_enabled: false,
check_interval_minutes: 60,
sync_status: 'update_available',
update_available: true,
last_seen: {
revision: 'b'.repeat(64),
label: 'v1.2.3',
asset_name: 'dist.zip',
},
last_applied: {
revision: 'a'.repeat(64),
label: 'v1.2.2',
asset_name: 'dist.zip',
},
last_checked_at: '2026-07-19T10:00:00Z',
last_synced_at: '2026-07-18T10:00:00Z',
next_check_at: '2026-07-19T11:00:00Z',
last_error: '',
};
const githubAttentionRevision = 'c'.repeat(64);
const githubAttentionSource: PagesGitHubReleaseSource = {
...githubLatestSource,
sync_status: 'attention',
update_available: true,
last_seen: {
revision: githubAttentionRevision,
label: 'v1.2.3',
asset_name: 'dist.zip',
},
};
function taskExecution(
status: TaskExecutionStatus,
errorMessage = '',
): TaskExecution {
return {
id: '42',
task_id: 'manual_of_pages_source_action_1',
task_type: 'of_pages_source_action',
task_name: 'Pages 来源动作',
status,
retryable: false,
max_retry: 0,
retry_count: 0,
log: '',
error_message: errorMessage,
result: '',
duration: 1,
payload: '',
triggered_by: 'admin:1',
created_at: '2026-07-19T10:00:00Z',
updated_at: '2026-07-19T10:00:01Z',
};
}
describe('Pages source UI', () => {
beforeEach(() => {
vi.mocked(PagesService.getSource).mockReset();
vi.mocked(PagesService.updateSource).mockReset();
vi.mocked(PagesService.deleteSource).mockReset();
vi.mocked(PagesService.checkSource).mockReset();
vi.mocked(PagesService.syncSource).mockReset();
vi.mocked(PagesService.listDeployments).mockReset();
vi.mocked(PagesService.listDeploymentFiles).mockReset();
@@ -81,7 +150,8 @@ describe('Pages source UI', () => {
vi.mocked(AdminTaskService.getTaskExecution).mockReset();
});
it('keeps Phase 1 manual source focused on upload and Remote URL', async () => {
it('offers the three Phase 2 source types without future repository build controls', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue({
source_type: 'manual',
});
@@ -92,8 +162,327 @@ describe('Pages source UI', () => {
expect(
screen.getByRole('button', { name: '配置 Remote URL' }),
).toBeVisible();
expect(
screen.getByRole('button', { name: '配置 GitHub Release' }),
).toBeVisible();
expect(screen.queryByText('检查更新')).not.toBeInTheDocument();
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
await user.click(
screen.getByRole('button', { name: '配置 GitHub Release' }),
);
expect(screen.getByRole('radio', { name: '手动部署' })).toBeVisible();
expect(screen.getByRole('radio', { name: 'Remote URL' })).toBeVisible();
expect(screen.getByRole('radio', { name: 'GitHub Release' })).toBeVisible();
expect(
screen.getByText('仓库源码构建将在后续作为独立来源类型提供。'),
).toBeVisible();
expect(screen.queryByText('构建命令')).not.toBeInTheDocument();
expect(screen.queryByText('输出目录')).not.toBeInTheDocument();
});
it('submits the GitHub latest payload while keeping automatic controls hidden', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue({
source_type: 'manual',
});
vi.mocked(PagesService.updateSource).mockResolvedValue({
source: githubLatestSource,
check_task: null,
warning: '',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(
await screen.findByRole('button', { name: '配置 GitHub Release' }),
);
await user.type(
screen.getByLabelText('GitHub 仓库 URL'),
'https://github.com/openflare/site',
);
expect(screen.getByLabelText('Release Asset 文件名')).toHaveValue(
'dist.zip',
);
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
expect(screen.queryByText('检查间隔')).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
await waitFor(() => {
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'latest',
release_tag: '',
asset_name: 'dist.zip',
auto_update_enabled: false,
check_interval_minutes: 60,
});
});
});
it('rejects non-canonical GitHub repository URL paths', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue({
source_type: 'manual',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(
await screen.findByRole('button', { name: '配置 GitHub Release' }),
);
const repositoryInput = screen.getByLabelText('GitHub 仓库 URL');
await user.type(repositoryInput, 'https://github.com//openflare/site');
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
expect(
screen.getByText(
'请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址',
),
).toBeVisible();
await user.clear(repositoryInput);
await user.type(repositoryInput, 'https://github.com/openflare/site/');
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
expect(PagesService.updateSource).not.toHaveBeenCalled();
});
it('submits the GitHub tag discriminator with safe disabled defaults', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue({
source_type: 'manual',
});
vi.mocked(PagesService.updateSource).mockResolvedValue({
source: {
...githubLatestSource,
release_selector: 'tag',
release_tag: 'v1.2.3',
check_interval_minutes: undefined,
next_check_at: null,
},
check_task: null,
warning: '',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(
await screen.findByRole('button', { name: '配置 GitHub Release' }),
);
await user.type(
screen.getByLabelText('GitHub 仓库 URL'),
'https://github.com/openflare/site',
);
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
await user.type(
screen.getByLabelText('Release tag'),
'release/candidate#1&channel=stable',
);
await user.clear(screen.getByLabelText('Release Asset 文件名'));
await user.type(
screen.getByLabelText('Release Asset 文件名'),
' site?arch=amd64#stable.zip ',
);
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
await waitFor(() => {
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
source_type: 'github_release',
repository_url: 'https://github.com/openflare/site',
release_selector: 'tag',
release_tag: 'release/candidate#1&channel=stable',
asset_name: ' site?arch=amd64#stable.zip ',
auto_update_enabled: false,
check_interval_minutes: 0,
});
});
});
it('rejects unsafe GitHub tag and asset values before saving', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue({
source_type: 'manual',
});
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(
await screen.findByRole('button', { name: '配置 GitHub Release' }),
);
await user.type(
screen.getByLabelText('GitHub 仓库 URL'),
'https://github.com/openflare/site',
);
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
await user.type(screen.getByLabelText('Release tag'), 'release//candidate');
await user.clear(screen.getByLabelText('Release Asset 文件名'));
await user.type(
screen.getByLabelText('Release Asset 文件名'),
'../dist.zip',
);
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
expect(
screen.getByText(
'Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)',
),
).toBeVisible();
expect(
screen.getByText(
'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符',
),
).toBeVisible();
expect(PagesService.updateSource).not.toHaveBeenCalled();
});
it('polls the initial check receipt and represents the queued window locally', async () => {
const user = userEvent.setup();
const idleGitHubSource: PagesGitHubReleaseSource = {
...githubLatestSource,
sync_status: 'idle',
update_available: false,
};
vi.mocked(PagesService.getSource)
.mockResolvedValueOnce({ source_type: 'manual' })
.mockResolvedValue(idleGitHubSource);
vi.mocked(PagesService.updateSource).mockResolvedValue({
source: idleGitHubSource,
check_task: {
task_id: 'manual_of_pages_source_action_1',
execution_id: '42',
action: 'check',
},
warning: '',
});
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
taskExecution('pending'),
);
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(
await screen.findByRole('button', { name: '配置 GitHub Release' }),
);
await user.type(
screen.getByLabelText('GitHub 仓库 URL'),
'https://github.com/openflare/site',
);
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
await waitFor(() => {
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
expect(screen.getByText('检查中')).toBeVisible();
});
const checkButton = screen.getByRole('button', { name: /检查更新/ });
expect(checkButton).toBeDisabled();
expect(within(checkButton).getByRole('status')).toBeVisible();
expect(PagesService.checkSource).not.toHaveBeenCalled();
expect(PagesService.syncSource).not.toHaveBeenCalled();
});
it('shows GitHub release state and hides Phase 3 controls', async () => {
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
renderWithQuery(<PagesSourceCard projectId={9} />);
expect(await screen.findByText('openflare/site')).toBeVisible();
expect(screen.getByText('v1.2.3 · bbbbbbbbbbbb')).toBeVisible();
expect(screen.getByText('v1.2.2 · aaaaaaaaaaaa')).toBeVisible();
expect(screen.getByText('有可用更新')).toBeVisible();
expect(screen.queryByText('下次检查时间')).not.toBeInTheDocument();
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
expect(screen.queryByText('检查间隔')).not.toBeInTheDocument();
});
it('dispatches a GitHub check and starts TaskExecution polling', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
vi.mocked(PagesService.checkSource).mockResolvedValue({
task_id: 'manual_of_pages_source_action_1',
execution_id: '42',
action: 'check',
});
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
taskExecution('succeeded'),
);
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '检查更新' }));
await waitFor(() => {
expect(PagesService.checkSource).toHaveBeenCalledWith(9);
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
expect(PagesService.syncSource).not.toHaveBeenCalled();
});
});
it('renders a GitHub check dispatch error', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
vi.mocked(PagesService.checkSource).mockRejectedValue(
new Error('GitHub API 暂不可用'),
);
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '检查更新' }));
expect(await screen.findByText('GitHub API 暂不可用')).toBeVisible();
});
it('renders a TaskExecution polling error with an explicit retry', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
vi.mocked(PagesService.checkSource).mockResolvedValue({
task_id: 'manual_of_pages_source_action_1',
execution_id: '42',
action: 'check',
});
vi.mocked(AdminTaskService.getTaskExecution).mockRejectedValue(
new Error('任务状态暂时不可读取'),
);
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '检查更新' }));
expect(await screen.findByText('任务状态暂时不可读取')).toBeVisible();
expect(screen.getByRole('button', { name: '重试' })).toBeVisible();
});
it('requires the exact currently displayed revision for attention sync', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(githubAttentionSource);
vi.mocked(PagesService.syncSource).mockResolvedValue({
task_id: 'manual_of_pages_source_action_1',
execution_id: '42',
action: 'sync',
});
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
taskExecution('succeeded'),
);
renderWithQuery(<PagesSourceCard projectId={9} />);
const alert = await screen.findByRole('alert');
expect(
within(alert).getByText('Release Asset 发生变化,需要显式确认'),
).toBeVisible();
expect(within(alert).getByText(githubAttentionRevision)).toBeVisible();
await user.click(screen.getByRole('button', { name: '同步并发布' }));
const dialog = screen.getByRole('alertdialog');
expect(within(dialog).getByText(githubAttentionRevision)).toBeVisible();
await user.click(
within(dialog).getByRole('button', { name: '确认并发布' }),
);
await waitFor(() => {
expect(PagesService.syncSource).toHaveBeenCalledWith(9, {
confirmed_revision: githubAttentionRevision,
});
});
});
it('never reuses the masked URL as an editable value', async () => {
@@ -119,7 +508,13 @@ describe('Pages source UI', () => {
'https://artifacts.example.com/site.zip?token=...',
);
expect(input).toHaveValue('');
expect(input).toHaveAttribute('type', 'password');
await user.type(input, 'https://new.example.com/site.zip?token=new');
expect(
screen.queryByText('https://new.example.com/site.zip?token=new'),
).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '显示 Remote URL' }));
expect(input).toHaveAttribute('type', 'url');
await user.click(screen.getByRole('button', { name: '保存 Remote 来源' }));
await waitFor(() => {
@@ -130,6 +525,17 @@ describe('Pages source UI', () => {
remote_network_policy: 'public',
});
});
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: '编辑来源' }));
await user.click(screen.getByRole('button', { name: '更换地址' }));
const reopenedInput = screen.getByPlaceholderText(
'https://artifacts.example.com/site.zip?token=...',
);
expect(reopenedInput).toHaveValue('');
expect(reopenedInput).toHaveAttribute('type', 'password');
});
it('requires a second confirmation for trusted internal networking', async () => {
@@ -169,24 +575,9 @@ describe('Pages source UI', () => {
execution_id: '42',
action: 'sync',
});
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue({
id: '42',
task_id: 'manual_of_pages_source_action_1',
task_type: 'of_pages_source_action',
task_name: 'Pages 来源动作',
status: 'succeeded',
retryable: false,
max_retry: 0,
retry_count: 0,
log: '',
error_message: '',
result: '',
duration: 1,
payload: '',
triggered_by: 'admin:1',
created_at: '2026-07-19T10:00:00Z',
updated_at: '2026-07-19T10:00:01Z',
});
vi.mocked(AdminTaskService.getTaskExecution)
.mockResolvedValueOnce(taskExecution('pending'))
.mockResolvedValue(taskExecution('succeeded'));
renderWithQuery(<PagesSourceCard projectId={9} />);
@@ -196,6 +587,14 @@ describe('Pages source UI', () => {
expect(PagesService.syncSource).toHaveBeenCalledWith(9, {});
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
});
expect(screen.getByRole('button', { name: /同步并发布/ })).toBeDisabled();
await waitFor(
() => {
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledTimes(2);
},
{ timeout: 3_500 },
);
});
it('shows the actual project entry and no one-off URL upload tab', () => {
@@ -224,4 +623,30 @@ describe('Pages source UI', () => {
expect(await screen.findByText('部署历史暂时不可用')).toBeVisible();
expect(screen.queryByText('暂无部署')).not.toBeInTheDocument();
});
it('renders the immutable GitHub deployment provenance in Chinese', async () => {
const deployment: PagesDeployment = {
id: 51,
project_id: 9,
deployment_number: 7,
checksum: 'd'.repeat(64),
status: 'active',
file_count: 12,
total_size: 4_096,
created_by: 'user:1',
source_type: 'github_release',
source_label: 'v1.2.3',
trigger_type: 'manual_sync',
created_at: '2026-07-19T10:00:00Z',
activated_at: '2026-07-19T10:00:01Z',
};
vi.mocked(PagesService.listDeployments).mockResolvedValue([deployment]);
renderWithQuery(
<DeploymentHistory projectId={9} activeDeploymentId={51} />,
);
expect(await screen.findByText('GitHub · v1.2.3 · 手动同步')).toBeVisible();
expect(screen.getByText('当前生产部署')).toBeVisible();
});
});
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import {
validGitHubAssetName,
validGitHubReleaseTag,
validGitHubRepositoryURL,
} from '@/app/(main)/pages/detail/components/pages-source-validation';
describe('Pages GitHub source validation', () => {
it('accepts only canonical public GitHub repository URLs', () => {
expect(validGitHubRepositoryURL('https://github.com/acme/site')).toBe(true);
expect(validGitHubRepositoryURL('https://GitHub.com/acme/site.git')).toBe(
true,
);
const invalid = [
'http://github.com/acme/site',
'https://github.com//acme/site',
'https://github.com/acme/site/',
'https://github.com/acme/site/extra',
'https://github.com/acme/./site',
'https://github.com/acme/../site',
'https://github.com/acme/%73ite',
'https://github.com/acme/site?token=secret',
'https://github.com:443/acme/site',
String.raw`https://github.com/acme\site`,
];
for (const value of invalid) {
expect(validGitHubRepositoryURL(value), value).toBe(false);
}
});
it('mirrors Git ref rules while preserving legal release tag characters', () => {
const valid = [
'@',
'release/v1#stable&channel=prod',
'foo.LOCK',
'中文/发布=稳定',
];
for (const value of valid) {
expect(validGitHubReleaseTag(value), value).toBe(true);
}
const invalid = [
'',
'release v1',
'release~v1',
'release^v1',
'release:v1',
'release?v1',
'release*v1',
'release[v1',
String.raw`release\v1`,
'release..v1',
'release@{v1',
'release//v1',
'/release',
'release/',
'release.',
'.release',
'release/.candidate',
'release/v1.lock',
'release\nsecret',
'release\u2028secret',
'release\u202esecret',
'a'.repeat(256),
];
for (const value of invalid) {
expect(validGitHubReleaseTag(value), value).toBe(false);
}
});
it('preserves exact legal asset names and rejects path or display controls', () => {
const valid = [
'dist.zip',
'dist?channel=stable&part#1.zip',
' dist.zip ',
'build=production.zip',
];
for (const value of valid) {
expect(validGitHubAssetName(value), value).toBe(true);
}
const invalid = [
'',
'.',
'..',
'../dist.zip',
String.raw`dir\dist.zip`,
'dist\n.zip',
'dist\u2028.zip',
'dist\u202e.zip',
'a'.repeat(256),
'\ud800',
];
for (const value of invalid) {
expect(validGitHubAssetName(value), value).toBe(false);
}
});
});