feat(api): add CloudFront distributions to the cloud explorer
CI / checks (pull_request) Successful in 26m22s
CI / checks (pull_request) Successful in 26m22s
This commit is contained in:
@@ -130,6 +130,10 @@ export const apiEndpointKeys = {
|
||||
messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"},
|
||||
moveTasks: "clouds.services.messaging.move-tasks",
|
||||
},
|
||||
cdn: {
|
||||
enabled: "clouds.services.cdn.enabled",
|
||||
invalidations: "clouds.services.cdn.invalidations",
|
||||
},
|
||||
userpools: {
|
||||
userAction: "clouds.services.userpools.users.action",
|
||||
userGroups: "clouds.services.userpools.users.groups",
|
||||
@@ -886,6 +890,20 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.cdn.enabled,
|
||||
{
|
||||
path: "/clouds/:cloud/services/cdn/resources/:id/enabled",
|
||||
method: "PUT",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.cdn.invalidations,
|
||||
{
|
||||
path: "/clouds/:cloud/services/cdn/resources/:id/invalidations",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[
|
||||
apiEndpointKeys.clouds.userpools.client.get,
|
||||
{
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
UserPoolSignInTestInput,
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
UpdateUserPoolClientOAuthInput,
|
||||
UserPoolUserAction,
|
||||
LogsInsightsQueryInput,
|
||||
@@ -1476,6 +1477,32 @@ export async function testUserPoolSignIn(
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function setDistributionEnabled(
|
||||
cloud: CloudProvider,
|
||||
distributionId: string,
|
||||
enabled: boolean,
|
||||
): Promise<CloudResource> {
|
||||
const res = await apiClient.call<CloudResource, { enabled: boolean }>(
|
||||
apiEndpointKeys.clouds.cdn.enabled,
|
||||
requestOptions(cloud, "cdn", { body: { enabled } }),
|
||||
{ cloud, id: distributionId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createDistributionInvalidation(
|
||||
cloud: CloudProvider,
|
||||
distributionId: string,
|
||||
paths: string[],
|
||||
): Promise<DistributionInvalidation> {
|
||||
const res = await apiClient.call<DistributionInvalidation, { paths: string[] }>(
|
||||
apiEndpointKeys.clouds.cdn.invalidations,
|
||||
requestOptions(cloud, "cdn", { body: { paths } }),
|
||||
{ cloud, id: distributionId },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function describeUserPoolClient(
|
||||
cloud: CloudProvider,
|
||||
poolId: string,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -6525,3 +6525,16 @@ button.console-service-card {
|
||||
.dns-values { overflow-wrap: anywhere; }
|
||||
.dns-badge { margin-left: 8px; }
|
||||
tr[aria-current="true"] td { background: var(--hover-md); }
|
||||
|
||||
.cdn-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
.cdn-viewer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 12px;
|
||||
padding: 0 16px 12px;
|
||||
}
|
||||
|
||||
.cdn-viewer .copy-value { overflow-wrap: anywhere; min-width: 0; }
|
||||
.cdn-viewer small { flex-basis: 100%; }
|
||||
|
||||
@@ -302,6 +302,13 @@ export interface UserPoolSignInTestInput {
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface DistributionInvalidation {
|
||||
id: string
|
||||
status: string
|
||||
createdAt: string | null
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export type UserPoolOAuthFlow = 'code' | 'implicit' | 'client_credentials'
|
||||
|
||||
export interface UserPoolClientSettings {
|
||||
|
||||
@@ -27,6 +27,7 @@ export type KubernetesActionName =
|
||||
| 'createFargateProfile'
|
||||
| 'deleteFargateProfile'
|
||||
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
|
||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||
|
||||
@@ -88,6 +89,7 @@ export interface ServiceSchema {
|
||||
kubernetesActions?: Array<CapabilitySchema<KubernetesActionName> | KubernetesActionName>
|
||||
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
||||
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
||||
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
|
||||
}
|
||||
filters: FieldSchema[]
|
||||
columns: TableColumnSchema[]
|
||||
|
||||
Reference in New Issue
Block a user