feat: CORS enable button...ish still now understanding this
This commit is contained in:
@@ -8,6 +8,7 @@ import type {CloudResource} from '@/types/resource'
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
createApiGatewayLambdaRoute: vi.fn(),
|
||||
deleteApiGatewayRoute: vi.fn(),
|
||||
enableApiGatewayCors: vi.fn(),
|
||||
listApiGatewayRoutes: vi.fn(),
|
||||
listCloudResources: vi.fn(),
|
||||
queryLogs: vi.fn(),
|
||||
@@ -54,6 +55,39 @@ test('creates and displays a deployed Lambda proxy route', async () => {
|
||||
expect(await screen.findByText('Route deployed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('displays and copies the selected API stage base invoke URL', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
|
||||
const user = userEvent.setup()
|
||||
const writeText = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable runtimeEndpoint="http://floci.test:4566"/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
const baseInvokeUrl = 'http://floci.test:4566/execute-api/api-123/dev'
|
||||
expect(screen.getByText(baseInvokeUrl)).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', {name: 'Copy Base invoke URL'}))
|
||||
expect(writeText).toHaveBeenCalledWith(baseInvokeUrl)
|
||||
})
|
||||
|
||||
test('uses the browser host for a Docker bridge runtime endpoint', () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable runtimeEndpoint="http://172.17.0.3:4566"/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('http://localhost:4566/execute-api/api-123/dev')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('sends a route test request and displays its response', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
|
||||
@@ -144,3 +178,29 @@ test('confirms before deleting a deployed route', async () => {
|
||||
resourceId: 'resource-123', path: '/orders', method: 'POST', stage: 'dev',
|
||||
}))
|
||||
})
|
||||
|
||||
test('enables CORS for one resource and shows AWS proxy guidance', async () => {
|
||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({
|
||||
routes: [
|
||||
{resourceId: 'resource-123', path: '/orders', method: 'GET', functionName: null, integrationType: 'HTTP'},
|
||||
{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler', integrationType: 'AWS_PROXY'},
|
||||
], stages: ['dev'],
|
||||
})
|
||||
apiMocks.enableApiGatewayCors.mockResolvedValue({configuredMethods: ['GET'], proxyMethods: ['POST']})
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
render(<QueryClientProvider client={queryClient}><ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable/></QueryClientProvider>)
|
||||
|
||||
await user.click(await screen.findByRole('button', {name: 'Enable CORS'}))
|
||||
expect(screen.getByDisplayValue('*')).toBeInTheDocument()
|
||||
expect(screen.getByLabelText('GET')).toBeChecked()
|
||||
expect(screen.getByLabelText('POST')).toBeChecked()
|
||||
await user.click(screen.getByRole('button', {name: 'Confirm enable CORS'}))
|
||||
|
||||
await waitFor(() => expect(apiMocks.enableApiGatewayCors).toHaveBeenCalledWith('aws', 'api-123', {
|
||||
resourceId: 'resource-123', stage: 'dev', origin: '*', headers: 'Content-Type', methods: ['GET', 'POST'],
|
||||
}))
|
||||
expect(await screen.findByText(/AWS_PROXY methods need their backend/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/Access-Control-Allow-Origin/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {ArrowRight, Braces, Copy, Link2, ListTree, Loader2, Rocket, Send, Trash2, Waypoints, X} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient'
|
||||
import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, enableApiGatewayCors, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import {CopyValueButton} from './CopyValueButton'
|
||||
import {LogsQueryPanel} from './LogsQueryPanel'
|
||||
|
||||
interface ApiGatewayRoutePanelProps {
|
||||
cloud: CloudProvider
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
runtimeEndpoint?: string
|
||||
}
|
||||
|
||||
export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGatewayRoutePanelProps) {
|
||||
export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtimeEndpoint}: ApiGatewayRoutePanelProps) {
|
||||
const [path, setPath] = useState('/hello')
|
||||
const [method, setMethod] = useState('GET')
|
||||
const [stage, setStage] = useState('dev')
|
||||
@@ -24,6 +26,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
||||
const [bodyFormatError, setBodyFormatError] = useState<string | null>(null)
|
||||
const [logsRefreshKey, setLogsRefreshKey] = useState(0)
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState<string | null>(null)
|
||||
const [corsConfirmation, setCorsConfirmation] = useState<{resourceId: string; path: string} | null>(null)
|
||||
const [corsOrigin, setCorsOrigin] = useState('*')
|
||||
const [corsHeaders, setCorsHeaders] = useState('Content-Type')
|
||||
const [corsMethods, setCorsMethods] = useState<string[]>([])
|
||||
const [corsProxyMethods, setCorsProxyMethods] = useState<string[]>([])
|
||||
const queryClient = useQueryClient()
|
||||
const functionsQuery = useQuery({
|
||||
queryKey: ['cloud-resources', cloud, 'serverless'],
|
||||
@@ -48,6 +55,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
||||
setBodyFormatError(null)
|
||||
setLogsRefreshKey(0)
|
||||
setDeleteConfirmation(null)
|
||||
setCorsConfirmation(null)
|
||||
setCorsOrigin('*')
|
||||
setCorsHeaders('Content-Type')
|
||||
setCorsMethods([])
|
||||
setCorsProxyMethods([])
|
||||
}, [resource?.id])
|
||||
|
||||
const routeMutation = useMutation({
|
||||
@@ -73,6 +85,20 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
||||
queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]})
|
||||
},
|
||||
})
|
||||
const corsMutation = useMutation({
|
||||
mutationFn: () => enableApiGatewayCors(cloud, resource!.id, {
|
||||
resourceId: corsConfirmation!.resourceId,
|
||||
stage,
|
||||
origin: corsOrigin,
|
||||
headers: corsHeaders,
|
||||
methods: corsMethods,
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
setCorsProxyMethods(result.proxyMethods)
|
||||
setCorsConfirmation(null)
|
||||
queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]})
|
||||
},
|
||||
})
|
||||
|
||||
if (!resource || resource.service !== 'apigateway') {
|
||||
return (
|
||||
@@ -89,6 +115,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
||||
const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName) && !routeMutation.isPending
|
||||
const existingPath = routesQuery.data?.routes.some((route) => route.path === path) ?? false
|
||||
const curlCommand = buildCurlCommand(resource.id, stage, path, method, testHeaders, testQuery, testBody)
|
||||
const endpoint = invokeEndpoint(runtimeEndpoint)
|
||||
const baseInvokeUrl = endpoint ? `${endpoint}/execute-api/${resource.id}/${stage}` : null
|
||||
|
||||
return (
|
||||
<section className="table-panel">
|
||||
@@ -108,6 +136,13 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
||||
)}
|
||||
</div>
|
||||
<div className="resource-create-inline">
|
||||
{baseInvokeUrl && <div className="dynamic-field">
|
||||
<span>Base invoke URL</span>
|
||||
<div className="copy-value">
|
||||
<code>{baseInvokeUrl}</code>
|
||||
<CopyValueButton value={baseInvokeUrl} label="Base invoke URL"/>
|
||||
</div>
|
||||
</div>}
|
||||
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (canCreate) routeMutation.mutate()
|
||||
@@ -161,12 +196,16 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
||||
{routesQuery.isLoading && <p className="muted compact-text">Loading deployed routes...</p>}
|
||||
{routesQuery.isError && <p className="error-text compact-text">{routesQuery.error instanceof Error ? routesQuery.error.message : 'Unable to load API routes.'}</p>}
|
||||
{routesQuery.data?.routes.length === 0 && <p className="muted compact-text">No deployed methods yet. Create and deploy a route above.</p>}
|
||||
{routesQuery.data && routesQuery.data.routes.length > 0 && (
|
||||
<div className="apigateway-route-list">
|
||||
{routesQuery.data.routes.map((route) => {
|
||||
{routesQuery.data && routesQuery.data.routes.length > 0 && (
|
||||
<div className="apigateway-route-list">
|
||||
{routesQuery.data.routes.map((route) => {
|
||||
const selected = route.path === path && route.method === method
|
||||
const key = `${route.resourceId}-${route.method}`
|
||||
return <div className={`apigateway-route-item${selected ? ' selected' : ''}`} key={key}>
|
||||
const resourceMethods = routesQuery.data.routes
|
||||
.filter((item) => item.resourceId === route.resourceId && CORS_METHODS.includes(item.method))
|
||||
.map((item) => item.method)
|
||||
const isFirstForResource = routesQuery.data.routes.findIndex((item) => item.resourceId === route.resourceId) === routesQuery.data.routes.indexOf(route)
|
||||
return <div className={`apigateway-route-item${selected ? ' selected' : ''}`} key={key}>
|
||||
<button className="apigateway-route-select" type="button" onClick={() => {
|
||||
setPath(route.path)
|
||||
setMethod(route.method)
|
||||
@@ -176,18 +215,34 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
||||
}}>
|
||||
<span className="apigateway-route-method">{route.method}</span>
|
||||
<code>{route.path}</code>
|
||||
<small>{route.functionName ?? 'No Lambda integration'}</small>
|
||||
</button>
|
||||
{deleteConfirmation === key ? <div className="apigateway-route-confirm">
|
||||
<small>{route.integrationType ?? 'No integration'}{route.functionName ? ` · ${route.functionName}` : ''}</small>
|
||||
</button>
|
||||
{isFirstForResource && <button className="button compact" type="button" onClick={() => {
|
||||
setCorsConfirmation({resourceId: route.resourceId, path: route.path})
|
||||
setCorsMethods(resourceMethods)
|
||||
corsMutation.reset()
|
||||
}}>Enable CORS</button>}
|
||||
{deleteConfirmation === key ? <div className="apigateway-route-confirm">
|
||||
<span>Delete {route.method}?</span>
|
||||
<button className="button danger compact" type="button" disabled={deleteRouteMutation.isPending} onClick={() => deleteRouteMutation.mutate(route)}>Delete</button>
|
||||
<button className="button compact" type="button" disabled={deleteRouteMutation.isPending} onClick={() => setDeleteConfirmation(null)}>Cancel</button>
|
||||
</div> : <button className="icon-btn" type="button" aria-label={`Delete ${route.method} ${route.path}`} onClick={() => setDeleteConfirmation(key)}><Trash2 size={13}/></button>}
|
||||
</div>
|
||||
</div> : <button className="icon-btn" type="button" aria-label={`Delete ${route.method} ${route.path}`} onClick={() => setDeleteConfirmation(key)}><Trash2 size={13}/></button>}
|
||||
{corsConfirmation?.resourceId === route.resourceId && isFirstForResource && <div className="apigateway-route-confirm">
|
||||
<strong>Enable CORS for {corsConfirmation.path}</strong>
|
||||
<label className="dynamic-field"><span>Allowed origin</span><input className="input" value={corsOrigin} onChange={(event) => setCorsOrigin(event.target.value)}/></label>
|
||||
<label className="dynamic-field"><span>Allowed headers</span><input className="input" value={corsHeaders} onChange={(event) => setCorsHeaders(event.target.value)}/></label>
|
||||
<div className="dynamic-field"><span>Allowed methods</span>{resourceMethods.map((value) => <label key={value}><input type="checkbox" checked={corsMethods.includes(value)} onChange={() => setCorsMethods((current) => current.includes(value) ? current.filter((method) => method !== value) : [...current, value])}/>{value}</label>)}</div>
|
||||
<span>Updates this resource's OPTIONS preflight and non-proxy method responses, then deploys {stage}.</span>
|
||||
<button className="button primary compact" type="button" disabled={corsMutation.isPending || corsMethods.length === 0} onClick={() => corsMutation.mutate()}>{corsMutation.isPending ? 'Enabling' : 'Confirm enable CORS'}</button>
|
||||
<button className="button compact" type="button" disabled={corsMutation.isPending} onClick={() => setCorsConfirmation(null)}>Cancel</button>
|
||||
{corsMutation.isError && <p className="error-text compact-text">{corsMutation.error instanceof Error ? corsMutation.error.message : 'Unable to enable CORS.'}</p>}
|
||||
</div>}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{corsProxyMethods.length > 0 && <ProxyCorsWarning methods={corsProxyMethods} origin={corsOrigin} headers={corsHeaders}/>}
|
||||
</div>
|
||||
<div className="apigateway-test-panel">
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
@@ -299,6 +354,45 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
||||
)
|
||||
}
|
||||
|
||||
const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
|
||||
|
||||
function ProxyCorsWarning({methods, origin, headers}: {methods: string[]; origin: string; headers: string}) {
|
||||
return <div className="error-text compact-text">
|
||||
<strong>AWS_PROXY methods need their backend to return CORS headers: {methods.join(', ')}.</strong>
|
||||
<pre>{`import "net/http"
|
||||
|
||||
func handler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "${origin}")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "${headers}")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "${methods.join(',')},OPTIONS")
|
||||
if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK); return }
|
||||
// Handle the proxy request.
|
||||
}`}</pre>
|
||||
</div>
|
||||
}
|
||||
|
||||
function invokeEndpoint(runtimeEndpoint?: string): string | null {
|
||||
if (!runtimeEndpoint) return null
|
||||
try {
|
||||
const endpoint = new URL(runtimeEndpoint)
|
||||
if (isDockerBridgeAddress(endpoint.hostname) && typeof window !== 'undefined') {
|
||||
endpoint.hostname = window.location.hostname
|
||||
}
|
||||
return endpoint.toString().replace(/\/$/, '')
|
||||
} catch {
|
||||
return runtimeEndpoint.replace(/\/$/, '')
|
||||
}
|
||||
}
|
||||
|
||||
function isDockerBridgeAddress(hostname: string): boolean {
|
||||
const octets = hostname.split('.').map(Number)
|
||||
return octets.length === 4
|
||||
&& octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)
|
||||
&& octets[0] === 172
|
||||
&& octets[1] >= 16
|
||||
&& octets[1] <= 31
|
||||
}
|
||||
|
||||
function parseHeaders(value: string): Record<string, string> {
|
||||
let parsed: unknown
|
||||
try {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { DynamicFormRenderer } from "@/components/DynamicFormRenderer";
|
||||
import { ResourceInspector } from "@/components/ResourceInspector";
|
||||
import { ResourceTable } from "@/components/ResourceTable";
|
||||
import { StorageObjectBrowser } from "@/components/StorageObjectBrowser";
|
||||
import { S3BucketWebsitePanel } from "@/components/S3BucketWebsitePanel";
|
||||
import { ComputePanel, LaunchInstanceForm } from "@/components/ComputePanel";
|
||||
import { NetworkingPanel } from "@/components/NetworkingPanel";
|
||||
import { getPath } from "@/lib/resourcePath";
|
||||
@@ -848,14 +849,17 @@ export function DynamicResourceView({
|
||||
)}
|
||||
</div>
|
||||
{service === "storage" && (
|
||||
<StorageObjectBrowser
|
||||
cloud={cloud}
|
||||
resource={selected}
|
||||
capabilities={objectCapabilities}
|
||||
runtimeReachable={canUseRuntime}
|
||||
selectedObjectKey={selectedObject?.key}
|
||||
onSelectObject={setSelectedObject}
|
||||
/>
|
||||
<>
|
||||
<StorageObjectBrowser
|
||||
cloud={cloud}
|
||||
resource={selected}
|
||||
capabilities={objectCapabilities}
|
||||
runtimeReachable={canUseRuntime}
|
||||
selectedObjectKey={selectedObject?.key}
|
||||
onSelectObject={setSelectedObject}
|
||||
/>
|
||||
{cloud === "aws" && <S3BucketWebsitePanel resource={selected} runtimeReachable={canUseRuntime} />}
|
||||
</>
|
||||
)}
|
||||
{service === "ecr" && cloud === "aws" && (
|
||||
<EcrImageBrowser resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
@@ -912,6 +916,7 @@ export function DynamicResourceView({
|
||||
cloud={cloud}
|
||||
resource={activeSelected}
|
||||
runtimeReachable={canUseRuntime}
|
||||
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
|
||||
/>
|
||||
)}
|
||||
{service === "events" && (
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {render, screen, waitFor} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {expect, test, vi} from 'vitest'
|
||||
import {S3BucketWebsitePanel} from './S3BucketWebsitePanel'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getS3BucketWebsite: vi.fn(),
|
||||
putS3BucketWebsite: vi.fn(),
|
||||
deleteS3BucketWebsite: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
|
||||
const bucket = {id: 'assets', name: 'assets', cloud: 'aws' as const, service: 'storage' as const, type: 'bucket', region: 'us-west-2', createdAt: null, metadata: {}}
|
||||
|
||||
function renderPanel() {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><S3BucketWebsitePanel resource={bucket} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
test('loads website configuration, shows its regional endpoint, and saves the index-only AWS shape', async () => {
|
||||
api.getS3BucketWebsite.mockResolvedValue({mode: 'index', indexDocument: 'home.html', errorDocument: 'error.html'})
|
||||
api.putS3BucketWebsite.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
expect(await screen.findByDisplayValue('home.html')).toBeVisible()
|
||||
expect(screen.getByText('http://assets.s3-website-us-west-2.localhost:4566')).toBeVisible()
|
||||
await user.clear(screen.getByLabelText(/Error document/))
|
||||
await user.click(screen.getByRole('button', {name: 'Save changes'}))
|
||||
|
||||
await waitFor(() => expect(api.putS3BucketWebsite).toHaveBeenCalledWith('aws', 'assets', {mode: 'index', indexDocument: 'home.html', errorDocument: null}))
|
||||
})
|
||||
|
||||
test('switches to redirect mode and confirms disable before deleting', async () => {
|
||||
api.getS3BucketWebsite.mockResolvedValue({mode: 'index', indexDocument: 'index.html', errorDocument: null})
|
||||
api.putS3BucketWebsite.mockResolvedValue(undefined)
|
||||
api.deleteS3BucketWebsite.mockResolvedValue(undefined)
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
await screen.findByText('Enabled')
|
||||
await user.selectOptions(screen.getByLabelText('Hosting mode'), 'redirect')
|
||||
await user.type(screen.getByLabelText('Redirect host name'), 'www.example.com')
|
||||
await user.selectOptions(screen.getByLabelText('Redirect protocol'), 'https')
|
||||
await user.click(screen.getByRole('button', {name: 'Save changes'}))
|
||||
await waitFor(() => expect(api.putS3BucketWebsite).toHaveBeenCalledWith('aws', 'assets', {mode: 'redirect', hostName: 'www.example.com', protocol: 'https'}))
|
||||
|
||||
await user.click(screen.getByRole('button', {name: 'Disable website hosting'}))
|
||||
expect(screen.getByText('Disable static website hosting?')).toBeVisible()
|
||||
await user.click(screen.getByRole('button', {name: 'Disable'}))
|
||||
await waitFor(() => expect(api.deleteS3BucketWebsite).toHaveBeenCalledWith('aws', 'assets'))
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Globe, Loader2, RefreshCw, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {deleteS3BucketWebsite, getS3BucketWebsite, putS3BucketWebsite} from '@/api/cloudProxyClient'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import type {CloudResource, S3BucketWebsiteConfiguration} from '@/types/resource'
|
||||
|
||||
interface S3BucketWebsitePanelProps {
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
export function S3BucketWebsitePanel({resource, runtimeReachable}: S3BucketWebsitePanelProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const accountId = useAccountId()
|
||||
const [mode, setMode] = useState<'index' | 'redirect'>('index')
|
||||
const [indexDocument, setIndexDocument] = useState('index.html')
|
||||
const [errorDocument, setErrorDocument] = useState('')
|
||||
const [hostName, setHostName] = useState('')
|
||||
const [protocol, setProtocol] = useState<'http' | 'https' | ''>('')
|
||||
const [confirmDisable, setConfirmDisable] = useState(false)
|
||||
const queryKey = ['s3-bucket-website', accountId, resource?.id]
|
||||
const websiteQuery = useQuery({
|
||||
queryKey,
|
||||
queryFn: ({signal}) => getS3BucketWebsite('aws', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const configuration = websiteQuery.data
|
||||
if (!configuration) {
|
||||
setMode('index')
|
||||
setIndexDocument('index.html')
|
||||
setErrorDocument('')
|
||||
setHostName('')
|
||||
setProtocol('')
|
||||
return
|
||||
}
|
||||
setMode(configuration.mode)
|
||||
if (configuration.mode === 'index') {
|
||||
setIndexDocument(configuration.indexDocument)
|
||||
setErrorDocument(configuration.errorDocument ?? '')
|
||||
} else {
|
||||
setHostName(configuration.hostName)
|
||||
setProtocol(configuration.protocol ?? '')
|
||||
}
|
||||
}, [websiteQuery.data, resource?.id])
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (configuration: S3BucketWebsiteConfiguration) => putS3BucketWebsite('aws', resource?.id ?? '', configuration),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({queryKey})
|
||||
},
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => deleteS3BucketWebsite('aws', resource?.id ?? ''),
|
||||
onSuccess: () => {
|
||||
setConfirmDisable(false)
|
||||
void queryClient.setQueryData<S3BucketWebsiteConfiguration | null>(queryKey, null)
|
||||
void queryClient.invalidateQueries({queryKey})
|
||||
},
|
||||
})
|
||||
|
||||
if (!resource || resource.service !== 'storage' || resource.type !== 'bucket') return null
|
||||
const endpoint = `http://${resource.name}.s3-website-${resource.region ?? 'us-east-1'}.localhost:4566`
|
||||
const configuration = mode === 'index'
|
||||
? {mode, indexDocument: indexDocument.trim(), errorDocument: errorDocument.trim() || null}
|
||||
: {mode, hostName: hostName.trim(), protocol: protocol || null}
|
||||
const canSave = !saveMutation.isPending && (mode === 'index' ? Boolean(configuration.indexDocument) : Boolean(configuration.hostName))
|
||||
|
||||
return <section className="table-panel">
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">Static website hosting</p>
|
||||
<h3><Globe size={15}/> {websiteQuery.data ? 'Enabled' : 'Not configured'}</h3>
|
||||
<p className="muted compact-text">Website endpoint: <a href={endpoint} target="_blank" rel="noreferrer"><code>{endpoint}</code></a></p>
|
||||
</div>
|
||||
<button className="icon-btn" type="button" aria-label="Refresh website configuration" onClick={() => websiteQuery.refetch()} disabled={websiteQuery.isFetching}>
|
||||
{websiteQuery.isFetching ? <Loader2 size={13}/> : <RefreshCw size={13}/>}
|
||||
</button>
|
||||
</div>
|
||||
{websiteQuery.isLoading && <p className="muted compact-text">Loading website configuration...</p>}
|
||||
{websiteQuery.isError && <p className="error-text compact-text">{websiteQuery.error instanceof Error ? websiteQuery.error.message : 'Unable to load website configuration.'}</p>}
|
||||
<form className="resource-create-inline" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (canSave) saveMutation.mutate(configuration)
|
||||
}}>
|
||||
<label className="dynamic-field" htmlFor="s3-website-mode">
|
||||
<span>Hosting mode</span>
|
||||
<select id="s3-website-mode" className="input" value={mode} onChange={(event) => setMode(event.target.value as 'index' | 'redirect')} disabled={saveMutation.isPending}>
|
||||
<option value="index">Index and error documents</option>
|
||||
<option value="redirect">Redirect all requests</option>
|
||||
</select>
|
||||
</label>
|
||||
{mode === 'index' ? <>
|
||||
<label className="dynamic-field" htmlFor="s3-website-index-document"><span>Index document</span><input id="s3-website-index-document" className="input" value={indexDocument} onChange={(event) => setIndexDocument(event.target.value)} placeholder="index.html" required disabled={saveMutation.isPending}/></label>
|
||||
<label className="dynamic-field" htmlFor="s3-website-error-document"><span>Error document</span><input id="s3-website-error-document" className="input" value={errorDocument} onChange={(event) => setErrorDocument(event.target.value)} placeholder="error.html" disabled={saveMutation.isPending}/><small>Optional.</small></label>
|
||||
</> : <>
|
||||
<label className="dynamic-field" htmlFor="s3-website-redirect-host"><span>Redirect host name</span><input id="s3-website-redirect-host" className="input" value={hostName} onChange={(event) => setHostName(event.target.value)} placeholder="www.example.com" required disabled={saveMutation.isPending}/></label>
|
||||
<label className="dynamic-field" htmlFor="s3-website-redirect-protocol"><span>Redirect protocol</span><select id="s3-website-redirect-protocol" className="input" value={protocol} onChange={(event) => setProtocol(event.target.value as 'http' | 'https' | '')} disabled={saveMutation.isPending}><option value="">Preserve request protocol</option><option value="http">HTTP</option><option value="https">HTTPS</option></select></label>
|
||||
</>}
|
||||
<button className="button primary" type="submit" disabled={!canSave}>{saveMutation.isPending ? <Loader2 size={13}/> : null}{websiteQuery.data ? 'Save changes' : 'Enable website hosting'}</button>
|
||||
</form>
|
||||
{saveMutation.isError && <p className="error-text compact-text">{saveMutation.error instanceof Error ? saveMutation.error.message : 'Unable to save website configuration.'}</p>}
|
||||
{websiteQuery.data && (confirmDisable ? <div className="apigateway-route-confirm"><span>Disable static website hosting?</span><button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate()}>{deleteMutation.isPending ? 'Disabling' : 'Disable'}</button><button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setConfirmDisable(false)}>Cancel</button></div> : <button className="button danger compact" type="button" onClick={() => setConfirmDisable(true)}><Trash2 size={13}/> Disable website hosting</button>)}
|
||||
{deleteMutation.isError && <p className="error-text compact-text">{deleteMutation.error instanceof Error ? deleteMutation.error.message : 'Unable to disable website hosting.'}</p>}
|
||||
</section>
|
||||
}
|
||||
Reference in New Issue
Block a user