@liiift-studio/deploy-vercel-from-sanity 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +171 -37
- package/dist/index.d.mts +54 -36
- package/dist/index.d.ts +54 -36
- package/dist/index.js +687 -468
- package/dist/index.mjs +538 -318
- package/package.json +14 -9
- package/proxy/.env.example +40 -0
- package/proxy/README.md +228 -0
- package/proxy/core.ts +283 -0
- package/proxy/nextjs-app-router/route.ts +129 -0
- package/src/compat/code.tsx +0 -29
- package/src/compat/index.ts +0 -14
- package/src/compat/menu.tsx +0 -225
- package/src/compat/primitives.tsx +0 -93
- package/src/compat/resolve.ts +0 -55
- package/src/compat/toast.tsx +0 -173
- package/src/compat/tooltip.tsx +0 -78
- package/src/components/DeployHistory.tsx +0 -160
- package/src/components/DeployItem.tsx +0 -596
- package/src/components/DeployTargetForm.tsx +0 -172
- package/src/components/DeployTool.tsx +0 -318
- package/src/components/StatusBadge.tsx +0 -23
- package/src/components/TokenSetup.tsx +0 -98
- package/src/icons.tsx +0 -78
- package/src/index.ts +0 -47
- package/src/lib/api.ts +0 -102
- package/src/lib/helpers.ts +0 -131
- package/src/schema/schemaIcon.tsx +0 -38
- package/src/schema/vercelConfig.ts +0 -34
- package/src/schema/vercelDeploy.ts +0 -49
- package/src/types.ts +0 -77
- package/src/version.ts +0 -2
|
@@ -1,596 +0,0 @@
|
|
|
1
|
-
// Per-deploy-target card — shows status, build timer, history, cancel, deploy, copy URL, and error logs
|
|
2
|
-
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
3
|
-
import { flushSync } from 'react-dom'
|
|
4
|
-
import { ActionMenu, Badge, Box, Button, Card, Code, Flex, Spinner, Stack, Text, Tooltip, useToast } from '../compat'
|
|
5
|
-
import {
|
|
6
|
-
ClockIcon, TrashIcon, EllipsisVerticalIcon, LaunchIcon,
|
|
7
|
-
CopyIcon, CheckmarkIcon, WarningOutlineIcon, ChevronDownIcon, ChevronUpIcon, EditIcon,
|
|
8
|
-
} from '../icons'
|
|
9
|
-
import { listDeployments, cancelDeployment, triggerDeploy, getDeploymentEvents } from '../lib/api'
|
|
10
|
-
import { parseHookUrl, isActiveState, formatDuration, timeAgo, shortSha, safeHref, projectHref, githubCommitHref, deploymentHref } from '../lib/helpers'
|
|
11
|
-
import { StatusBadge } from './StatusBadge'
|
|
12
|
-
import { DeployHistory } from './DeployHistory'
|
|
13
|
-
import type { DeployTarget, VercelDeployment, VercelDeployState } from '../types'
|
|
14
|
-
|
|
15
|
-
const POLL_INTERVAL_MS = 5_000
|
|
16
|
-
const LABEL_WIDTH = 64
|
|
17
|
-
/** Max time (ms) to hold the optimistic pending state before giving up and deferring to real API status */
|
|
18
|
-
const PENDING_TIMEOUT_MS = 60_000
|
|
19
|
-
|
|
20
|
-
interface DeployItemProps {
|
|
21
|
-
target: DeployTarget
|
|
22
|
-
token: string
|
|
23
|
-
onDelete: (target: DeployTarget) => void
|
|
24
|
-
onEdit: (target: DeployTarget) => void
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function DeployItem({ target, token, onDelete, onEdit }: DeployItemProps) {
|
|
28
|
-
const { projectId, hookId } = parseHookUrl(target.url)
|
|
29
|
-
const toast = useToast()
|
|
30
|
-
|
|
31
|
-
const [deployments, setDeployments] = useState<VercelDeployment[]>([])
|
|
32
|
-
const [loadingInitial, setLoadingInitial] = useState(true)
|
|
33
|
-
const [pendingSince, setPendingSince] = useState<number | null>(null)
|
|
34
|
-
const [canceling, setCanceling] = useState(false)
|
|
35
|
-
const [deployError, setDeployError] = useState<string | null>(null)
|
|
36
|
-
const [showHistory, setShowHistory] = useState(false)
|
|
37
|
-
const [elapsed, setElapsed] = useState(0)
|
|
38
|
-
const [copied, setCopied] = useState(false)
|
|
39
|
-
const [showDetails, setShowDetails] = useState(false)
|
|
40
|
-
const [showErrorLogs, setShowErrorLogs] = useState(false)
|
|
41
|
-
const [errorLines, setErrorLines] = useState<string[]>([])
|
|
42
|
-
const [loadingLogs, setLoadingLogs] = useState(false)
|
|
43
|
-
const [logError, setLogError] = useState<string | null>(null)
|
|
44
|
-
const [pollError, setPollError] = useState<string | null>(null)
|
|
45
|
-
|
|
46
|
-
/** uid of the deployment that was latest when Deploy was clicked — lets us tell the optimistic state apart from a genuinely new deployment */
|
|
47
|
-
const triggeredFromUidRef = useRef<string | undefined>(undefined)
|
|
48
|
-
/** Monotonic request id — a slower earlier poll must not overwrite a newer response. */
|
|
49
|
-
const requestSeqRef = useRef(0)
|
|
50
|
-
/** False once the card unmounts, so in-flight responses stop updating state. */
|
|
51
|
-
const mountedRef = useRef(true)
|
|
52
|
-
useEffect(() => () => { mountedRef.current = false }, [])
|
|
53
|
-
|
|
54
|
-
const latest = deployments[0]
|
|
55
|
-
/** True between the click and the API returning a new deployment — drives the optimistic "Queued" state */
|
|
56
|
-
const isPending = pendingSince !== null
|
|
57
|
-
const isActive = isPending || isActiveState(latest?.state)
|
|
58
|
-
|
|
59
|
-
// ── Fetch deployments ──────────────────────────────────────────────────────
|
|
60
|
-
const fetchDeployments = useCallback(async () => {
|
|
61
|
-
if (!projectId || !hookId || !token) return
|
|
62
|
-
const seq = ++requestSeqRef.current
|
|
63
|
-
try {
|
|
64
|
-
const data = await listDeployments({ projectId, hookId, token, teamId: target.teamId })
|
|
65
|
-
// Drop the response if a newer request has since been issued, or the card unmounted.
|
|
66
|
-
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
|
67
|
-
setDeployments(data)
|
|
68
|
-
setPollError(null)
|
|
69
|
-
} catch (err) {
|
|
70
|
-
if (seq !== requestSeqRef.current || !mountedRef.current) return
|
|
71
|
-
// Surfaced in the card rather than only logged — the README documents rate-limit
|
|
72
|
-
// and auth failures as visible, and a silently frozen card looks identical to an idle one.
|
|
73
|
-
setPollError(err instanceof Error ? err.message : 'Could not reach the Vercel API')
|
|
74
|
-
console.error('Deploy-vercel-from-sanity: fetch error', err)
|
|
75
|
-
}
|
|
76
|
-
}, [projectId, hookId, token, target.teamId])
|
|
77
|
-
|
|
78
|
-
useEffect(() => {
|
|
79
|
-
fetchDeployments().finally(() => setLoadingInitial(false))
|
|
80
|
-
}, [fetchDeployments])
|
|
81
|
-
|
|
82
|
-
useEffect(() => {
|
|
83
|
-
if (!isActive) return
|
|
84
|
-
const id = setInterval(fetchDeployments, POLL_INTERVAL_MS)
|
|
85
|
-
return () => clearInterval(id)
|
|
86
|
-
}, [isActive, fetchDeployments])
|
|
87
|
-
|
|
88
|
-
// Hand off from the optimistic state only once the API returns a deployment
|
|
89
|
-
// that is not the one which was already latest when Deploy was clicked.
|
|
90
|
-
useEffect(() => {
|
|
91
|
-
if (!isPending) return
|
|
92
|
-
if (latest?.uid && latest.uid !== triggeredFromUidRef.current) setPendingSince(null)
|
|
93
|
-
}, [isPending, latest?.uid])
|
|
94
|
-
|
|
95
|
-
// Safety net — never strand the card in the optimistic state if the new
|
|
96
|
-
// deployment never appears (hook accepted but nothing was queued).
|
|
97
|
-
useEffect(() => {
|
|
98
|
-
if (!isPending) return
|
|
99
|
-
const id = setTimeout(() => setPendingSince(null), PENDING_TIMEOUT_MS)
|
|
100
|
-
return () => clearTimeout(id)
|
|
101
|
-
}, [isPending])
|
|
102
|
-
|
|
103
|
-
//── Deploy-complete toast ─────────────────────────────────────────────────
|
|
104
|
-
const prevStateRef = useRef<VercelDeployState | undefined>(undefined)
|
|
105
|
-
useEffect(() => {
|
|
106
|
-
const current = latest?.state
|
|
107
|
-
const prev = prevStateRef.current
|
|
108
|
-
if (prev && isActiveState(prev) && current && !isActiveState(current)) {
|
|
109
|
-
if (current === 'READY') {
|
|
110
|
-
toast.push({ status: 'success', title: `${target.name} deployed`, description: 'Build completed successfully' })
|
|
111
|
-
} else if (current === 'ERROR') {
|
|
112
|
-
toast.push({ status: 'error', title: `${target.name} failed`, description: 'Build encountered an error — check error details' })
|
|
113
|
-
} else if (current === 'CANCELED') {
|
|
114
|
-
toast.push({ status: 'warning', title: `${target.name} canceled`, description: 'Deployment was canceled' })
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
prevStateRef.current = current
|
|
118
|
-
}, [latest?.state, target.name, toast])
|
|
119
|
-
|
|
120
|
-
useEffect(() => {
|
|
121
|
-
setShowErrorLogs(false)
|
|
122
|
-
setErrorLines([])
|
|
123
|
-
setLogError(null)
|
|
124
|
-
}, [latest?.uid])
|
|
125
|
-
|
|
126
|
-
// ── Build timer ───────────────────────────────────────────────────────────
|
|
127
|
-
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
|
128
|
-
useEffect(() => {
|
|
129
|
-
if (isActive) {
|
|
130
|
-
// While optimistic, count from the click; once real, count from the deployment's own timestamp
|
|
131
|
-
const start = pendingSince ?? latest?.created ?? Date.now()
|
|
132
|
-
setElapsed(Math.floor((Date.now() - start) / 1000))
|
|
133
|
-
timerRef.current = setInterval(() => {
|
|
134
|
-
setElapsed(Math.floor((Date.now() - start) / 1000))
|
|
135
|
-
}, 1000)
|
|
136
|
-
} else {
|
|
137
|
-
setElapsed(0)
|
|
138
|
-
if (timerRef.current) clearInterval(timerRef.current)
|
|
139
|
-
}
|
|
140
|
-
return () => { if (timerRef.current) clearInterval(timerRef.current) }
|
|
141
|
-
}, [isActive, pendingSince, latest?.created])
|
|
142
|
-
|
|
143
|
-
// ── Actions ───────────────────────────────────────────────────────────────
|
|
144
|
-
const deploy = useCallback(() => {
|
|
145
|
-
// Paint the optimistic "Queued" state in the same frame as the click,
|
|
146
|
-
// before the hook request is even sent
|
|
147
|
-
flushSync(() => {
|
|
148
|
-
setDeployError(null)
|
|
149
|
-
triggeredFromUidRef.current = latest?.uid
|
|
150
|
-
setPendingSince(Date.now())
|
|
151
|
-
})
|
|
152
|
-
void (async () => {
|
|
153
|
-
try {
|
|
154
|
-
await triggerDeploy(target.url)
|
|
155
|
-
setTimeout(fetchDeployments, 2000)
|
|
156
|
-
} catch (err) {
|
|
157
|
-
setPendingSince(null)
|
|
158
|
-
setDeployError(err instanceof Error ? err.message : 'Deploy failed')
|
|
159
|
-
}
|
|
160
|
-
})()
|
|
161
|
-
}, [target.url, fetchDeployments, latest?.uid])
|
|
162
|
-
|
|
163
|
-
const cancel = useCallback(async () => {
|
|
164
|
-
if (!latest?.uid) return
|
|
165
|
-
setCanceling(true)
|
|
166
|
-
try {
|
|
167
|
-
await cancelDeployment({ deploymentId: latest.uid, token, teamId: target.teamId })
|
|
168
|
-
await fetchDeployments()
|
|
169
|
-
} catch (err) {
|
|
170
|
-
console.error('deploy-vercel-from-sanity: cancel error', err)
|
|
171
|
-
} finally {
|
|
172
|
-
setCanceling(false)
|
|
173
|
-
}
|
|
174
|
-
}, [latest?.uid, token, target.teamId, fetchDeployments])
|
|
175
|
-
|
|
176
|
-
const copyUrl = useCallback(() => {
|
|
177
|
-
if (!latest?.url) return
|
|
178
|
-
const fullUrl = deploymentHref(latest.url) ?? ''
|
|
179
|
-
navigator.clipboard.writeText(fullUrl).then(() => {
|
|
180
|
-
setCopied(true)
|
|
181
|
-
setTimeout(() => setCopied(false), 2000)
|
|
182
|
-
}).catch(() => {
|
|
183
|
-
// Clipboard API unavailable — surface the URL for manual copy
|
|
184
|
-
window.prompt('Copy deployment URL:', fullUrl)
|
|
185
|
-
})
|
|
186
|
-
}, [latest?.url])
|
|
187
|
-
|
|
188
|
-
const fetchErrorLogs = useCallback(async () => {
|
|
189
|
-
if (!latest?.uid) return
|
|
190
|
-
setLoadingLogs(true)
|
|
191
|
-
setLogError(null)
|
|
192
|
-
try {
|
|
193
|
-
const events = await getDeploymentEvents({
|
|
194
|
-
deploymentId: latest.uid,
|
|
195
|
-
token,
|
|
196
|
-
teamId: target.teamId,
|
|
197
|
-
})
|
|
198
|
-
const lines = events
|
|
199
|
-
.filter(e => e.type === 'stderr' || e.type === 'stdout')
|
|
200
|
-
.map(e => e.text ?? '')
|
|
201
|
-
.filter(Boolean)
|
|
202
|
-
.reverse()
|
|
203
|
-
.slice(-30)
|
|
204
|
-
setErrorLines(lines.length > 0 ? lines : ['No stderr or stdout was captured for this build. Open the full build log in Vercel for details.'])
|
|
205
|
-
} catch (err) {
|
|
206
|
-
setLogError(err instanceof Error ? err.message : 'Failed to load build logs')
|
|
207
|
-
} finally {
|
|
208
|
-
setLoadingLogs(false)
|
|
209
|
-
}
|
|
210
|
-
}, [latest?.uid, token, target.teamId])
|
|
211
|
-
|
|
212
|
-
const toggleErrorLogs = useCallback(() => {
|
|
213
|
-
if (!showErrorLogs && errorLines.length === 0 && !logError) fetchErrorLogs()
|
|
214
|
-
setShowErrorLogs(v => !v)
|
|
215
|
-
}, [showErrorLogs, errorLines.length, logError, fetchErrorLogs])
|
|
216
|
-
|
|
217
|
-
// ── Derived display values ────────────────────────────────────────────────
|
|
218
|
-
const branch = latest?.meta?.githubCommitRef
|
|
219
|
-
const commitMsg = latest?.meta?.githubCommitMessage?.split('\n')[0]
|
|
220
|
-
const sha = shortSha(latest?.meta?.githubCommitSha)
|
|
221
|
-
const fullSha = latest?.meta?.githubCommitSha
|
|
222
|
-
const commitHref = safeHref(githubCommitHref(latest?.meta) ?? undefined)
|
|
223
|
-
const creator = latest?.creator?.username
|
|
224
|
-
const deployedAt = latest?.created ? timeAgo(latest.created) : null
|
|
225
|
-
const vercelProjectUrl = projectHref(latest?.inspectorUrl)
|
|
226
|
-
const isError = latest?.state === 'ERROR'
|
|
227
|
-
|
|
228
|
-
return (
|
|
229
|
-
<>
|
|
230
|
-
<Card radius={2} shadow={1} tone="default">
|
|
231
|
-
<Flex align="stretch" className="dvfs-card-flex">
|
|
232
|
-
|
|
233
|
-
{/* ── Left: info column ──────────────────────────────────── */}
|
|
234
|
-
<Flex direction="column" flex={1} style={{ minWidth: 0 }}>
|
|
235
|
-
|
|
236
|
-
<Stack space={3} padding={3} style={{ flex: 1 }}>
|
|
237
|
-
|
|
238
|
-
{/* ── Title row: name + branch + status + menu ──────── */}
|
|
239
|
-
<Flex align="center" justify="space-between" gap={2}>
|
|
240
|
-
<Flex align="center" gap={2} style={{ minWidth: 0, flexWrap: 'wrap' }}>
|
|
241
|
-
<Text size={2} weight="semibold" style={{ flexShrink: 0 }}>{target.name}</Text>
|
|
242
|
-
{branch && (
|
|
243
|
-
<Badge tone="default" padding={2}>
|
|
244
|
-
<Flex align="center" gap={1}>
|
|
245
|
-
{branch}
|
|
246
|
-
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" style={{ marginLeft: "-0.1em", opacity: 0.5 }}>
|
|
247
|
-
<path d="M5 3.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0zm0 2.122a2.25 2.25 0 1 0-1.5 0v.878A2.25 2.25 0 0 0 5.75 8.5h1.5v2.128a2.251 2.251 0 1 0 1.5 0V8.5h1.5a2.25 2.25 0 0 0 2.25-2.25v-.878a2.25 2.25 0 1 0-1.5 0v.878a.75.75 0 0 1-.75.75h-4.5A.75.75 0 0 1 5 6.25v-.878zm3.75 7.378a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0zm3-8.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0z" />
|
|
248
|
-
</svg>
|
|
249
|
-
</Flex>
|
|
250
|
-
</Badge>
|
|
251
|
-
)}
|
|
252
|
-
{token && !loadingInitial && (
|
|
253
|
-
<>
|
|
254
|
-
{isPending ? (
|
|
255
|
-
<StatusBadge state="QUEUED" />
|
|
256
|
-
) : (
|
|
257
|
-
<StatusBadge state={latest?.state} />
|
|
258
|
-
)}
|
|
259
|
-
{isActiveState(latest?.state) && (
|
|
260
|
-
<Button
|
|
261
|
-
text="Cancel"
|
|
262
|
-
mode="ghost"
|
|
263
|
-
tone="critical"
|
|
264
|
-
loading={canceling}
|
|
265
|
-
disabled={canceling}
|
|
266
|
-
onClick={cancel}
|
|
267
|
-
style={{ cursor: 'pointer' }}
|
|
268
|
-
/>
|
|
269
|
-
)}
|
|
270
|
-
{isPending ? (
|
|
271
|
-
/* Optimistic — dimmed spinner only; no elapsed time until a real build exists */
|
|
272
|
-
<Spinner muted style={{ marginLeft: 4, opacity: 0.5 }} />
|
|
273
|
-
) : isActive ? (
|
|
274
|
-
<Flex align="center" gap={1}>
|
|
275
|
-
<Spinner muted style={{ marginLeft: 4 }} />
|
|
276
|
-
<Text size={1} muted>{formatDuration(elapsed)}</Text>
|
|
277
|
-
</Flex>
|
|
278
|
-
) : deployedAt ? (
|
|
279
|
-
<Text size={1} muted>{deployedAt}</Text>
|
|
280
|
-
) : null}
|
|
281
|
-
</>
|
|
282
|
-
)}
|
|
283
|
-
</Flex>
|
|
284
|
-
<ActionMenu
|
|
285
|
-
id={`menu-${target._id}`}
|
|
286
|
-
label={`Actions for ${target.name}`}
|
|
287
|
-
buttonIcon={EllipsisVerticalIcon}
|
|
288
|
-
items={[
|
|
289
|
-
{ text: 'Edit target', icon: EditIcon, onClick: () => onEdit(target) },
|
|
290
|
-
{ text: 'History', icon: ClockIcon, onClick: () => setShowHistory(true) },
|
|
291
|
-
...(safeHref(latest?.inspectorUrl)
|
|
292
|
-
? [{ text: 'Build logs', icon: LaunchIcon, href: safeHref(latest?.inspectorUrl)! }]
|
|
293
|
-
: []),
|
|
294
|
-
...(vercelProjectUrl
|
|
295
|
-
? [{ text: 'Open in Vercel', icon: LaunchIcon, href: vercelProjectUrl }]
|
|
296
|
-
: []),
|
|
297
|
-
...(!target.disableDeleteAction
|
|
298
|
-
? [{ text: 'Delete', icon: TrashIcon, tone: 'critical' as const, onClick: () => onDelete(target) }]
|
|
299
|
-
: []),
|
|
300
|
-
]}
|
|
301
|
-
/>
|
|
302
|
-
</Flex>
|
|
303
|
-
|
|
304
|
-
{/* ── Divider below title ────────────────────────────── */}
|
|
305
|
-
<hr style={{ border: 'none', borderTop: '1px solid currentColor', opacity: 0.1, margin: 0 }} />
|
|
306
|
-
|
|
307
|
-
{/* ── Status + metadata ──────────────────────────────── */}
|
|
308
|
-
{!token ? (
|
|
309
|
-
<Text size={1} muted>Connect a Vercel API token to see deployment status.</Text>
|
|
310
|
-
) : loadingInitial ? (
|
|
311
|
-
<Flex align="center" gap={2}>
|
|
312
|
-
<Spinner muted />
|
|
313
|
-
<Text size={1} muted>Loading…</Text>
|
|
314
|
-
</Flex>
|
|
315
|
-
) : (
|
|
316
|
-
<Stack space={2}>
|
|
317
|
-
|
|
318
|
-
{/* Metadata row */}
|
|
319
|
-
<Flex align="center" gap={2} wrap="wrap">
|
|
320
|
-
|
|
321
|
-
{/* Visit link + copy URL */}
|
|
322
|
-
{latest?.url && latest.state === 'READY' && (
|
|
323
|
-
<>
|
|
324
|
-
<a
|
|
325
|
-
href={deploymentHref(latest.url)}
|
|
326
|
-
target="_blank"
|
|
327
|
-
rel="noreferrer"
|
|
328
|
-
style={{ color: 'inherit' }}
|
|
329
|
-
>
|
|
330
|
-
<Flex align="center" gap={1}>
|
|
331
|
-
<Text size={1}>{latest.url}</Text>
|
|
332
|
-
</Flex>
|
|
333
|
-
</a>
|
|
334
|
-
</>
|
|
335
|
-
)}
|
|
336
|
-
|
|
337
|
-
{/* Commit SHA — links to GitHub if repo info available, tooltip shows full message */}
|
|
338
|
-
{sha && (
|
|
339
|
-
<Tooltip text={commitMsg ?? sha}>
|
|
340
|
-
{commitHref ? (
|
|
341
|
-
<a
|
|
342
|
-
href={commitHref}
|
|
343
|
-
target="_blank"
|
|
344
|
-
rel="noreferrer"
|
|
345
|
-
style={{ color: 'inherit', textDecoration: 'none' }}
|
|
346
|
-
>
|
|
347
|
-
<Text size={1} muted style={{ cursor: 'pointer', fontFamily: 'monospace' }}>
|
|
348
|
-
{sha}
|
|
349
|
-
</Text>
|
|
350
|
-
</a>
|
|
351
|
-
) : (
|
|
352
|
-
<Text size={1} muted style={{ cursor: 'default', fontFamily: 'monospace' }}>
|
|
353
|
-
{sha}
|
|
354
|
-
</Text>
|
|
355
|
-
)}
|
|
356
|
-
</Tooltip>
|
|
357
|
-
)}
|
|
358
|
-
|
|
359
|
-
{/* Creator */}
|
|
360
|
-
{creator && <Text size={1} muted>by {creator}</Text>}
|
|
361
|
-
|
|
362
|
-
{/* Build duration — only shown when READY and ready timestamp is available */}
|
|
363
|
-
{latest?.state === 'READY' && latest.ready && latest.created && (
|
|
364
|
-
<Text size={1} muted>Took {formatDuration(Math.floor((latest.ready - latest.created) / 1000))} to build</Text>
|
|
365
|
-
)}
|
|
366
|
-
|
|
367
|
-
{/* Visit link + copy URL */}
|
|
368
|
-
{latest?.url && latest.state === 'READY' && (
|
|
369
|
-
<>
|
|
370
|
-
<Tooltip text={copied ? 'Copied!' : 'Copy URL'}>
|
|
371
|
-
<Button
|
|
372
|
-
mode="ghost"
|
|
373
|
-
icon={copied ? CheckmarkIcon : CopyIcon}
|
|
374
|
-
padding={1}
|
|
375
|
-
tone={copied ? 'positive' : 'default'}
|
|
376
|
-
onClick={copyUrl}
|
|
377
|
-
style={{ cursor: 'pointer' }}
|
|
378
|
-
/>
|
|
379
|
-
</Tooltip>
|
|
380
|
-
</>
|
|
381
|
-
)}
|
|
382
|
-
</Flex>
|
|
383
|
-
|
|
384
|
-
<Flex align="center" gap={2} wrap="wrap">
|
|
385
|
-
{/* Commit message */}
|
|
386
|
-
{commitMsg && (
|
|
387
|
-
<Text
|
|
388
|
-
size={0}
|
|
389
|
-
muted
|
|
390
|
-
style={{ fontStyle: 'italic'}}
|
|
391
|
-
>
|
|
392
|
-
{commitMsg}
|
|
393
|
-
</Text>
|
|
394
|
-
)}
|
|
395
|
-
|
|
396
|
-
{/* Error expansion */}
|
|
397
|
-
{isError && (
|
|
398
|
-
<Stack space={2}>
|
|
399
|
-
<Button
|
|
400
|
-
aria-expanded={showErrorLogs}
|
|
401
|
-
text={showErrorLogs ? 'Hide error details' : 'Show error details'}
|
|
402
|
-
mode="ghost"
|
|
403
|
-
tone="critical"
|
|
404
|
-
icon={WarningOutlineIcon}
|
|
405
|
-
fontSize={1}
|
|
406
|
-
padding={2}
|
|
407
|
-
onClick={toggleErrorLogs}
|
|
408
|
-
style={{ alignSelf: 'flex-start', cursor: 'pointer' }}
|
|
409
|
-
/>
|
|
410
|
-
{showErrorLogs && (
|
|
411
|
-
<Card tone="critical" radius={2} padding={3}>
|
|
412
|
-
{loadingLogs && (
|
|
413
|
-
<Flex align="center" gap={2}>
|
|
414
|
-
<Spinner muted />
|
|
415
|
-
<Text size={1} muted>Loading logs…</Text>
|
|
416
|
-
</Flex>
|
|
417
|
-
)}
|
|
418
|
-
{logError && (
|
|
419
|
-
<Stack space={2}>
|
|
420
|
-
<Text size={1}>{logError}</Text>
|
|
421
|
-
{safeHref(latest?.inspectorUrl) && (
|
|
422
|
-
<a href={safeHref(latest?.inspectorUrl)} target="_blank" rel="noreferrer" style={{ color: 'inherit' }}>
|
|
423
|
-
<Flex align="center" gap={1}>
|
|
424
|
-
<LaunchIcon />
|
|
425
|
-
<Text size={1}>View full logs in Vercel</Text>
|
|
426
|
-
</Flex>
|
|
427
|
-
</a>
|
|
428
|
-
)}
|
|
429
|
-
</Stack>
|
|
430
|
-
)}
|
|
431
|
-
{!loadingLogs && !logError && errorLines.length > 0 && (
|
|
432
|
-
<Stack space={2}>
|
|
433
|
-
<Box style={{ maxHeight: 240, overflowY: 'auto', fontFamily: 'monospace', fontSize: 13, lineHeight: 1.6 }}>
|
|
434
|
-
{errorLines.map((line, i) => (
|
|
435
|
-
<Code key={i} style={{ display: 'block', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}>
|
|
436
|
-
{line}
|
|
437
|
-
</Code>
|
|
438
|
-
))}
|
|
439
|
-
</Box>
|
|
440
|
-
{safeHref(latest?.inspectorUrl) && (
|
|
441
|
-
<a href={safeHref(latest?.inspectorUrl)} target="_blank" rel="noreferrer" style={{ color: 'inherit' }}>
|
|
442
|
-
<Flex align="center" gap={1}>
|
|
443
|
-
<LaunchIcon />
|
|
444
|
-
<Text size={1}>View full logs in Vercel</Text>
|
|
445
|
-
</Flex>
|
|
446
|
-
</a>
|
|
447
|
-
)}
|
|
448
|
-
</Stack>
|
|
449
|
-
)}
|
|
450
|
-
</Card>
|
|
451
|
-
)}
|
|
452
|
-
</Stack>
|
|
453
|
-
)}
|
|
454
|
-
|
|
455
|
-
{/* Trigger error */}
|
|
456
|
-
{deployError && (
|
|
457
|
-
<Card tone="critical" padding={2} radius={2}>
|
|
458
|
-
<Text size={1}>{deployError}</Text>
|
|
459
|
-
</Card>
|
|
460
|
-
)}
|
|
461
|
-
</Flex>
|
|
462
|
-
</Stack>
|
|
463
|
-
)}
|
|
464
|
-
|
|
465
|
-
{/* Polling failures were previously logged only, so a card that had stopped
|
|
466
|
-
updating looked identical to an idle one. */}
|
|
467
|
-
{pollError && (
|
|
468
|
-
<Card tone="caution" padding={3} radius={2} role="status">
|
|
469
|
-
<Flex align="center" gap={2}>
|
|
470
|
-
<WarningOutlineIcon aria-hidden="true" />
|
|
471
|
-
<Text size={1}>Status updates paused — {pollError}</Text>
|
|
472
|
-
</Flex>
|
|
473
|
-
</Card>
|
|
474
|
-
)}
|
|
475
|
-
|
|
476
|
-
</Stack>
|
|
477
|
-
|
|
478
|
-
{/* ── Details accordion — flush to left/bottom/right ─────── */}
|
|
479
|
-
<Box>
|
|
480
|
-
<Button
|
|
481
|
-
mode="ghost"
|
|
482
|
-
iconRight={showDetails ? ChevronUpIcon : ChevronDownIcon}
|
|
483
|
-
aria-expanded={showDetails}
|
|
484
|
-
text="Details"
|
|
485
|
-
fontSize={0}
|
|
486
|
-
padding={3}
|
|
487
|
-
onClick={() => setShowDetails(v => !v)}
|
|
488
|
-
style={{ width: '100%', justifyContent: 'flex-start', borderRadius: 0, cursor: 'pointer' }}
|
|
489
|
-
/>
|
|
490
|
-
{showDetails && (
|
|
491
|
-
<Card tone="primary" padding={3} className="dvfs-accordion-content" style={{ borderRadius: 0, borderTop: '1px solid rgba(128,128,128,0.15)' }}>
|
|
492
|
-
<Stack space={2}>
|
|
493
|
-
<Flex gap={2} align="center">
|
|
494
|
-
<Text size={0} muted weight="semibold" style={{ minWidth: LABEL_WIDTH }}>Project</Text>
|
|
495
|
-
<Text size={0} muted style={{ fontFamily: 'monospace' }}>{projectId || '—'}</Text>
|
|
496
|
-
</Flex>
|
|
497
|
-
<Flex gap={2} align="center">
|
|
498
|
-
<Text size={0} muted weight="semibold" style={{ minWidth: LABEL_WIDTH }}>Hook</Text>
|
|
499
|
-
<Text size={0} muted style={{ fontFamily: 'monospace' }}>{hookId || '—'}</Text>
|
|
500
|
-
</Flex>
|
|
501
|
-
{target.teamId && (
|
|
502
|
-
<Flex gap={2} align="center">
|
|
503
|
-
<Text size={0} muted weight="semibold" style={{ minWidth: LABEL_WIDTH }}>Team</Text>
|
|
504
|
-
<Text size={0} muted style={{ fontFamily: 'monospace' }}>{target.teamId}</Text>
|
|
505
|
-
</Flex>
|
|
506
|
-
)}
|
|
507
|
-
{fullSha && (
|
|
508
|
-
<Flex gap={2} align="flex-start">
|
|
509
|
-
<Text size={0} muted weight="semibold" style={{ minWidth: LABEL_WIDTH }}>Commit</Text>
|
|
510
|
-
<Text size={0} muted style={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>{fullSha}</Text>
|
|
511
|
-
</Flex>
|
|
512
|
-
)}
|
|
513
|
-
{latest?.meta?.githubCommitAuthorName && (
|
|
514
|
-
<Flex gap={2} align="center">
|
|
515
|
-
<Text size={0} muted weight="semibold" style={{ minWidth: LABEL_WIDTH }}>Author</Text>
|
|
516
|
-
<Text size={0} muted>{latest.meta.githubCommitAuthorName}</Text>
|
|
517
|
-
</Flex>
|
|
518
|
-
)}
|
|
519
|
-
{branch && (
|
|
520
|
-
<Flex gap={2} align="center">
|
|
521
|
-
<Text size={0} muted weight="semibold" style={{ minWidth: LABEL_WIDTH }}>Branch</Text>
|
|
522
|
-
<Text size={0} muted style={{ fontFamily: 'monospace' }}>{branch}</Text>
|
|
523
|
-
</Flex>
|
|
524
|
-
)}
|
|
525
|
-
{latest?.uid && (
|
|
526
|
-
<Flex gap={2} align="center">
|
|
527
|
-
<Text size={0} muted weight="semibold" style={{ minWidth: LABEL_WIDTH }}>Deploy ID</Text>
|
|
528
|
-
<Text size={0} muted style={{ fontFamily: 'monospace' }}>{latest.uid}</Text>
|
|
529
|
-
</Flex>
|
|
530
|
-
)}
|
|
531
|
-
{latest?.url && (
|
|
532
|
-
<Flex gap={2} align="flex-start">
|
|
533
|
-
<Text size={0} muted weight="semibold" style={{ minWidth: LABEL_WIDTH }}>URL</Text>
|
|
534
|
-
<Text size={0} muted style={{ fontFamily: 'monospace', wordBreak: 'break-all' }}>{latest.url}</Text>
|
|
535
|
-
</Flex>
|
|
536
|
-
)}
|
|
537
|
-
{safeHref(latest?.inspectorUrl) && (
|
|
538
|
-
<Flex gap={2} align="center">
|
|
539
|
-
<Text size={0} muted weight="semibold" style={{ minWidth: LABEL_WIDTH }}>Inspector</Text>
|
|
540
|
-
<a href={safeHref(latest?.inspectorUrl)!} target="_blank" rel="noreferrer" style={{ color: 'inherit' }}>
|
|
541
|
-
<Flex align="center" gap={1}>
|
|
542
|
-
<Text size={0} muted style={{ fontFamily: 'monospace' }}>Open in Vercel</Text>
|
|
543
|
-
<LaunchIcon style={{ width: 10, height: 10 }} />
|
|
544
|
-
</Flex>
|
|
545
|
-
</a>
|
|
546
|
-
</Flex>
|
|
547
|
-
)}
|
|
548
|
-
{latest?.created && (
|
|
549
|
-
<Flex gap={2} align="center">
|
|
550
|
-
<Text size={0} muted weight="semibold" style={{ minWidth: LABEL_WIDTH }}>Created</Text>
|
|
551
|
-
<Text size={0} muted>{new Date(latest.created).toLocaleString()}</Text>
|
|
552
|
-
</Flex>
|
|
553
|
-
)}
|
|
554
|
-
</Stack>
|
|
555
|
-
</Card>
|
|
556
|
-
)}
|
|
557
|
-
</Box>
|
|
558
|
-
|
|
559
|
-
</Flex>
|
|
560
|
-
|
|
561
|
-
{/* ── Right: action buttons — stretch full card height ── */}
|
|
562
|
-
<Flex
|
|
563
|
-
direction="column"
|
|
564
|
-
gap={2}
|
|
565
|
-
className="dvfs-deploy-col"
|
|
566
|
-
style={{ flexShrink: 0, alignSelf: 'stretch' }}
|
|
567
|
-
>
|
|
568
|
-
<Button
|
|
569
|
-
text="Deploy"
|
|
570
|
-
tone="primary"
|
|
571
|
-
loading={isPending}
|
|
572
|
-
disabled={isActive}
|
|
573
|
-
onClick={deploy}
|
|
574
|
-
style={{
|
|
575
|
-
flex: 1,
|
|
576
|
-
borderRadius: 0,
|
|
577
|
-
borderTopRightRadius: 3,
|
|
578
|
-
borderBottomRightRadius: 3,
|
|
579
|
-
cursor: 'pointer',
|
|
580
|
-
}}
|
|
581
|
-
/>
|
|
582
|
-
</Flex>
|
|
583
|
-
|
|
584
|
-
</Flex>
|
|
585
|
-
</Card>
|
|
586
|
-
|
|
587
|
-
{showHistory && (
|
|
588
|
-
<DeployHistory
|
|
589
|
-
target={target}
|
|
590
|
-
token={token}
|
|
591
|
-
onClose={() => setShowHistory(false)}
|
|
592
|
-
/>
|
|
593
|
-
)}
|
|
594
|
-
</>
|
|
595
|
-
)
|
|
596
|
-
}
|