feat: Fargate
This commit is contained in:
@@ -326,7 +326,7 @@ export interface PipelineState {
|
||||
name: string;
|
||||
status?: string;
|
||||
lastStatusChange?: string;
|
||||
actions: Array<{name: string; status?: string; summary?: string; lastStatusChange?: string; externalExecutionId?: string; externalExecutionUrl?: string; entityUrl?: string; revisionUrl?: string; codeBuildProject?: string; codeDeployDeploymentId?: string}>;
|
||||
actions: Array<{name: string; status?: string; summary?: string; lastStatusChange?: string; externalExecutionId?: string; externalExecutionUrl?: string; entityUrl?: string; revisionUrl?: string; codeBuildProject?: string; codeDeployDeploymentId?: string; codeDeployApplication?: string; codeDeployDeploymentGroup?: string}>;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -960,7 +960,7 @@ export async function stopWorkflowExecution(stateMachineArn: string, executionAr
|
||||
);
|
||||
}
|
||||
|
||||
export async function startCodeBuild(projectName: string, input: {environmentVariablesOverride?: CodeBuildEnvironmentVariableOverride[]}): Promise<CodeBuildBuild> {
|
||||
export async function startCodeBuild(projectName: string, input: {environmentVariablesOverride?: CodeBuildEnvironmentVariableOverride[]; buildspecOverride?: string}): Promise<CodeBuildBuild> {
|
||||
const res = await apiClient.call<CodeBuildBuild, typeof input>(apiEndpointKeys.clouds.resources.startCodeBuild, requestOptions("aws", "codebuild", {body: input}), {cloud: "aws", id: projectName});
|
||||
return res.data;
|
||||
}
|
||||
|
||||
@@ -9,28 +9,34 @@ interface CodeBuildPanelProps { resource?: CloudResource; runtimeReachable: bool
|
||||
|
||||
export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps) {
|
||||
const client = useQueryClient()
|
||||
const projectSourceType = sourceType(resource)
|
||||
const [startOpen, setStartOpen] = useState(false)
|
||||
const [overrides, setOverrides] = useState<CodeBuildEnvironmentVariableOverride[]>([])
|
||||
const [buildspecOverride, setBuildspecOverride] = useState(() => defaultBuildspec(projectSourceType))
|
||||
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])
|
||||
const start = useMutation({mutationFn: () => startCodeBuild(resource!.id, {environmentVariablesOverride: overrides.length ? overrides : undefined, buildspecOverride: buildspecOverride.trim() || undefined}), onSuccess: (build) => { setSelectedId(build.id); setStartOpen(false); setOverrides([]); setBuildspecOverride(DEFAULT_BUILDSPEC); void client.invalidateQueries({queryKey: buildsKey}) }})
|
||||
useEffect(() => { setSelectedId(undefined); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)) }, [resource?.id, projectSourceType])
|
||||
if (!resource) return null
|
||||
const ecrRepository = typeof resource.metadata.ecrRepository === 'string' ? resource.metadata.ecrRepository : undefined
|
||||
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()}/>}
|
||||
{startOpen && (
|
||||
<StartBuildDialog overrides={overrides} buildspecOverride={buildspecOverride} requiresBuildspec={projectSourceType === 'NO_SOURCE'} pending={start.isPending} error={start.error} onChange={setOverrides} onBuildspecChange={setBuildspecOverride} onCancel={() => setStartOpen(false)} onStart={() => start.mutate()}/>
|
||||
)}
|
||||
{ecrRepository && <p className="muted compact-text">Docker builds push to ECR repository <a href={`/cloud-explorer/aws/ecr?resource=${encodeURIComponent(ecrRepository)}`}>{ecrRepository}</a> and publish `imageDetail.json`, `taskdef.json`, and `appspec.yaml` for CodeDeployToECS.</p>}
|
||||
{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 StartBuildDialog({overrides, buildspecOverride, requiresBuildspec, pending, error, onChange, onBuildspecChange, onCancel, onStart}: {overrides: CodeBuildEnvironmentVariableOverride[]; buildspecOverride: string; requiresBuildspec: boolean; pending: boolean; error: unknown; onChange: (values: CodeBuildEnvironmentVariableOverride[]) => void; onBuildspecChange: (value: string) => 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">{requiresBuildspec ? 'A buildspec override lets this no-source project run a real build.' : 'Leave the buildspec override blank to use the S3 source buildspec.'}</p></div><button className="icon-btn" type="button" aria-label="Close" disabled={pending} onClick={onCancel}><X size={14}/></button></div><label className="dynamic-field"><span>Buildspec override</span><textarea className="textarea code-textarea small" value={buildspecOverride} onChange={(event) => onBuildspecChange(event.target.value)} rows={8} spellCheck={false}/></label>{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 || (requiresBuildspec && !buildspecOverride.trim()) || 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}) {
|
||||
@@ -42,5 +48,8 @@ function BuildDetail({build, loading, error}: {build?: CodeBuildBuild; loading:
|
||||
}
|
||||
|
||||
function replace<T>(items: T[], index: number, value: T): T[] { return items.map((item, itemIndex) => itemIndex === index ? value : item) }
|
||||
const DEFAULT_BUILDSPEC = 'version: 0.2\nphases:\n pre_build:\n commands:\n - AWS_ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)\n - REPOSITORY_URI=$AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com/$ECR_REPOSITORY\n - aws ecr get-login-password | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com\n build:\n commands:\n - IMAGE_TAG=${CODEBUILD_RESOLVED_SOURCE_VERSION:0:7}\n - docker build -t $REPOSITORY_URI:$IMAGE_TAG .\n - docker push $REPOSITORY_URI:$IMAGE_TAG\n post_build:\n commands:\n - printf \'[{"name":"IMAGE1_NAME","imageUri":"%s"}]\' "$REPOSITORY_URI:$IMAGE_TAG" > imageDetail.json\nartifacts:\n files:\n - imageDetail.json\n - taskdef.json\n - appspec.yaml'
|
||||
function sourceType(resource?: CloudResource): string | undefined { return typeof resource?.metadata.sourceType === 'string' ? resource.metadata.sourceType : undefined }
|
||||
function defaultBuildspec(sourceType?: string): string { return sourceType === 'S3' ? '' : DEFAULT_BUILDSPEC }
|
||||
function active(status?: string): boolean { return status === 'IN_PROGRESS' }
|
||||
function message(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback }
|
||||
|
||||
@@ -41,9 +41,10 @@ export function CodeDeployPanel({resource, runtimeReachable}: {resource?: CloudR
|
||||
const stop = useMutation({mutationFn: ({id, rollback}: {id: string; rollback: boolean}) => stopCodeDeployDeployment(id, rollback), onSuccess: () => void client.invalidateQueries({queryKey: deploymentsKey})})
|
||||
const selected = dependencies.data?.lambdaFunctions.find((item) => item.name === functionName)
|
||||
if (!resource) return null
|
||||
const ecsServices = Array.isArray(resource.metadata.ecsServices) ? resource.metadata.ecsServices as Array<{clusterName?: string; serviceName?: string}> : []
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">Lambda release</p><h3>Deploy a version to an alias</h3><p className="muted compact-text">{String(resource.metadata.applicationName)} / {resource.name}</p></div><button className="button" type="button" disabled={!enabled || deployments.isFetching} onClick={() => void deployments.refetch()}><RefreshCw size={13}/>Refresh</button></div>
|
||||
<form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); start.mutate() }}>
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">{ecsServices.length ? 'ECS EC2 release' : 'Lambda release'}</p><h3>{ecsServices.length ? 'CodeDeploy deployment status' : 'Deploy a version to an alias'}</h3><p className="muted compact-text">{String(resource.metadata.applicationName)} / {resource.name}{ecsServices.length ? ` | ${ecsServices.map((service) => `${service.clusterName}/${service.serviceName}`).join(', ')}` : ''}</p></div><button className="button" type="button" disabled={!enabled || deployments.isFetching} onClick={() => void deployments.refetch()}><RefreshCw size={13}/>Refresh</button></div>
|
||||
{!ecsServices.length && <form className="dynamic-form" onSubmit={(event) => { event.preventDefault(); start.mutate() }}>
|
||||
<label>Function<select className="input" required value={functionName} onChange={(event) => { setFunctionName(event.target.value); setAlias(''); setCurrentVersion(''); setTargetVersion('') }}><option value="">Select Lambda function</option>{(dependencies.data?.lambdaFunctions ?? []).map((item) => <option key={item.name} value={item.name}>{item.name}</option>)}</select></label>
|
||||
<label>Alias<select className="input" required value={alias} onChange={(event) => setAlias(event.target.value)} disabled={!selected}><option value="">Select alias</option>{selected?.aliases.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
|
||||
<label>Current version<select className="input" required value={currentVersion} onChange={(event) => setCurrentVersion(event.target.value)} disabled={!selected}><option value="">Select current version</option>{selected?.versions.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
|
||||
@@ -52,7 +53,7 @@ export function CodeDeployPanel({resource, runtimeReachable}: {resource?: CloudR
|
||||
<label>AfterAllowTraffic hook (optional)<HookPicker dependencies={dependencies.data} value={afterHook} onChange={setAfterHook}/></label>
|
||||
{start.isError && <p className="error-text compact-text">{message(start.error)}</p>}
|
||||
<div className="form-actions"><button className="button primary" type="submit" disabled={!enabled || start.isPending}><Play size={13}/>{start.isPending ? 'Starting' : 'Start deployment'}</button></div>
|
||||
</form>
|
||||
</form>}
|
||||
{deployments.isError && <p className="error-text compact-text">{message(deployments.error)}</p>}
|
||||
<div className="workflow-execution-list"><div className="workflow-execution-list-heading"><h4>Deployment group history</h4><span>{deployments.data?.length ?? 0}</span></div><div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Deployment</th><th>Status</th><th>Traffic</th><th>Versions</th><th>Started</th><th>Actions</th></tr></thead><tbody>{(deployments.data ?? []).map((deployment) => <tr key={deployment.id}><td><code>{deployment.id}</code>{deployment.errorInformation?.message && <p className="error-text compact-text">{deployment.errorInformation.message}</p>}</td><td>{deployment.status ?? '-'}</td><td>{deployment.trafficPercent === null ? '-' : `${deployment.trafficPercent}%`}</td><td>{deployment.currentVersion ?? '-'} to {deployment.targetVersion ?? '-'}</td><td>{formatDateTime(deployment.createTime) ?? '-'}</td><td>{active(deployment.status) && <><button className="button compact danger" type="button" disabled={stop.isPending} onClick={() => stop.mutate({id: deployment.id, rollback: false})}><Ban size={12}/>Stop</button><button className="button compact" type="button" disabled={stop.isPending} onClick={() => stop.mutate({id: deployment.id, rollback: true})}><RotateCcw size={12}/>Stop and roll back</button></>}</td></tr>)}</tbody></table></div>{!deployments.isLoading && deployments.data?.length === 0 && <p className="muted compact-text">No deployments yet.</p>}</div>
|
||||
</section>
|
||||
|
||||
@@ -15,7 +15,7 @@ export function CodePipelinePanel({resource, runtimeReachable}: CodePipelinePane
|
||||
const start = useMutation({mutationFn: () => startCodePipeline(resource!.id), onSuccess: () => { void client.invalidateQueries({queryKey: executionsKey}); void client.invalidateQueries({queryKey: ['codepipeline-state', resource?.id]}) }})
|
||||
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 S3 deploy.</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>
|
||||
<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 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>
|
||||
{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>
|
||||
@@ -32,7 +32,7 @@ function PipelineTimeline({state, loading}: {state?: PipelineState; loading: boo
|
||||
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.codeDeployDeploymentId ? {href: `/cloud-explorer/aws/codedeploy?deployment=${encodeURIComponent(action.codeDeployDeploymentId)}`, label: 'Open CodeDeploy'} : 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))
|
||||
|
||||
@@ -65,6 +65,7 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane
|
||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
|
||||
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
|
||||
import { EcsServicePanel } from "@/components/EcsServicePanel";
|
||||
import {CodeDeployCreatePanel, CodeDeployPanel} from "@/components/CodeDeployPanel";
|
||||
import {WorkflowCreatePanel} from "@/components/WorkflowCreatePanel";
|
||||
import {workflowCreationDestinationPath, type WorkflowCreationDestination} from "@/lib/workflowCreate";
|
||||
@@ -1012,6 +1013,7 @@ export function DynamicResourceView({
|
||||
{service === "codebuild" && cloud === "aws" && (
|
||||
<CodeBuildPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{(service === "ecs" || service === "fargate") && cloud === "aws" && <EcsServicePanel resource={activeSelected} />}
|
||||
{service === "codepipeline" && cloud === "aws" && (
|
||||
<CodePipelinePanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import {expect, test} from 'vitest'
|
||||
import {EcsServicePanel} from './EcsServicePanel'
|
||||
|
||||
test('shows Fargate awsvpc task, IP target, and ALB endpoint health', () => {
|
||||
render(<EcsServicePanel resource={{id: 'orders-cluster|orders', name: 'orders', cloud: 'aws', service: 'ecs', type: 'ecs-service', region: 'us-east-1', createdAt: null, metadata: {cluster: 'orders-cluster', launchType: 'FARGATE', desiredCount: 1, runningCount: 1, networkConfiguration: {subnets: ['subnet-1'], securityGroups: ['sg-1'], assignPublicIp: 'ENABLED'}, tasks: [{arn: 'task-1', lastStatus: 'RUNNING', healthStatus: 'HEALTHY', connectivity: 'CONNECTED'}], loadBalancers: [{targetGroupArn: 'tg-1', targetType: 'ip', targets: [{id: '10.0.1.5', port: 8080, state: 'healthy'}], endpoints: [{dnsName: 'orders.example.test', scheme: 'internet-facing', state: 'active'}]}]}}}/>)
|
||||
|
||||
expect(screen.getByText('Fargate service state')).toBeInTheDocument()
|
||||
expect(screen.getByText('awsvpc networking: subnets subnet-1 | security groups sg-1 | public IP ENABLED')).toBeInTheDocument()
|
||||
expect(screen.getByText('HEALTHY')).toBeInTheDocument()
|
||||
expect(screen.getByText('orders.example.test')).toBeInTheDocument()
|
||||
expect(screen.getByText('10.0.1.5:8080')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('labels inactive services as not running instead of showing stale counts', () => {
|
||||
render(<EcsServicePanel resource={{id: 'orders-cluster|orders', name: 'orders', cloud: 'aws', service: 'ecs', type: 'ecs-service', region: 'us-east-1', createdAt: null, status: 'INACTIVE', metadata: {cluster: 'orders-cluster', launchType: 'FARGATE', desiredCount: 2, runningCount: 2, inactive: true}}}/>)
|
||||
|
||||
expect(screen.getByText('Inactive, not running')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Running 2 of 2')).not.toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
|
||||
interface EcsServicePanelProps { resource?: CloudResource }
|
||||
interface Deployment { id?: string; status?: string; taskDefinition?: string; desiredCount?: number; runningCount?: number; pendingCount?: number; createdAt?: string; updatedAt?: string }
|
||||
interface Task { arn?: string; lastStatus?: string; desiredStatus?: string; healthStatus?: string; connectivity?: string; stoppedReason?: string }
|
||||
interface Target { id?: string; port?: number; state?: string; reason?: string }
|
||||
interface LoadBalancer { targetGroupArn?: string; targetType?: string | null; containerName?: string; containerPort?: number; targets?: Target[]; endpoints?: Array<{dnsName?: string; state?: string | null; scheme?: string | null}> }
|
||||
|
||||
export function EcsServicePanel({resource}: EcsServicePanelProps) {
|
||||
if (!resource) return null
|
||||
const deployments = array<Deployment>(resource.metadata.deployments)
|
||||
const tasks = array<Task>(resource.metadata.tasks)
|
||||
const loadBalancers = array<LoadBalancer>(resource.metadata.loadBalancers)
|
||||
const inactive = resource.status === 'INACTIVE' || resource.metadata.inactive === true
|
||||
const launchType = String(resource.metadata.launchType ?? 'EC2')
|
||||
const network = object(resource.metadata.networkConfiguration)
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header"><div><p className="eyebrow">{launchType === 'FARGATE' ? 'Fargate service state' : 'EC2 service state'}</p><h3>{resource.name}</h3><p className="muted compact-text">Launch type: <strong>{launchType}</strong> | Cluster: {String(resource.metadata.cluster ?? '-')} | <span>{inactive ? 'Inactive, not running' : `Running ${String(resource.metadata.runningCount ?? 0)} of ${String(resource.metadata.desiredCount ?? 0)}`}</span></p></div></div>
|
||||
{network && <p className="muted compact-text">awsvpc networking: subnets {values(network.subnets)} | security groups {values(network.securityGroups)} | public IP {String(network.assignPublicIp ?? 'DISABLED')}</p>}
|
||||
{launchType === 'FARGATE' && !network && <p className="muted compact-text">Fargate services require awsvpc networking. Attach an IP target group, not an instance target group, when using an ALB.</p>}
|
||||
{inactive ? <p className="muted compact-text">This service is inactive. ECS may retain historical deployment counts while deletion settles; they are not shown as running tasks.</p> : <>
|
||||
<h4>Tasks</h4>
|
||||
{tasks.length ? <div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Task</th><th>Last status</th><th>Health</th><th>Connectivity</th></tr></thead><tbody>{tasks.map((task, index) => <tr key={task.arn ?? index}><td><code>{task.arn ?? '-'}</code></td><td>{task.lastStatus ?? '-'}</td><td>{task.healthStatus ?? '-'}</td><td>{task.connectivity ?? '-'}</td></tr>)}</tbody></table></div> : <p className="muted compact-text">No running tasks reported for this service.</p>}
|
||||
{loadBalancers.map((loadBalancer, index) => <div key={loadBalancer.targetGroupArn ?? index}><h4>ALB and target health</h4><p className="muted compact-text">Target group: <code>{loadBalancer.targetGroupArn ?? '-'}</code> | Target type: <strong>{loadBalancer.targetType ?? '-'}</strong>{launchType === 'FARGATE' && loadBalancer.targetType !== 'ip' ? ' (Fargate requires IP targets)' : ''}</p>{loadBalancer.endpoints?.map((endpoint, endpointIndex) => <p className="muted compact-text" key={endpoint.dnsName ?? endpointIndex}>ALB endpoint: <code>{endpoint.dnsName ?? '-'}</code> | {endpoint.scheme ?? '-'} | {endpoint.state ?? '-'}</p>)}<div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Target</th><th>Health</th><th>Reason</th></tr></thead><tbody>{(loadBalancer.targets ?? []).map((target, targetIndex) => <tr key={`${target.id ?? targetIndex}:${target.port ?? ''}`}><td><code>{target.id ?? '-'}{target.port ? `:${target.port}` : ''}</code></td><td>{target.state ?? '-'}</td><td>{target.reason ?? '-'}</td></tr>)}</tbody></table></div>{!loadBalancer.targets?.length && <p className="muted compact-text">No target health reported by this target group.</p>}</div>)}
|
||||
<h4>Deployments</h4>
|
||||
<div className="workflow-execution-list-table"><table className="table"><thead><tr><th>Deployment</th><th>Status</th><th>Task definition</th><th>Running</th><th>Updated</th></tr></thead><tbody>{deployments.map((deployment, index) => <tr key={deployment.id ?? index}><td><code>{deployment.id ?? '-'}</code></td><td>{deployment.status ?? '-'}</td><td><code>{deployment.taskDefinition ?? '-'}</code></td><td>{deployment.runningCount ?? 0} / {deployment.desiredCount ?? 0}{deployment.pendingCount ? ` (${deployment.pendingCount} pending)` : ''}</td><td>{formatDateTime(deployment.updatedAt ?? deployment.createdAt) ?? '-'}</td></tr>)}</tbody></table></div>
|
||||
{deployments.length === 0 && <p className="muted compact-text">No deployment state reported for this service.</p>}
|
||||
</>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function array<T>(value: unknown): T[] { return Array.isArray(value) ? value as T[] : [] }
|
||||
function object(value: unknown): Record<string, unknown> | null { return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null }
|
||||
function values(value: unknown): string { return Array.isArray(value) && value.length ? value.map(String).join(', ') : '-' }
|
||||
Reference in New Issue
Block a user