feat: add SQS + EventBridge

This commit is contained in:
Zakaria
2026-09-24 22:34:04 -04:00
parent 9bbe80b5f2
commit 1b129e9dcf
37 changed files with 2902 additions and 66 deletions
@@ -35,11 +35,16 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
const [folderName, setFolderName] = useState('')
const [createFolderOpen, setCreateFolderOpen] = useState(false)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const [deleteFolderConfirm, setDeleteFolderConfirm] = useState<string | null>(null)
const [emptyBucketConfirm, setEmptyBucketConfirm] = useState(false)
const [emptyBucketText, setEmptyBucketText] = useState('')
const [copyObject, setCopyObject] = useState<StorageObject | null>(null)
const [search, setSearch] = useState('')
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set())
const [bulkDeleting, setBulkDeleting] = useState(false)
const [previewObject, setPreviewObject] = useState<StorageObject | null>(null)
const [folderUpload, setFolderUpload] = useState<{completed: number; total: number; failed: number} | null>(null)
const [folderUploadNotice, setFolderUploadNotice] = useState<string | null>(null)
const resolvedCapabilities = withRuntimeState(normalizeCapabilities(capabilities), runtimeReachable)
const uploadCapability = capabilityFor(resolvedCapabilities, 'upload')
@@ -69,10 +74,15 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
setFolderName('')
setCreateFolderOpen(false)
setDeleteConfirm(null)
setDeleteFolderConfirm(null)
setEmptyBucketConfirm(false)
setEmptyBucketText('')
setCopyObject(null)
setSearch('')
setSelectedKeys(new Set())
setPreviewObject(null)
setFolderUpload(null)
setFolderUploadNotice(null)
onSelectObject(undefined)
}, [resource?.id, onSelectObject])
@@ -91,6 +101,27 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
onSuccess: () => qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}),
})
const folderUploadMut = useMutation({
mutationFn: async (files: File[]) => {
if (!resource) return
const basePrefix = uploadPrefix || prefix
setFolderUpload({completed: 0, total: files.length, failed: 0})
let completed = 0
let failed = 0
for (const file of files) {
const relativePath = (file as File & {webkitRelativePath?: string}).webkitRelativePath || file.name
try {
await uploadStorageObject(cloud, resource.id, `${basePrefix}${relativePath}`, file)
completed++
} catch {
failed++
}
setFolderUpload({completed, total: files.length, failed})
}
},
onSuccess: () => qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]}),
})
const createFolderMut = useMutation({
mutationFn: async (name: string) => {
if (!resource) return
@@ -115,6 +146,31 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
},
})
const deleteFolderMut = useMutation({
mutationFn: async (folder: StorageObject) => {
if (!resource) return
await deleteFolderContents(cloud, resource.id, folder.key)
},
onSuccess: (_, folder) => {
if (selectedObjectKey?.startsWith(folder.key)) onSelectObject(undefined)
setDeleteFolderConfirm(null)
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
},
})
const emptyBucketMut = useMutation({
mutationFn: async () => {
if (!resource) return
await emptyBucket(cloud, resource.id)
},
onSuccess: () => {
setEmptyBucketConfirm(false)
setEmptyBucketText('')
onSelectObject(undefined)
void qc.invalidateQueries({queryKey: ['storage-objects', cloud, resource?.id]})
},
})
const moveCopyMut = useMutation({
mutationFn: async ({srcKey, destKey, destResourceId, mode}: {
srcKey: string; destKey: string; destResourceId?: string; mode: 'move' | 'copy'
@@ -137,6 +193,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
setSearch('')
setSelectedKeys(new Set())
setDeleteConfirm(null)
setDeleteFolderConfirm(null)
setPreviewObject(null)
onSelectObject(undefined)
}
@@ -181,7 +238,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
const allSelected = filteredFiles.length > 0 && filteredFiles.every((f) => selectedKeys.has(f.key))
const someSelected = !allSelected && filteredFiles.some((f) => selectedKeys.has(f.key))
const error = query.error ?? uploadMut.error ?? createFolderMut.error ?? deleteMut.error ?? moveCopyMut.error
const error = query.error ?? uploadMut.error ?? createFolderMut.error ?? deleteMut.error ?? deleteFolderMut.error ?? emptyBucketMut.error ?? moveCopyMut.error
return (
<section className="object-browser">
@@ -224,6 +281,12 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
Back
</button>
)}
{cloud === 'aws' && (
<button className="button danger" type="button" disabled={emptyBucketMut.isPending} onClick={() => setEmptyBucketConfirm(true)}>
<Trash2 size={14}/>
Empty bucket
</button>
)}
{createFolderCapability && (
<button className="button" type="button" disabled={!canCreateFolder} title={createFolderCapability.reason} onClick={() => setCreateFolderOpen((open) => !open)}>
<Folder size={14}/>
@@ -267,6 +330,42 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
</button>
</form>
)}
{emptyBucketConfirm && (
<form className="object-create-folder" onSubmit={(event) => {
event.preventDefault()
if (emptyBucketText === resource.name) emptyBucketMut.mutate()
}}>
<label>
<span>Type <code>{resource.name}</code> to permanently delete every object and folder in this bucket.</span>
<input className="input" value={emptyBucketText} onChange={(event) => setEmptyBucketText(event.target.value)} placeholder={resource.name}/>
</label>
<button className="button danger" type="submit" disabled={emptyBucketMut.isPending || emptyBucketText !== resource.name}>
<Trash2 size={14}/>
{emptyBucketMut.isPending ? 'Emptying bucket' : 'Empty bucket'}
</button>
<button className="button" type="button" disabled={emptyBucketMut.isPending} onClick={() => {
setEmptyBucketConfirm(false)
setEmptyBucketText('')
}}>Cancel</button>
</form>
)}
{folderUpload && <p className={folderUpload.failed > 0 ? 'error-text compact-text' : 'muted compact-text'}>
Folder upload: {folderUpload.completed} of {folderUpload.total} files uploaded{folderUpload.failed > 0 ? `, ${folderUpload.failed} failed` : ''}.
</p>}
{folderUploadNotice && <p className="error-text compact-text">{folderUploadNotice}</p>}
{canUpload && <div className="object-upload-dropzone" onDragOver={(event) => event.preventDefault()} onDrop={(event) => {
event.preventDefault()
void droppedFiles(event.dataTransfer).then((files) => {
if (files.length > 0) {
setFolderUploadNotice(null)
folderUploadMut.mutate(files)
} else {
setFolderUploadNotice('Drop files or a folder from your desktop to upload them.')
}
})
}}>
<Folder size={18}/><strong>Drop files or folders here</strong><span>Folder paths are preserved under the current upload prefix.</span>
</div>}
{/* ── Error ── */}
{error && (
@@ -348,10 +447,25 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
<ChevronRight size={12}/>
</span>
</td>
<td>{object.type}</td>
<td>Folder</td>
<td>—</td>
<td>{object.lastModified ?? '—'}</td>
<td/>
<td className="table-actions">
{canDelete && deleteFolderConfirm === object.key ? (
<span className="object-folder-confirm">
<button className="button danger compact" disabled={deleteFolderMut.isPending} onClick={() => deleteFolderMut.mutate(object)}>
Delete folder
</button>
<button className="button compact" disabled={deleteFolderMut.isPending} onClick={() => setDeleteFolderConfirm(null)}>
Cancel
</button>
</span>
) : deleteCapability && (
<button className="icon-btn danger" disabled={!canDelete} title={deleteCapability.reason ?? `Delete ${object.name} and all contents`} aria-label={`Delete ${object.name} folder`} onClick={() => setDeleteFolderConfirm(object.key)}>
<Trash2 size={13}/>
</button>
)}
</td>
</tr>
))}
{filteredFiles.map((object) => {
@@ -389,7 +503,7 @@ export function StorageObjectBrowser({cloud, resource, capabilities = [], runtim
{object.name}
</span>
</td>
<td>{object.type}</td>
<td>{objectTypeLabel(object.name)}</td>
<td>{object.size === null ? '—' : formatBytes(object.size)}</td>
<td>{object.lastModified ?? '—'}</td>
<td className="table-actions">
@@ -639,3 +753,59 @@ function parentPrefix(prefix: string): string {
return segments.length ? `${segments.join('/')}/` : ''
}
function objectTypeLabel(name: string): string {
const extension = name.split('.').pop()?.trim().toUpperCase()
return extension && extension !== name.toUpperCase() ? `${extension} file` : 'File'
}
interface DroppedEntry {
isFile: boolean
isDirectory: boolean
name: string
file(callback: (file: File) => void, errorCallback?: (error: DOMException) => void): void
createReader(): {readEntries(callback: (entries: DroppedEntry[]) => void, errorCallback?: (error: DOMException) => void): void}
}
async function droppedFiles(dataTransfer: DataTransfer): Promise<File[]> {
const items = Array.from(dataTransfer.items)
const entries = items.map((item) => (item as DataTransferItem & {webkitGetAsEntry?: () => unknown}).webkitGetAsEntry?.() as DroppedEntry | null | undefined).filter((entry): entry is DroppedEntry => entry !== null && entry !== undefined)
if (entries.length === 0) return Array.from(dataTransfer.files)
const files: File[] = []
for (const entry of entries) files.push(...await filesInEntry(entry))
return files
}
async function filesInEntry(entry: DroppedEntry, prefix = ''): Promise<File[]> {
const files: File[] = []
const path = `${prefix}${entry.name}`
if (entry.isFile) {
const file = await new Promise<File>((resolve, reject) => entry.file(resolve, reject))
Object.defineProperty(file, 'webkitRelativePath', {value: path})
return [file]
}
if (!entry.isDirectory) return files
const reader = entry.createReader()
let entries: DroppedEntry[]
do {
entries = await new Promise<DroppedEntry[]>((resolve, reject) => reader.readEntries(resolve, reject))
for (const child of entries) files.push(...await filesInEntry(child, `${path}/`))
} while (entries.length > 0)
return files
}
async function deleteFolderContents(cloud: CloudProvider, bucketName: string, prefix: string): Promise<void> {
const listing = await listStorageObjects(cloud, bucketName, prefix)
const folders = listing.objects.filter((object) => object.type === 'folder')
const objects = listing.objects.filter((object) => object.type === 'object')
await Promise.all(folders.map((folder) => deleteFolderContents(cloud, bucketName, folder.key)))
await Promise.all(objects.map((object) => deleteStorageObject(cloud, bucketName, object.key)))
await deleteStorageObject(cloud, bucketName, prefix)
}
async function emptyBucket(cloud: CloudProvider, bucketName: string): Promise<void> {
const listing = await listStorageObjects(cloud, bucketName, '')
const folders = listing.objects.filter((object) => object.type === 'folder')
const objects = listing.objects.filter((object) => object.type === 'object')
await Promise.all(folders.map((folder) => deleteFolderContents(cloud, bucketName, folder.key)))
await Promise.all(objects.map((object) => deleteStorageObject(cloud, bucketName, object.key)))
}