feat(api): add CloudFront distributions to the cloud explorer
CI / checks (pull_request) Successful in 26m22s

This commit is contained in:
Zakaria
2026-10-24 14:10:16 -04:00
parent 5c3c73c582
commit 6586f48539
23 changed files with 1055 additions and 55 deletions
@@ -0,0 +1,71 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, expect, test, vi} from 'vitest'
import {CloudFrontPanel} from './CloudFrontPanel'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
getCloudResource: vi.fn(),
setDistributionEnabled: vi.fn(),
createDistributionInvalidation: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
const VIEWER_URL = 'http://e7cpm0p0g0vc8d.cloudfront.localhost.floci.io:4566/'
const distribution: CloudResource = {
id: 'E7CPM0P0G0VC8D', name: 'e7cpm0p0g0vc8d.cloudfront.net', cloud: 'aws', service: 'cdn', type: 'distribution',
region: null, createdAt: null, status: 'Deployed',
metadata: {
viewerUrl: VIEWER_URL, domainName: 'e7cpm0p0g0vc8d.cloudfront.net', enabled: true, defaultRootObject: 'index.html',
origins: [{id: 's3-site', domainName: 'site.s3.amazonaws.com', kind: 's3', protocol: null}],
behaviors: [{pathPattern: 'Default (*)', originId: 's3-site', viewerProtocol: 'redirect-to-https', methods: ['GET', 'HEAD']}],
invalidations: [{id: 'I1', status: 'Completed', createdAt: null, paths: []}],
},
}
function renderPanel() {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><CloudFrontPanel resource={distribution} runtimeReachable/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.getCloudResource.mockResolvedValue(distribution)
api.setDistributionEnabled.mockImplementation(async (_cloud, _id, enabled) => ({...distribution, metadata: {...distribution.metadata, enabled}}))
api.createDistributionInvalidation.mockImplementation(async (_cloud, _id, paths) => ({id: 'I2', status: 'Completed', createdAt: null, paths}))
})
test('opens the distribution locally and shows its origins and behaviours', async () => {
renderPanel()
expect(await screen.findByRole('link', {name: /Open/})).toHaveAttribute('href', VIEWER_URL)
expect(screen.getByText('site.s3.amazonaws.com')).toBeInTheDocument()
expect(screen.getByText('Redirect to HTTPS')).toBeInTheDocument()
expect(screen.getByText('I1')).toBeInTheDocument()
})
test('disables the distribution', async () => {
const user = userEvent.setup()
renderPanel()
await user.click(await screen.findByRole('button', {name: 'Disable'}))
expect(api.setDistributionEnabled).toHaveBeenCalledWith('aws', distribution.id, false)
expect(await screen.findByText(/Distribution disabled/)).toBeInTheDocument()
})
test('invalidates the paths entered, one per line', async () => {
const user = userEvent.setup()
renderPanel()
const form = await screen.findByRole('form', {name: 'Create invalidation'})
const field = within(form).getByLabelText(/Paths/)
await user.clear(field)
await user.type(field, '/index.html{enter}/assets/*')
await user.click(within(form).getByRole('button', {name: 'Create invalidation'}))
expect(api.createDistributionInvalidation).toHaveBeenCalledWith('aws', distribution.id, ['/index.html', '/assets/*'])
expect(await screen.findByText(/Invalidation I2 completed for \/index.html, \/assets\/\*/)).toBeInTheDocument()
})
@@ -0,0 +1,124 @@
import {ExternalLink, RefreshCw} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {createDistributionInvalidation, getCloudResource, setDistributionEnabled} from '@/api/cloudProxyClient'
import {CopyValueButton} from '@/components/CopyValueButton'
import {useAccountId} from '@/lib/accountStore'
import {formatDateTime} from '@/lib/format'
import type {CloudResource} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
type Origin = {id: string; domainName: string; kind: string; protocol: string | null}
type Behavior = {pathPattern: string; originId: string; viewerProtocol: string; methods: string[]}
type Invalidation = {id: string; status: string; createdAt: string | null}
const VIEWER_PROTOCOL: Record<string, string> = {
'allow-all': 'HTTP and HTTPS',
'redirect-to-https': 'Redirect to HTTPS',
'https-only': 'HTTPS only',
}
export function CloudFrontPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [paths, setPaths] = useState('/*')
const [notice, setNotice] = useState('')
const distributionKey = ['cdn-distribution', accountId, resource?.id]
// The table row is a list summary; behaviours and invalidations come from the full distribution.
const distributionQuery = useQuery({
queryKey: distributionKey,
queryFn: ({signal}) => getCloudResource('aws', 'cdn', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
useEffect(() => {
setPaths('/*')
setNotice('')
}, [resource?.id])
const refresh = () => Promise.all([
queryClient.invalidateQueries({queryKey: distributionKey}),
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'cdn']}),
])
const enabledMutation = useMutation({
mutationFn: (enabled: boolean) => setDistributionEnabled('aws', resource?.id ?? '', enabled),
onSuccess: (updated) => {
setNotice(updated.metadata.enabled ? 'Distribution enabled.' : 'Distribution disabled. It no longer answers viewer requests.')
return refresh()
},
})
const invalidationMutation = useMutation({
mutationFn: () => createDistributionInvalidation('aws', resource?.id ?? '', paths.split('\n')),
onSuccess: (invalidation) => {
setNotice(`Invalidation ${invalidation.id} ${invalidation.status === 'Completed' ? 'completed' : 'started'} for ${invalidation.paths.join(', ')}.`)
return refresh()
},
})
if (!resource) {
return <section className="table-panel"><div className="empty compact"><h3>Select a distribution</h3><p>Choose a distribution to open it, change its state or invalidate cached files.</p></div></section>
}
const distribution = distributionQuery.data ?? resource
const metadata = distribution.metadata
const viewerUrl = typeof metadata.viewerUrl === 'string' ? metadata.viewerUrl : ''
const enabled = metadata.enabled === true
const origins = list<Origin>(metadata.origins)
const behaviors = list<Behavior>(metadata.behaviors)
const invalidations = list<Invalidation>(metadata.invalidations)
const pending = enabledMutation.isPending || invalidationMutation.isPending
const disabled = !runtimeReachable || pending
const error = distributionQuery.error ?? enabledMutation.error ?? invalidationMutation.error
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Distribution ${distribution.id}`}>
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Distribution</p><span className="muted">{enabled ? 'Enabled' : 'Disabled'} · {distribution.status ?? 'Unknown'}{metadata.defaultRootObject ? ` · serves ${String(metadata.defaultRootObject)} at /` : ''}</span></div>
<div className="cdn-actions">
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); enabledMutation.mutate(!enabled) }}>{enabled ? 'Disable' : 'Enable'}</button>
<button className="button" type="button" disabled={!runtimeReachable || distributionQuery.isFetching} onClick={() => { void refresh() }}><RefreshCw size={14}/>{distributionQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
</div>
{viewerUrl && <div className="cdn-viewer">
<span className="copy-value mono">{viewerUrl}<CopyValueButton value={viewerUrl} label="Local URL"/></span>
<a className="button" href={viewerUrl} target="_blank" rel="noreferrer"><ExternalLink size={13}/> Open</a>
<small className="muted">Served by TheCloud on this machine. On AWS the same distribution answers at <code>{String(metadata.domainName ?? distribution.name)}</code>.</small>
</div>}
<div className="input-row resource-table-bar"><div><p className="eyebrow">Origins</p></div></div>
{origins.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Origin</th><th>Domain</th><th>Type</th></tr></thead><tbody>{origins.map((origin) => (
<tr key={origin.id}><td className="mono">{origin.id}</td><td className="mono">{origin.domainName}</td><td>{origin.kind === 's3' ? 'S3 bucket' : `Custom (${origin.protocol ?? 'https-only'})`}</td></tr>
))}</tbody></table></div>}
{behaviors.length > 0 && <>
<div className="input-row resource-table-bar"><div><p className="eyebrow">Cache behaviours</p><span className="muted">The first path pattern that matches a request decides how it is served.</span></div></div>
<div className="cognito-table"><table className="table"><thead><tr><th>Path pattern</th><th>Origin</th><th>Viewer protocol</th><th>Methods</th></tr></thead><tbody>{behaviors.map((behavior) => (
<tr key={behavior.pathPattern}><td className="mono">{behavior.pathPattern}</td><td className="mono">{behavior.originId}</td><td>{VIEWER_PROTOCOL[behavior.viewerProtocol] ?? behavior.viewerProtocol}</td><td>{behavior.methods.join(', ')}</td></tr>
))}</tbody></table></div>
</>}
<div className="input-row resource-table-bar"><div><p className="eyebrow">Invalidations</p><span className="muted">Clear cached copies so the next request fetches them from the origin.</span></div></div>
<form className="dynamic-form" aria-label="Create invalidation" onSubmit={(event) => { event.preventDefault(); setNotice(''); invalidationMutation.mutate() }}>
<label className="dynamic-field dynamic-field--span"><span>Paths</span>
<textarea className="textarea code-textarea small" rows={2} value={paths} onChange={(event) => setPaths(event.target.value)} disabled={disabled} required/>
<small className="muted">One per line, each starting with /. Use /* for everything.</small>
</label>
<div><button className="button primary" type="submit" disabled={disabled || !paths.trim()}>{invalidationMutation.isPending ? 'Invalidating' : 'Create invalidation'}</button></div>
</form>
{invalidations.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Invalidation</th><th>Status</th><th>Created</th></tr></thead><tbody>{invalidations.map((invalidation) => (
<tr key={invalidation.id}><td className="mono">{invalidation.id}</td><td><code>{invalidation.status}</code></td><td>{formatDateTime(invalidation.createdAt) ?? '—'}</td></tr>
))}</tbody></table></div>}
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
</section>
}
function list<T>(value: unknown): T[] {
return Array.isArray(value) ? value as T[] : []
}
@@ -66,6 +66,7 @@ import { TargetGroupPanel } from "@/components/TargetGroupPanel";
import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPanel";
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
@@ -983,6 +984,9 @@ export function DynamicResourceView({
{service === "dns" && cloud === "aws" && (
<Route53RecordsPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "cdn" && cloud === "aws" && (
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "compute" && (
<ComputePanel
cloud={cloud}
@@ -4,6 +4,7 @@ import {
Clock,
Container,
Database,
Earth,
Globe,
HardDrive,
Hammer,
@@ -33,6 +34,7 @@ import codePipelineIcon from '../../../../aws-icons-svg/Developer-Tools/CodePipe
import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy.svg'
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg'
import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
@@ -90,6 +92,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
kms: Lock,
userpools: UsersRound,
dns: Globe,
cdn: Earth,
loadbalancing: Scale,
parameters: SlidersHorizontal,
configuration: SlidersHorizontal,
@@ -135,6 +138,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
storage: s3Icon,
userpools: cognitoIcon,
dns: route53Icon,
cdn: cloudFrontIcon,
workflows: stepFunctionsIcon,
}