Files
floci-ui/packages/frontend/src/components/CodePipelinePanel.tsx
T

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 }