'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 { useTranslations } from 'next-intl'; 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, { labelKey: string; variant: 'default' | 'secondary' | 'destructive' | 'outline'; } > = { idle: { labelKey: 'source.status.idle', variant: 'outline' }, checking: { labelKey: 'source.status.checking', variant: 'secondary' }, update_available: { labelKey: 'source.status.update_available', variant: 'default', }, syncing: { labelKey: 'source.status.syncing', variant: 'secondary' }, failed: { labelKey: 'source.status.failed', variant: 'destructive' }, attention: { labelKey: 'source.status.attention', 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 ?? ''}`; } export function PagesSourceCard({ projectId }: { projectId: number }) { const t = useTranslations('pages'); const tCommon = useTranslations('common'); 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(t('source.missingExecutionId')); 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 = activeAction.receipt.action === 'check' ? t('source.check') : t('source.syncPublish'); if (execution.status === 'succeeded') { toast.success(t('source.actionDone', { action: actionLabel })); } else { toast.error( execution.error_message || t('source.actionFailed', { action: actionLabel }), ); } setActiveAction(null); setActionTimedOut(false); }, [activeAction, executionQuery.data, invalidateSourceState, t]); const checkMutation = useMutation({ mutationFn: () => PagesService.checkSource(projectId), onSuccess: async (receipt) => { beginActionPolling(receipt); await queryClient.invalidateQueries({ queryKey: sourceQueryKey(projectId), }); toast.success(t('source.checkQueued')); }, onError: (error) => { toast.error(error instanceof Error ? error.message : t('source.checkQueueFailed')); }, }); 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(t('source.syncQueued')); }, onError: (error) => { toast.error(error instanceof Error ? error.message : t('source.syncQueueFailed')); }, }); 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 ( {t('source.title')} {t('source.loadingDesc')} ); } if (sourceQuery.isError || !source) { return ( {t('source.title')} {t('source.independentDesc')} 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 ( <>
{t('source.title')} {t('source.configDesc')}
{status ? ( {t(status.labelKey)} ) : ( {t('source.manualBadge')} )}
{source.source_type === 'manual' ? (

{t('source.localPackage')}

{t('source.noRemote')}

) : source.source_type === 'remote_url' ? ( ) : ( )} {dispatchError ? ( ) : null} {executionQuery.isError ? ( void executionQuery.refetch()} /> ) : null} {actionTimedOut ? (
{t('source.waitStopped')}
) : null}
{source.source_type !== 'manual' ? ( <> {source.source_type === 'github_release' ? ( ) : null} ) : null}
{ if (!syncMutation.isPending) setAttentionDialogOpen(open); }} > {t('source.confirmTitle')} {t('source.confirmLead')}{' '} {attentionRevision?.revision ?? t('source.revisionUnavailable')} {tCommon('cancel')} { event.preventDefault(); if (!attentionRevision) return; syncMutation.mutate({ confirmed_revision: attentionRevision.revision, }); }} > {syncMutation.isPending ? ( ) : null} {t('source.confirmPublish')} ); }