@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.
@@ -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
- }