64 lines
7.2 KiB
TypeScript
64 lines
7.2 KiB
TypeScript
import {useEffect, useState} from 'react'
|
|
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
|
import {ExternalLink, Play, RefreshCw} from 'lucide-react'
|
|
import pipelineReleaseAnimation from '../../../../svgAsset/Pipleline.svg'
|
|
import {getCodePipelineState, listCodePipelineExecutions, startCodePipeline, type PipelineState} from '@/api/cloudProxyClient'
|
|
import {formatDateTime} from '@/lib/format'
|
|
import type {CloudResource} from '@/types/resource'
|
|
|
|
interface CodePipelinePanelProps { resource?: CloudResource; runtimeReachable: boolean }
|
|
|
|
export function CodePipelinePanel({resource, runtimeReachable}: CodePipelinePanelProps) {
|
|
const client = useQueryClient()
|
|
const [releaseExecutionId, setReleaseExecutionId] = useState<string>()
|
|
const enabled = Boolean(resource && runtimeReachable)
|
|
const executionsKey = ['codepipeline-executions', resource?.id]
|
|
const executions = useQuery({queryKey: executionsKey, queryFn: ({signal}) => listCodePipelineExecutions(resource!.id, signal), enabled, refetchInterval: (query) => Boolean(releaseExecutionId) || query.state.data?.some((execution) => active(execution.status)) ? 2_000 : false})
|
|
const state = useQuery({queryKey: ['codepipeline-state', resource?.id], queryFn: ({signal}) => getCodePipelineState(resource!.id, signal), enabled, refetchInterval: (query) => Boolean(releaseExecutionId) || query.state.data && activeState(query.state.data) ? 2_000 : false})
|
|
const start = useMutation({mutationFn: () => startCodePipeline(resource!.id), onSuccess: ({id}) => { setReleaseExecutionId(id); void client.invalidateQueries({queryKey: executionsKey}); void client.invalidateQueries({queryKey: ['codepipeline-state', resource?.id]}) }})
|
|
useEffect(() => { setReleaseExecutionId(undefined) }, [resource?.id])
|
|
const releaseExecution = executions.data?.find((execution) => execution.id === releaseExecutionId)
|
|
const releaseInProgress = Boolean(releaseExecutionId && (!releaseExecution || active(releaseExecution.status)))
|
|
useEffect(() => { if (releaseExecutionId && releaseExecution && !active(releaseExecution.status)) setReleaseExecutionId(undefined) }, [releaseExecution, releaseExecutionId])
|
|
if (!resource) return null
|
|
return <section className="table-panel">
|
|
<div className="dynamic-stage-header"><div><p className="eyebrow">Pipeline execution</p><h3>Run and inspect stages</h3><p className="muted compact-text">Local actions: S3 source, CodeBuild, and CodeDeployToECS for ECS EC2 and Fargate services.</p></div><div className="header-actions"><button className="button" type="button" disabled={!enabled || executions.isFetching || state.isFetching} onClick={() => { void executions.refetch(); void state.refetch() }}><RefreshCw size={13}/>Refresh</button><button className="button primary" type="button" disabled={!enabled || start.isPending} onClick={() => start.mutate()}><Play size={13}/>{start.isPending ? 'Starting' : 'Release change'}</button></div></div>
|
|
{releaseInProgress && <PipelineReleaseProgress executionId={releaseExecutionId} execution={releaseExecution}/>}
|
|
{start.isError && <p className="error-text compact-text">{message(start.error, 'Unable to start pipeline execution.')}</p>}
|
|
{(executions.isError || state.isError) && <p className="error-text compact-text">{message(executions.error ?? state.error, 'Unable to load pipeline execution state.')}</p>}
|
|
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Execution history</h4><span>{executions.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Execution</th><th>Status</th><th>Started</th><th>Updated</th></tr></thead><tbody>{(executions.data ?? []).map((execution) => <tr key={execution.id}><td><code>{execution.id}</code>{execution.statusSummary && <p className="muted compact-text">{execution.statusSummary}</p>}</td><td>{execution.status ?? '-'}</td><td>{formatDateTime(execution.startTime) ?? '-'}</td><td>{formatDateTime(execution.lastUpdateTime) ?? '-'}</td></tr>)}</tbody></table></div>{!executions.isLoading && executions.data?.length === 0 && <p className="muted compact-text">No executions yet.</p>}</div>
|
|
<PipelineTimeline state={state.data} loading={state.isLoading}/>
|
|
</section>
|
|
}
|
|
|
|
function PipelineReleaseProgress({executionId, execution}: {executionId?: string; execution?: {status?: string; statusSummary?: string}}) {
|
|
return <div className="pipeline-release-progress" role="status" aria-live="polite">
|
|
<PipelineReleaseAnimation/>
|
|
<div><p className="eyebrow">Release in progress</p><h3>{execution?.statusSummary || execution?.status || 'Starting pipeline execution'}</h3><p className="muted compact-text">Pipeline execution <code>{executionId}</code> is moving through source, build, and deployment stages.</p></div>
|
|
</div>
|
|
}
|
|
|
|
function PipelineReleaseAnimation() {
|
|
return <img className="pipeline-release-art" src={pipelineReleaseAnimation} alt="Animated pipeline release icon"/>
|
|
}
|
|
|
|
function PipelineTimeline({state, loading}: {state?: PipelineState; loading: boolean}) {
|
|
if (loading) return <p className="muted compact-text">Loading stage state...</p>
|
|
if (!state) return null
|
|
return <section className="workflow-execution-detail"><div className="dynamic-stage-header"><div><p className="eyebrow">Current pipeline state</p><h3>Stages and actions</h3></div></div><ol className="workflow-execution-history">{state.stages.map((stage) => <li key={stage.name}><div><strong>{stage.name}</strong><span>{stage.status ?? 'Unknown'}</span></div><time>{formatDateTime(stage.lastStatusChange) ?? '-'}</time>{stage.actions.map((action) => <div className="workflow-action" key={action.name}><strong>{action.name}</strong><span>{action.status ?? 'Unknown'}</span>{action.summary && <p className="muted compact-text">{action.summary}</p>}<ActionLinks action={action}/></div>)}</li>)}</ol></section>
|
|
}
|
|
|
|
function ActionLinks({action}: {action: PipelineState['stages'][number]['actions'][number]}) {
|
|
const links = [
|
|
action.codeBuildProject ? {href: `/cloud-explorer/aws/codebuild?resource=${encodeURIComponent(action.codeBuildProject)}`, label: 'Open CodeBuild'} : undefined,
|
|
action.codeDeployApplication && action.codeDeployDeploymentGroup ? {href: `/cloud-explorer/aws/codedeploy?resource=${encodeURIComponent(`${action.codeDeployApplication}/${action.codeDeployDeploymentGroup}`)}`, label: 'Open CodeDeploy'} : undefined,
|
|
action.externalExecutionUrl ? {href: action.externalExecutionUrl, label: 'Execution details'} : undefined,
|
|
action.entityUrl ? {href: action.entityUrl, label: 'Action details'} : undefined,
|
|
].filter((item): item is {href: string; label: string} => Boolean(item))
|
|
return <>{links.map((link) => <a className="button compact" key={`${link.label}:${link.href}`} href={link.href}><ExternalLink size={12}/>{link.label}</a>)}</>
|
|
}
|
|
|
|
function active(status?: string): boolean { return status === 'InProgress' || status === 'Stopping' }
|
|
function activeState(state: PipelineState): boolean { return state.stages.some((stage) => active(stage.status) || stage.actions.some((action) => active(action.status))) }
|
|
function message(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback }
|