feat(api): add EFS file systems to the cloud explorer
CI / checks (pull_request) Successful in 28m26s
CI / checks (pull_request) Successful in 28m26s
Create file systems (with a mount target in each default subnet unless asked not to), add and remove mount targets, create and delete access points, and attach a file system to a Lambda function through an access point. A function not yet in a VPC is put in the file system's mount target subnets with the default security group, as Lambda requires. TheCloud's mounts are real, so two functions on one access point share files. Deleting a file system removes its access points and mount targets first, and is refused while a function still mounts it.
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import {RefreshCw} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Link} from 'react-router-dom'
|
||||
import {
|
||||
attachFileSystemToFunction,
|
||||
createAccessPoint,
|
||||
createMountTarget,
|
||||
deleteAccessPoint,
|
||||
deleteMountTarget,
|
||||
detachFileSystemFromFunction,
|
||||
getCloudResource,
|
||||
listCloudResources,
|
||||
} from '@/api/cloudProxyClient'
|
||||
import {cloudQueryKeys} from '@/api/queries/cloudQueries'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
}
|
||||
|
||||
type MountTarget = {id: string; subnetId: string | null; availabilityZone: string | null; ipAddress: string | null; state: string | null}
|
||||
type AccessPoint = {id: string; arn: string | null; name: string | null; path: string; uid: number | null; gid: number | null; permissions: string | null; state: string | null}
|
||||
type MountedFunction = {name: string; accessPointId: string | null; mountPath: string | null}
|
||||
|
||||
const EMPTY_ACCESS_POINT = {name: '', path: '/', uid: '1000', gid: '1000', permissions: '755'}
|
||||
|
||||
export function FileSystemPanel({resource, runtimeReachable}: Props) {
|
||||
const accountId = useAccountId()
|
||||
const queryClient = useQueryClient()
|
||||
const [subnetId, setSubnetId] = useState('')
|
||||
const [accessPointForm, setAccessPointForm] = useState(EMPTY_ACCESS_POINT)
|
||||
const [attachForm, setAttachForm] = useState({accessPointId: '', functionName: '', mountPath: '/mnt/data'})
|
||||
const [notice, setNotice] = useState('')
|
||||
const detailKey = ['efs-file-system', accountId, resource?.id]
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: detailKey,
|
||||
queryFn: ({signal}) => getCloudResource('aws', 'filesystems', resource?.id ?? '', signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
const functionsQuery = useQuery({
|
||||
queryKey: cloudQueryKeys.resources('aws', 'serverless'),
|
||||
queryFn: ({signal}) => listCloudResources('aws', 'serverless', undefined, signal),
|
||||
enabled: Boolean(resource) && runtimeReachable,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setNotice('')
|
||||
setSubnetId('')
|
||||
setAccessPointForm(EMPTY_ACCESS_POINT)
|
||||
setAttachForm({accessPointId: '', functionName: '', mountPath: '/mnt/data'})
|
||||
}, [resource?.id])
|
||||
|
||||
const settle = (updated: CloudResource, message: string) => {
|
||||
queryClient.setQueryData(detailKey, updated)
|
||||
setNotice(message)
|
||||
return queryClient.invalidateQueries({queryKey: cloudQueryKeys.resources('aws', 'filesystems')})
|
||||
}
|
||||
const id = resource?.id ?? ''
|
||||
const addMountTarget = useMutation({
|
||||
mutationFn: () => createMountTarget('aws', id, subnetId.trim()),
|
||||
onSuccess: (updated) => { setSubnetId(''); return settle(updated, `Added a mount target in ${subnetId.trim()}.`) },
|
||||
})
|
||||
const removeMountTarget = useMutation({
|
||||
mutationFn: (mountTargetId: string) => deleteMountTarget('aws', id, mountTargetId),
|
||||
onSuccess: (updated, mountTargetId) => settle(updated, `Removed mount target ${mountTargetId}.`),
|
||||
})
|
||||
const addAccessPoint = useMutation({
|
||||
mutationFn: () => createAccessPoint('aws', id, {
|
||||
...(accessPointForm.name.trim() ? {name: accessPointForm.name.trim()} : {}),
|
||||
path: accessPointForm.path.trim(),
|
||||
uid: Number(accessPointForm.uid),
|
||||
gid: Number(accessPointForm.gid),
|
||||
permissions: accessPointForm.permissions.trim(),
|
||||
}),
|
||||
onSuccess: (updated) => { setAccessPointForm(EMPTY_ACCESS_POINT); return settle(updated, `Created an access point at ${accessPointForm.path.trim()}.`) },
|
||||
})
|
||||
const removeAccessPoint = useMutation({
|
||||
mutationFn: (accessPointId: string) => deleteAccessPoint('aws', id, accessPointId),
|
||||
onSuccess: (updated, accessPointId) => settle(updated, `Deleted access point ${accessPointId}.`),
|
||||
})
|
||||
const attach = useMutation({
|
||||
mutationFn: () => attachFileSystemToFunction('aws', id, {...attachForm, mountPath: attachForm.mountPath.trim()}),
|
||||
onSuccess: (updated) => settle(updated, `${attachForm.functionName} now sees this file system at ${attachForm.mountPath.trim()}.`),
|
||||
})
|
||||
const detach = useMutation({
|
||||
mutationFn: (functionName: string) => detachFileSystemFromFunction('aws', id, functionName),
|
||||
onSuccess: (updated, functionName) => settle(updated, `Detached ${functionName}.`),
|
||||
})
|
||||
|
||||
if (!resource) {
|
||||
return <section className="table-panel"><div className="empty compact"><h3>Select a file system</h3><p>Choose a file system to manage its mount targets and access points, and attach it to Lambda functions.</p></div></section>
|
||||
}
|
||||
|
||||
const fileSystem = detailQuery.data ?? resource
|
||||
const metadata = fileSystem.metadata
|
||||
const mountTargets = Array.isArray(metadata.mountTargets) ? metadata.mountTargets as MountTarget[] : []
|
||||
const accessPoints = Array.isArray(metadata.accessPoints) ? metadata.accessPoints as AccessPoint[] : []
|
||||
const mounted = Array.isArray(metadata.functions) ? metadata.functions as MountedFunction[] : []
|
||||
const functionNames = (functionsQuery.data ?? []).map((fn) => fn.name).sort()
|
||||
const mutations = [addMountTarget, removeMountTarget, addAccessPoint, removeAccessPoint, attach, detach]
|
||||
const disabled = !runtimeReachable || mutations.some((mutation) => mutation.isPending)
|
||||
const error = detailQuery.error ?? mutations.find((mutation) => mutation.error)?.error
|
||||
const accessPointLabel = (accessPointId: string | null) => {
|
||||
const accessPoint = accessPoints.find((candidate) => candidate.id === accessPointId)
|
||||
return accessPoint ? `${accessPoint.name ?? accessPoint.id} (${accessPoint.path})` : accessPointId ?? '—'
|
||||
}
|
||||
const run = (action: () => void) => { setNotice(''); action() }
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}} aria-label={`File system ${fileSystem.name}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">EFS file system</p><span className="muted mono">{String(metadata.fileSystemId ?? fileSystem.id)}</span><span className="muted"> · {String(metadata.throughputMode ?? '')} · {metadata.encrypted ? 'encrypted' : 'not encrypted'}</span></div>
|
||||
<div className="cdn-actions">
|
||||
<button className="button" type="button" disabled={!runtimeReachable || detailQuery.isFetching} onClick={() => { void detailQuery.refetch() }}><RefreshCw size={14}/>{detailQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Mount targets</p><span className="muted">Functions and tasks reach the file system through a mount target in their subnet.</span></div></div>
|
||||
{mountTargets.length === 0 ? <p className="muted compact-text efs-note">No mount targets yet. Add one before attaching the file system to a function.</p>
|
||||
: <div className="cognito-table"><table className="table"><thead><tr><th>Mount target</th><th>Subnet</th><th>Zone</th><th>IP address</th><th>State</th><th aria-label="Actions"/></tr></thead><tbody>{mountTargets.map((mountTarget) => (
|
||||
<tr key={mountTarget.id}>
|
||||
<td className="mono">{mountTarget.id}</td>
|
||||
<td className="mono">{mountTarget.subnetId ?? '—'}</td>
|
||||
<td>{mountTarget.availabilityZone ?? '—'}</td>
|
||||
<td className="mono">{mountTarget.ipAddress ?? '—'}</td>
|
||||
<td><code>{mountTarget.state ?? '—'}</code></td>
|
||||
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Remove ${mountTarget.id}`} onClick={() => run(() => {
|
||||
if (window.confirm(`Remove mount target ${mountTarget.id}?`)) removeMountTarget.mutate(mountTarget.id)
|
||||
})}>Remove</button></td>
|
||||
</tr>
|
||||
))}</tbody></table></div>}
|
||||
<form className="dynamic-form efs-inline-form" aria-label="Add mount target" onSubmit={(event) => { event.preventDefault(); run(() => addMountTarget.mutate()) }}>
|
||||
<label className="dynamic-field"><span>Subnet ID</span><input className="input mono" value={subnetId} placeholder="subnet-…" onChange={(event) => setSubnetId(event.target.value)} disabled={disabled} required/></label>
|
||||
<div><button className="button" type="submit" disabled={disabled || !subnetId.trim()}>{addMountTarget.isPending ? 'Adding' : 'Add mount target'}</button></div>
|
||||
</form>
|
||||
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Access points</p><span className="muted">An entry point into one directory, as a fixed POSIX user. Lambda mounts a file system through an access point.</span></div></div>
|
||||
{accessPoints.length === 0 ? <p className="muted compact-text efs-note">No access points yet.</p>
|
||||
: <div className="cognito-table"><table className="table"><thead><tr><th>Access point</th><th>Root directory</th><th>POSIX user</th><th>State</th><th aria-label="Actions"/></tr></thead><tbody>{accessPoints.map((accessPoint) => (
|
||||
<tr key={accessPoint.id}>
|
||||
<td>{accessPoint.name && <>{accessPoint.name}<br/></>}<small className="mono">{accessPoint.id}</small></td>
|
||||
<td className="mono">{accessPoint.path}{accessPoint.permissions ? <small className="muted"> ({accessPoint.permissions})</small> : null}</td>
|
||||
<td className="mono">{accessPoint.uid ?? '—'}:{accessPoint.gid ?? '—'}</td>
|
||||
<td><code>{accessPoint.state ?? '—'}</code></td>
|
||||
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Delete ${accessPoint.id}`} onClick={() => run(() => {
|
||||
if (window.confirm(`Delete access point ${accessPoint.id}? Files stay in the file system.`)) removeAccessPoint.mutate(accessPoint.id)
|
||||
})}>Delete</button></td>
|
||||
</tr>
|
||||
))}</tbody></table></div>}
|
||||
<form className="dynamic-form efs-access-point-form" aria-label="Create access point" onSubmit={(event) => { event.preventDefault(); run(() => addAccessPoint.mutate()) }}>
|
||||
<label className="dynamic-field"><span>Name</span><input className="input" value={accessPointForm.name} onChange={(event) => setAccessPointForm({...accessPointForm, name: event.target.value})} disabled={disabled}/></label>
|
||||
<label className="dynamic-field"><span>Root directory</span><input className="input mono" value={accessPointForm.path} onChange={(event) => setAccessPointForm({...accessPointForm, path: event.target.value})} disabled={disabled} required pattern="/.*" title="Start with /"/></label>
|
||||
<label className="dynamic-field"><span>User ID</span><input className="input" type="number" min={0} value={accessPointForm.uid} onChange={(event) => setAccessPointForm({...accessPointForm, uid: event.target.value})} disabled={disabled} required/></label>
|
||||
<label className="dynamic-field"><span>Group ID</span><input className="input" type="number" min={0} value={accessPointForm.gid} onChange={(event) => setAccessPointForm({...accessPointForm, gid: event.target.value})} disabled={disabled} required/></label>
|
||||
<label className="dynamic-field"><span>Permissions</span><input className="input mono" value={accessPointForm.permissions} onChange={(event) => setAccessPointForm({...accessPointForm, permissions: event.target.value})} disabled={disabled} required pattern="[0-7]{3,4}" title="Octal, such as 755"/></label>
|
||||
<div><button className="button" type="submit" disabled={disabled}>{addAccessPoint.isPending ? 'Creating' : 'Create access point'}</button></div>
|
||||
</form>
|
||||
|
||||
<div className="input-row resource-table-bar"><div><p className="eyebrow">Lambda functions</p><span className="muted">Functions that mount this file system share its files. A function can mount one file system.</span></div></div>
|
||||
{mounted.length === 0 ? <p className="muted compact-text efs-note">No function mounts this file system.</p>
|
||||
: <div className="cognito-table"><table className="table"><thead><tr><th>Function</th><th>Mount path</th><th>Access point</th><th aria-label="Actions"/></tr></thead><tbody>{mounted.map((fn) => (
|
||||
<tr key={fn.name}>
|
||||
<td><Link to="/cloud-explorer/aws/serverless" className="mono">{fn.name}</Link></td>
|
||||
<td className="mono">{fn.mountPath ?? '—'}</td>
|
||||
<td>{accessPointLabel(fn.accessPointId)}</td>
|
||||
<td className="efs-row-actions"><button className="button compact danger" type="button" disabled={disabled} aria-label={`Detach ${fn.name}`} onClick={() => run(() => {
|
||||
if (window.confirm(`Detach the file system from ${fn.name}?`)) detach.mutate(fn.name)
|
||||
})}>Detach</button></td>
|
||||
</tr>
|
||||
))}</tbody></table></div>}
|
||||
{accessPoints.length > 0 && <form className="dynamic-form efs-access-point-form" aria-label="Attach to a function" onSubmit={(event) => { event.preventDefault(); run(() => attach.mutate()) }}>
|
||||
<label className="dynamic-field"><span>Function</span><select className="input" value={attachForm.functionName} onChange={(event) => setAttachForm({...attachForm, functionName: event.target.value})} disabled={disabled} required>
|
||||
<option value="">{functionNames.length === 0 ? 'No functions yet' : 'Choose a function'}</option>
|
||||
{functionNames.map((name) => <option key={name} value={name}>{name}</option>)}
|
||||
</select></label>
|
||||
<label className="dynamic-field"><span>Access point</span><select className="input" value={attachForm.accessPointId} onChange={(event) => setAttachForm({...attachForm, accessPointId: event.target.value})} disabled={disabled} required>
|
||||
<option value="">Choose an access point</option>
|
||||
{accessPoints.map((accessPoint) => <option key={accessPoint.id} value={accessPoint.id}>{accessPointLabel(accessPoint.id)}</option>)}
|
||||
</select></label>
|
||||
<label className="dynamic-field"><span>Mount path</span><input className="input mono" value={attachForm.mountPath} onChange={(event) => setAttachForm({...attachForm, mountPath: event.target.value})} disabled={disabled} required pattern="/mnt/[a-zA-Z0-9\-_.]+" title="Under /mnt, such as /mnt/data"/></label>
|
||||
<div><button className="button" type="submit" disabled={disabled || mountTargets.length === 0} title={mountTargets.length === 0 ? 'Add a mount target first' : undefined}>{attach.isPending ? 'Attaching' : 'Attach'}</button></div>
|
||||
</form>}
|
||||
{accessPoints.length > 0 && <p className="muted compact-text efs-note">Lambda needs a VPC to mount a file system. A function not yet in one is put in this file system's mount target subnets with the default security group.</p>}
|
||||
|
||||
{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>
|
||||
}
|
||||
Reference in New Issue
Block a user