diff --git a/docker/Dockerfile b/docker/Dockerfile index 128dda6..1baed23 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -7,6 +7,7 @@ COPY packages/api/package.json packages/api/package.json COPY packages/frontend/package.json packages/frontend/package.json RUN pnpm install --frozen-lockfile --filter @floci/frontend COPY packages/frontend/ packages/frontend/ +COPY svgAsset/ svgAsset/ COPY aws-icons-svg/ aws-icons-svg/ COPY TheCloud.png TheCloud.png COPY OS_img/ OS_img/ diff --git a/packages/api/src/adapter-aws/AwsComputeAdapter.test.ts b/packages/api/src/adapter-aws/AwsComputeAdapter.test.ts index 2f69ce5..45e1101 100644 --- a/packages/api/src/adapter-aws/AwsComputeAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsComputeAdapter.test.ts @@ -127,6 +127,7 @@ describe('AwsComputeAdapter', () => { securityGroupIds: 'sg-111, sg-222', rootVolumeSize: '30', rootVolumeType: 'gp3', + userData: '#!/bin/bash\necho ready', }}) expect(launched).toHaveLength(1) expect(launched[0].name).toBe('test-box') @@ -135,6 +136,7 @@ describe('AwsComputeAdapter', () => { expect(launched[0].securityGroupIds).toEqual(['sg-111', 'sg-222']) expect(launched[0].rootVolumeSize).toBe(30) expect(launched[0].rootVolumeType).toBe('gp3') + expect(launched[0].userData).toBe('#!/bin/bash\necho ready') expect(result.type).toBe('instance') expect(result.cloud).toBe('aws') }) diff --git a/packages/api/src/adapter-aws/AwsComputeAdapter.ts b/packages/api/src/adapter-aws/AwsComputeAdapter.ts index f6926da..a7000c0 100644 --- a/packages/api/src/adapter-aws/AwsComputeAdapter.ts +++ b/packages/api/src/adapter-aws/AwsComputeAdapter.ts @@ -56,6 +56,7 @@ export class AwsComputeAdapter implements CloudServiceAdapter { securityGroupIds, rootVolumeSize, rootVolumeType: v.rootVolumeType ? String(v.rootVolumeType) : undefined, + userData: typeof v.userData === 'string' && v.userData.trim() ? v.userData : undefined, } const instance = await this.service_.runInstance(runInput) return instanceToResource(instance) diff --git a/packages/frontend/src/components/CodeBuildPanel.test.tsx b/packages/frontend/src/components/CodeBuildPanel.test.tsx index e77bbbc..188829c 100644 --- a/packages/frontend/src/components/CodeBuildPanel.test.tsx +++ b/packages/frontend/src/components/CodeBuildPanel.test.tsx @@ -1,19 +1,21 @@ import {QueryClient, QueryClientProvider} from '@tanstack/react-query' import {render, screen} from '@testing-library/react' import userEvent from '@testing-library/user-event' -import {expect, test, vi} from 'vitest' +import {beforeEach, expect, test, vi} from 'vitest' import {parse} from 'yaml' import {CodeBuildPanel, DEFAULT_BUILDSPEC} from './CodeBuildPanel' import type {CloudResource} from '@/types/resource' -vi.mock('@/api/cloudProxyClient', () => ({ - getCodeBuild: vi.fn(), - listCodeBuilds: vi.fn().mockResolvedValue([]), - startCodeBuild: vi.fn(), -})) +const api = vi.hoisted(() => ({getCodeBuild: vi.fn(), listCodeBuilds: vi.fn(), startCodeBuild: vi.fn()})) +vi.mock('@/api/cloudProxyClient', () => api) const project: CloudResource = {id: 'orders', name: 'orders', cloud: 'aws', service: 'codebuild', type: 'codebuild-project', region: 'us-east-1', createdAt: null, metadata: {sourceType: 'NO_SOURCE', ecrRepository: 'orders'}} +beforeEach(() => { + vi.clearAllMocks() + api.listCodeBuilds.mockResolvedValue([]) +}) + test('shows the corrected CodeDeployToECS artifact guidance and default buildspec', async () => { const client = new QueryClient({defaultOptions: {queries: {retry: false}}}) const user = userEvent.setup() @@ -26,3 +28,17 @@ test('shows the corrected CodeDeployToECS artifact guidance and default buildspe expect(screen.getByRole('textbox', {name: 'Buildspec override'})).toHaveValue(DEFAULT_BUILDSPEC) expect(parse(DEFAULT_BUILDSPEC).artifacts.files).toEqual(['imageDetail.json', 'taskdef.json', 'appspec.yaml']) }) + +test('shows the configuration animation while a started build is in progress', async () => { + const client = new QueryClient({defaultOptions: {queries: {retry: false}}}) + const user = userEvent.setup() + api.startCodeBuild.mockResolvedValue({id: 'build-1', buildNumber: 1, buildStatus: 'IN_PROGRESS', currentPhase: 'BUILD', phases: []}) + api.getCodeBuild.mockResolvedValue({id: 'build-1', buildNumber: 1, buildStatus: 'IN_PROGRESS', currentPhase: 'BUILD', phases: []}) + render() + + await user.click(screen.getByRole('button', {name: 'Start build'})) + await user.click(screen.getAllByRole('button', {name: 'Start build'})[1]) + + expect(await screen.findByRole('status')).toHaveTextContent('Build in progress') + expect(screen.getByRole('img', {name: 'Animated binary configuration icon'})).toBeInTheDocument() +}) diff --git a/packages/frontend/src/components/CodeBuildPanel.tsx b/packages/frontend/src/components/CodeBuildPanel.tsx index 60a1bda..0017caa 100644 --- a/packages/frontend/src/components/CodeBuildPanel.tsx +++ b/packages/frontend/src/components/CodeBuildPanel.tsx @@ -1,6 +1,7 @@ import {useEffect, useState} from 'react' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' import {ExternalLink, Play, Plus, RefreshCw, X} from 'lucide-react' +import codeBuildAnimation from '../../../../svgAsset/CodeBuild.svg' import {getCodeBuild, listCodeBuilds, startCodeBuild, type CodeBuildBuild, type CodeBuildEnvironmentVariableOverride} from '@/api/cloudProxyClient' import {formatDateTime} from '@/lib/format' import type {CloudResource} from '@/types/resource' @@ -14,12 +15,16 @@ export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps const [overrides, setOverrides] = useState([]) const [buildspecOverride, setBuildspecOverride] = useState(() => defaultBuildspec(projectSourceType)) const [selectedId, setSelectedId] = useState() + const [progressBuildId, setProgressBuildId] = useState() 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, buildspecOverride: buildspecOverride.trim() || undefined}), onSuccess: (build) => { setSelectedId(build.id); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)); void client.invalidateQueries({queryKey: buildsKey}) }}) - useEffect(() => { setSelectedId(undefined); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)) }, [resource?.id, projectSourceType]) + const start = useMutation({mutationFn: () => startCodeBuild(resource!.id, {environmentVariablesOverride: overrides.length ? overrides : undefined, buildspecOverride: buildspecOverride.trim() || undefined}), onSuccess: (build) => { setSelectedId(build.id); setProgressBuildId(build.id); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)); void client.invalidateQueries({queryKey: buildsKey}) }}) + useEffect(() => { setSelectedId(undefined); setProgressBuildId(undefined); setStartOpen(false); setOverrides([]); setBuildspecOverride(defaultBuildspec(projectSourceType)) }, [resource?.id, projectSourceType]) + const progressBuild = detail.data?.id === progressBuildId ? detail.data : builds.data?.find((build) => build.id === progressBuildId) + const buildInProgress = Boolean(progressBuildId && (!progressBuild || active(progressBuild.buildStatus))) + useEffect(() => { if (progressBuildId && progressBuild && !active(progressBuild.buildStatus)) setProgressBuildId(undefined) }, [progressBuild, progressBuildId]) 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) @@ -28,6 +33,7 @@ export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps {startOpen && ( setStartOpen(false)} onStart={() => start.mutate()}/> )} + {buildInProgress && } {ecrRepository &&

Docker builds push to local ECR repository {ecrRepository}. For CodeDeployToECS, BuildOutput must include imageDetail.json as {'{"ImageURI":"..."}'}, plus taskdef.json and appspec.yaml.

} {builds.isError &&

{message(builds.error, 'Unable to load builds.')}

}

Build history

{builds.data?.length ?? 0}
{(builds.data ?? []).map((build) => setSelectedId(build.id)} style={{cursor: 'pointer'}}>)}
BuildStatusPhaseStarted
#{build.buildNumber ?? build.id}{build.buildStatus ?? '-'}{build.currentPhase ?? '-'}{formatDateTime(build.startTime) ?? '-'}
{!builds.isLoading && builds.data?.length === 0 &&

No builds yet.

}
@@ -35,6 +41,17 @@ export function CodeBuildPanel({resource, runtimeReachable}: CodeBuildPanelProps } +function CodeBuildProgress({build}: {build?: CodeBuildBuild}) { + return
+ +

Build in progress

{build?.currentPhase ?? 'Preparing build environment'}

CodeBuild is running #{build?.buildNumber ?? build?.id ?? 'new build'}. This view updates until the build succeeds or fails.

+
+} + +function BuildConfigurationAnimation() { + return Animated binary configuration icon +} + 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
Start build

{requiresBuildspec ? 'A buildspec override lets this no-source project run a real build.' : 'Leave the buildspec override blank to use the S3 source buildspec.'}