'use client'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Download, Pencil, RefreshCw, 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 { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, } from '@/components/ui/card'; 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 { deploymentsQueryKey, projectQueryKey, projectsQueryKey, sourceQueryKey, } from '../../components/pages-utils'; 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; const LATEST_POLL_INTERVAL = 5 * 60 * 1_000; const LATEST_NEAR_DUE_POLL_INTERVAL = 30_000; const LATEST_OVERDUE_MAX_WAIT = 10 * 60 * 1_000; const SOURCE_STATUS: Record< PagesSourceStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline'; } > = { idle: { label: '空闲', variant: 'outline' }, checking: { label: '检查中', variant: 'secondary' }, update_available: { label: '有可用更新', variant: 'default' }, syncing: { label: '同步中', variant: 'secondary' }, failed: { label: '最近同步失败', variant: 'destructive' }, attention: { label: '需要确认', variant: 'destructive' }, }; interface ActiveAction { receipt: PagesSourceActionReceipt; startedAt: number; } export interface LatestSourceOverdueWindow { nextCheckAt: string; startedAt: number; } export interface LatestSourcePollingDecision { interval: number | false; overdueWindow: LatestSourceOverdueWindow | null; } export function getLatestSourceIdlePollingDecision( source: PagesSource | undefined, now: number, overdueWindow: LatestSourceOverdueWindow | null, ): LatestSourcePollingDecision { if ( source?.source_type !== 'github_release' || source.release_selector !== 'latest' ) { return { interval: false, overdueWindow: null }; } const nextCheckAt = source.next_check_at; const nextCheckTime = nextCheckAt ? Date.parse(nextCheckAt) : Number.NaN; if (!nextCheckAt || !Number.isFinite(nextCheckTime)) { return { interval: LATEST_POLL_INTERVAL, overdueWindow: null }; } const timeUntilCheck = nextCheckTime - now; if (timeUntilCheck > LATEST_POLL_INTERVAL) { return { interval: LATEST_POLL_INTERVAL, overdueWindow: null }; } if (timeUntilCheck > 0) { return { interval: LATEST_NEAR_DUE_POLL_INTERVAL, overdueWindow: null }; } const currentWindow = overdueWindow?.nextCheckAt === nextCheckAt ? overdueWindow : { nextCheckAt, startedAt: now }; if (now - currentWindow.startedAt >= LATEST_OVERDUE_MAX_WAIT) { return { interval: false, overdueWindow: currentWindow }; } return { interval: LATEST_NEAR_DUE_POLL_INTERVAL, overdueWindow: currentWindow, }; } function sourceDeploymentFingerprint(source: PagesSource) { if (source.source_type === 'manual') return '|'; return `${source.last_synced_at ?? ''}|${source.last_applied?.revision ?? ''}`; } function sourceActionLabel(action: PagesSourceActionReceipt['action']) { return action === 'check' ? '检查' : '同步并发布'; } export function PagesSourceCard({ projectId }: { projectId: number }) { const queryClient = useQueryClient(); const handledExecutionID = useRef(null); const sourcePollingStartedAt = useRef(null); const latestOverdueWindow = useRef(null); const sourceDeploymentState = useRef(undefined); const [dialogOpen, setDialogOpen] = useState(false); const [dialogMode, setDialogMode] = useState('manual'); const [activeAction, setActiveAction] = useState(null); const [actionTimedOut, setActionTimedOut] = useState(false); const [attentionDialogOpen, setAttentionDialogOpen] = useState(false); const sourceQuery = useQuery({ queryKey: sourceQueryKey(projectId), queryFn: () => PagesService.getSource(projectId), refetchInterval: (query) => { const source = query.state.data; if ( source && source.source_type !== 'manual' && (source.sync_status === 'checking' || source.sync_status === 'syncing') ) { sourcePollingStartedAt.current ??= Date.now(); latestOverdueWindow.current = null; return Date.now() - sourcePollingStartedAt.current < ACTION_MAX_WAIT ? ACTION_POLL_INTERVAL : false; } sourcePollingStartedAt.current = null; const decision = getLatestSourceIdlePollingDecision( source, Date.now(), latestOverdueWindow.current, ); latestOverdueWindow.current = decision.overdueWindow; return decision.interval; }, }); const invalidateSourceState = useCallback( () => Promise.all([ queryClient.invalidateQueries({ queryKey: sourceQueryKey(projectId) }), queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId), }), queryClient.invalidateQueries({ queryKey: deploymentsQueryKey(projectId), }), queryClient.invalidateQueries({ queryKey: ['openflare', 'pages', 'deployment-files', projectId], }), queryClient.invalidateQueries({ queryKey: projectsQueryKey }), ]), [projectId, queryClient], ); const executionQuery = useQuery({ queryKey: [ 'admin', 'task-execution', 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; const status = query.state.data?.status; return status === 'pending' || status === 'running' ? ACTION_POLL_INTERVAL : false; }, }); 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; const remaining = Math.max(0, ACTION_MAX_WAIT - elapsed); const timeout = window.setTimeout(() => setActionTimedOut(true), remaining); return () => window.clearTimeout(timeout); }, [actionTimedOut, activeAction]); useEffect(() => { const source = sourceQuery.data; if (!source) return; const fingerprint = sourceDeploymentFingerprint(source); const previousFingerprint = sourceDeploymentState.current; sourceDeploymentState.current = fingerprint; if ( previousFingerprint === undefined || previousFingerprint === fingerprint ) { return; } void invalidateSourceState(); }, [invalidateSourceState, sourceQuery.data]); useEffect(() => { const execution = executionQuery.data; if ( !activeAction || !execution || !['succeeded', 'failed'].includes(execution.status) ) { return; } if (handledExecutionID.current === execution.id) return; handledExecutionID.current = execution.id; void invalidateSourceState(); const actionLabel = sourceActionLabel(activeAction.receipt.action); if (execution.status === 'succeeded') { toast.success(`部署源${actionLabel}完成`); } else { toast.error(execution.error_message || `部署源${actionLabel}失败`); } setActiveAction(null); setActionTimedOut(false); }, [activeAction, executionQuery.data, invalidateSourceState]); 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: (payload: PagesSourceActionPayload) => PagesService.syncSource(projectId, payload), onSuccess: async (receipt) => { setAttentionDialogOpen(false); beginActionPolling(receipt); await queryClient.invalidateQueries({ queryKey: sourceQueryKey(projectId), }); toast.success('同步任务已提交'); }, onError: (error) => { toast.error(error instanceof Error ? error.message : '同步任务提交失败'); }, }); const source = sourceQuery.data; const executionBusy = activeAction !== null && (executionQuery.data?.status === undefined || executionQuery.data.status === 'pending' || executionQuery.data.status === 'running'); const sourceBusy = source?.source_type !== 'manual' && (source?.sync_status === 'checking' || source?.sync_status === 'syncing'); 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: 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 ( 部署源 加载来源配置... ); } if (sourceQuery.isError || !source) { return ( 部署源 来源配置与部署历史相互独立。 void sourceQuery.refetch()} /> ); } 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[effectiveSourceStatus ?? 'idle']; const attentionRevision = source.source_type === 'github_release' && source.sync_status === 'attention' ? source.last_seen : undefined; return ( <>
部署源 配置远端来源并同步发布;发布结果见部署历史。
{status ? ( {status.label} ) : ( 手动部署 )}
{source.source_type === 'manual' ? (

本地部署包

当前没有持久化远端来源。上传部署包后,再从部署历史显式激活。

) : source.source_type === 'remote_url' ? ( ) : ( )} {dispatchError ? ( ) : null} {executionQuery.isError ? ( void executionQuery.refetch()} /> ) : null} {actionTimedOut ? (
自动等待已停止,任务可能仍在后台运行。
) : null}
{source.source_type !== 'manual' ? ( <> {source.source_type === 'github_release' ? ( ) : null} ) : null}
{ if (!syncMutation.isPending) setAttentionDialogOpen(open); }} > 确认发布当前 GitHub revision 这将发布卡片当前显示的精确 revision:{' '} {attentionRevision?.revision ?? '当前 revision 已不可用,请刷新后重试'} 取消 { event.preventDefault(); if (!attentionRevision) return; syncMutation.mutate({ confirmed_revision: attentionRevision.revision, }); }} > {syncMutation.isPending ? ( ) : null} 确认并发布 ); }