// Per-deploy-target card — shows status, build timer, history, cancel, deploy, copy URL, and error logs import { useState, useEffect, useCallback, useRef } from 'react' import { flushSync } from 'react-dom' import { Card, Box, Stack, Flex, Text, Button, Tooltip, Badge, Spinner, MenuButton, Menu, MenuItem, Code, useToast, } from '@sanity/ui' import { ClockIcon, TrashIcon, EllipsisVerticalIcon, LaunchIcon, CopyIcon, CheckmarkIcon, WarningOutlineIcon, ChevronDownIcon, ChevronUpIcon, EditIcon, SchemaIcon } from '@sanity/icons' import { listDeployments, cancelDeployment, triggerDeploy, getDeploymentEvents } from '../lib/api' import { parseHookUrl, isActiveState, formatDuration, timeAgo, shortSha, safeHref, projectHref, githubCommitHref } from '../lib/helpers' import { StatusBadge } from './StatusBadge' import { DeployHistory } from './DeployHistory' import type { DeployTarget, VercelDeployment } from '../types' const POLL_INTERVAL_MS = 5_000 const LABEL_WIDTH = 64 interface DeployItemProps { target: DeployTarget token: string onDelete: (target: DeployTarget) => void onEdit: (target: DeployTarget) => void } export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps) { const { projectId, hookId } = parseHookUrl(target.url) const toast = useToast() const [deployments, setDeployments] = useState([]) const [loadingInitial, setLoadingInitial] = useState(true) const [triggering, setTriggering] = useState(false) const [canceling, setCanceling] = useState(false) const [deployError, setDeployError] = useState(null) const [showHistory, setShowHistory] = useState(false) const [elapsed, setElapsed] = useState(0) const [copied, setCopied] = useState(false) const [showDetails, setShowDetails] = useState(false) const [showErrorLogs, setShowErrorLogs] = useState(false) const [errorLines, setErrorLines] = useState([]) const [loadingLogs, setLoadingLogs] = useState(false) const [logError, setLogError] = useState(null) const latest = deployments[0] const isActive = triggering || isActiveState(latest?.state) // ── Fetch deployments ────────────────────────────────────────────────────── const fetchDeployments = useCallback(async () => { if (!projectId || !hookId || !token) return try { const data = await listDeployments({ projectId, hookId, token, teamId: target.teamId }) setDeployments(data) } catch (err) { console.error('deploy-vercel-from-sanity: fetch error', err) } }, [projectId, hookId, token, target.teamId]) useEffect(() => { fetchDeployments().finally(() => setLoadingInitial(false)) }, [fetchDeployments]) useEffect(() => { if (!isActive) return const id = setInterval(fetchDeployments, POLL_INTERVAL_MS) return () => clearInterval(id) }, [isActive, fetchDeployments]) useEffect(() => { if (triggering && latest && latest.state !== undefined) setTriggering(false) }, [triggering, latest]) //── Deploy-complete toast ───────────────────────────────────────────────── const prevStateRef = useRef(undefined) useEffect(() => { const current = latest?.state const prev = prevStateRef.current if (prev && isActiveState(prev as never) && current && !isActiveState(current as never)) { if (current === 'READY') { toast.push({ status: 'success', title: `${target.name} deployed`, description: 'Build completed successfully' }) } else if (current === 'ERROR') { toast.push({ status: 'error', title: `${target.name} failed`, description: 'Build encountered an error — check error details' }) } else if (current === 'CANCELED') { toast.push({ status: 'warning', title: `${target.name} canceled`, description: 'Deployment was canceled' }) } } prevStateRef.current = current }, [latest?.state, target.name, toast]) useEffect(() => { setShowErrorLogs(false) setErrorLines([]) setLogError(null) }, [latest?.uid]) // ── Build timer ─────────────────────────────────────────────────────────── const timerRef = useRef | null>(null) useEffect(() => { if (isActive) { const start = latest?.created ?? Date.now() setElapsed(Math.floor((Date.now() - start) / 1000)) timerRef.current = setInterval(() => { setElapsed(Math.floor((Date.now() - start) / 1000)) }, 1000) } else { setElapsed(0) if (timerRef.current) clearInterval(timerRef.current) } return () => { if (timerRef.current) clearInterval(timerRef.current) } }, [isActive, latest?.created]) // ── Actions ─────────────────────────────────────────────────────────────── const deploy = useCallback(() => { flushSync(() => { setDeployError(null) setTriggering(true) }) void (async () => { try { await triggerDeploy(target.url) setTimeout(fetchDeployments, 2000) } catch (err) { setTriggering(false) setDeployError(err instanceof Error ? err.message : 'Deploy failed') } })() }, [target.url, fetchDeployments]) const cancel = useCallback(async () => { if (!latest?.uid) return setCanceling(true) try { await cancelDeployment({ deploymentId: latest.uid, token, teamId: target.teamId }) await fetchDeployments() } catch (err) { console.error('deploy-vercel-from-sanity: cancel error', err) } finally { setCanceling(false) } }, [latest?.uid, token, target.teamId, fetchDeployments]) const copyUrl = useCallback(() => { if (!latest?.url) return const fullUrl = `https://${latest.url}` navigator.clipboard.writeText(fullUrl).then(() => { setCopied(true) setTimeout(() => setCopied(false), 2000) }).catch(() => { // Clipboard API unavailable — surface the URL for manual copy window.prompt('Copy deployment URL:', fullUrl) }) }, [latest?.url]) const fetchErrorLogs = useCallback(async () => { if (!latest?.uid) return setLoadingLogs(true) setLogError(null) try { const events = await getDeploymentEvents({ deploymentId: latest.uid, token, teamId: target.teamId, }) const lines = events .filter(e => e.type === 'stderr' || e.type === 'stdout') .map(e => e.text ?? '') .filter(Boolean) .reverse() .slice(-30) setErrorLines(lines.length > 0 ? lines : ['No stderr or stdout was captured for this build. Open the full build log in Vercel for details.']) } catch (err) { setLogError(err instanceof Error ? err.message : 'Failed to load build logs') } finally { setLoadingLogs(false) } }, [latest?.uid, token, target.teamId]) const toggleErrorLogs = useCallback(() => { if (!showErrorLogs && errorLines.length === 0 && !logError) fetchErrorLogs() setShowErrorLogs(v => !v) }, [showErrorLogs, errorLines.length, logError, fetchErrorLogs]) // ── Derived display values ──────────────────────────────────────────────── const branch = latest?.meta?.githubCommitRef const commitMsg = latest?.meta?.githubCommitMessage?.split('\n')[0] const sha = shortSha(latest?.meta?.githubCommitSha) const fullSha = latest?.meta?.githubCommitSha const commitHref = githubCommitHref(latest?.meta) const creator = latest?.creator?.username const deployedAt = latest?.created ? timeAgo(latest.created) : null const vercelProjectUrl = projectHref(latest?.inspectorUrl) const isError = latest?.state === 'ERROR' return ( <> {/* ── Left: info column ──────────────────────────────────── */} {/* ── Title row: name + branch + status + menu ──────── */} {target.name} {branch && ( {branch} )} {token && !loadingInitial && ( <> {triggering ? ( Triggering… ) : ( )} {isActiveState(latest?.state) && (