mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 07:36:37 +08:00
feat(pages): 支持 GitHub Release 部署源
增加 latest/tag 手动检查与同步、ETag 与限流退避、资源替换确认,以及对应的前端来源管理和部署来源展示。
This commit is contained in:
@@ -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'),
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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`,
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user