Files
floci-ui/packages/frontend/src/components/CodeBuildPanel.tsx
T
2026-10-01 09:18:04 -04:00

47 lines
7.3 KiB
TypeScript

import {useEffect, useState} from 'react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {ExternalLink, Play, Plus, RefreshCw, X} from 'lucide-react'
import {getCodeBuild, listCodeBuilds, startCodeBuild, type CodeBuildBuild, type CodeBuildEnvironmentVariableOverride} from '@/api/cloudProxyClient'
import {formatDateTime} from '@/lib/format'
import type {CloudResource} from '@/types/resource'
interface CodeBuildPanelProps { resource?: CloudResource; runtimeReachable: boolean }
export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps) {
const client = useQueryClient()
const [startOpen, setStartOpen] = useState(false)
const [overrides, setOverrides] = useState<CodeBuildEnvironmentVariableOverride[]>([])
const [selectedId, setSelectedId] = useState<string>()
const enabled = Boolean(resource && runtimeReachable)
const buildsKey = ['codebuild-builds', resource?.id]
const builds = useQuery({queryKey: buildsKey, queryFn: ({signal}) => listCodeBuilds(resource!.id, signal), enabled, refetchInterval: (query) => query.state.data?.some((build) => active(build.buildStatus)) ? 2_000 : false})
const detail = useQuery({queryKey: ['codebuild-build', resource?.id, selectedId], queryFn: ({signal}) => getCodeBuild(resource!.id, selectedId!, signal), enabled: enabled && Boolean(selectedId), refetchInterval: (query) => active(query.state.data?.buildStatus) ? 2_000 : false})
const start = useMutation({mutationFn: () => startCodeBuild(resource!.id, {environmentVariablesOverride: overrides.length ? overrides : undefined}), onSuccess: (build) => { setSelectedId(build.id); setStartOpen(false); setOverrides([]); void client.invalidateQueries({queryKey: buildsKey}) }})
useEffect(() => { setSelectedId(undefined); setStartOpen(false); setOverrides([]) }, [resource?.id])
if (!resource) return null
const selected = detail.data ?? builds.data?.find((build) => build.id === selectedId)
return <section className="table-panel">
<div className="dynamic-stage-header"><div><p className="eyebrow">Builds</p><h3>Run and inspect builds</h3></div><div className="header-actions"><button className="button" type="button" disabled={!enabled || builds.isFetching} onClick={() => builds.refetch()}><RefreshCw size={13}/>{builds.isFetching ? 'Refreshing' : 'Refresh'}</button><button className="button primary" type="button" disabled={!enabled} onClick={() => setStartOpen(true)}><Play size={13}/>Start build</button></div></div>
{startOpen && <StartBuildDialog overrides={overrides} pending={start.isPending} error={start.error} onChange={setOverrides} onCancel={() => setStartOpen(false)} onStart={() => start.mutate()}/>}
{builds.isError && <p className="error-text compact-text">{message(builds.error, 'Unable to load builds.')}</p>}
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Build history</h4><span>{builds.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Build</th><th>Status</th><th>Phase</th><th>Started</th></tr></thead><tbody>{(builds.data ?? []).map((build) => <tr key={build.id} onClick={() => setSelectedId(build.id)} style={{cursor: 'pointer'}}><td><code>#{build.buildNumber ?? build.id}</code></td><td>{build.buildStatus ?? '-'}</td><td>{build.currentPhase ?? '-'}</td><td>{formatDateTime(build.startTime) ?? '-'}</td></tr>)}</tbody></table></div>{!builds.isLoading && builds.data?.length === 0 && <p className="muted compact-text">No builds yet.</p>}</div>
{selectedId && <BuildDetail build={selected} loading={detail.isLoading} error={detail.error}/>}
</section>
}
function StartBuildDialog({overrides, pending, error, onChange, onCancel, onStart}: {overrides: CodeBuildEnvironmentVariableOverride[]; pending: boolean; error: unknown; onChange: (values: CodeBuildEnvironmentVariableOverride[]) => void; onCancel: () => void; onStart: () => void}) {
return <div className="workflow-confirmation" role="dialog" aria-labelledby="start-build-title"><div className="dynamic-stage-header"><div><strong id="start-build-title">Start build</strong><p className="muted compact-text">Environment variable overrides apply only to this build.</p></div><button className="icon-btn" type="button" aria-label="Close" disabled={pending} onClick={onCancel}><X size={14}/></button></div>{overrides.map((override, index) => <div className="input-row" key={index}><input className="input" aria-label={`Variable name ${index + 1}`} placeholder="Name" value={override.name} onChange={(event) => onChange(replace(overrides, index, {...override, name: event.target.value}))}/><input className="input" aria-label={`Variable value ${index + 1}`} placeholder="Value" value={override.value} onChange={(event) => onChange(replace(overrides, index, {...override, value: event.target.value}))}/><button className="button compact" type="button" disabled={pending} onClick={() => onChange(overrides.filter((_, item) => item !== index))}>Remove</button></div>)}<button className="button compact" type="button" disabled={pending} onClick={() => onChange([...overrides, {name: '', value: '', type: 'PLAINTEXT'}])}><Plus size={13}/>Add override</button>{Boolean(error) && <p className="error-text compact-text">{message(error, 'Unable to start build.')}</p>}<div><button className="button primary compact" type="button" disabled={pending || overrides.some((item) => !item.name.trim())} onClick={onStart}>{pending ? 'Starting' : 'Start build'}</button><button className="button compact" type="button" disabled={pending} onClick={onCancel}>Cancel</button></div></div>
}
function BuildDetail({build, loading, error}: {build?: CodeBuildBuild; loading: boolean; error: unknown}) {
if (loading) return <p className="muted compact-text">Loading build details...</p>
if (error) return <p className="error-text compact-text">{message(error, 'Unable to load build details.')}</p>
if (!build) return null
const logsHref = build.logGroupName ? `/cloud-explorer/aws/logs?resource=${encodeURIComponent(build.logGroupName)}` : undefined
return <section className="workflow-execution-detail"><div className="dynamic-stage-header"><div><p className="eyebrow">Build detail</p><h3>#{build.buildNumber ?? build.id}</h3><p className="muted compact-text">{build.buildStatus ?? 'Unknown'}{build.endTime ? `, finished ${formatDateTime(build.endTime)}` : ''}</p></div>{logsHref && <a className="button compact" href={logsHref}><ExternalLink size={12}/>Open logs{build.logStreamName ? `: ${build.logStreamName}` : ''}</a>}</div><ol className="workflow-execution-history">{build.phases.map((phase, index) => <li key={`${phase.phaseType}-${index}`}><div><strong>{phase.phaseType}</strong><span>{phase.phaseStatus ?? 'Unknown'}</span></div><time>{formatDateTime(phase.startTime) ?? '-'}{phase.endTime ? ` to ${formatDateTime(phase.endTime)}` : ''}</time>{phase.contexts.map((context, contextIndex) => <p className="muted compact-text" key={contextIndex}>{context.statusCode}: {context.message}</p>)}</li>)}</ol></section>
}
function replace<T>(items: T[], index: number, value: T): T[] { return items.map((item, itemIndex) => itemIndex === index ? value : item) }
function active(status?: string): boolean { return status === 'IN_PROGRESS' }
function message(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback }