125 lines
7.9 KiB
TypeScript
125 lines
7.9 KiB
TypeScript
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[] : []
|
|
}
|