diff --git a/apps/platform-dash/src/app/api/s3/buckets/route.ts b/apps/platform-dash/src/app/api/s3/buckets/route.ts new file mode 100644 index 0000000..c0ced3c --- /dev/null +++ b/apps/platform-dash/src/app/api/s3/buckets/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { + S3Client, + ListBucketsCommand, + CreateBucketCommand, + DeleteBucketCommand, +} from "@aws-sdk/client-s3"; + +function getClient() { + return new S3Client({ + endpoint: process.env.S3_ENDPOINT || "http://garage.platform.svc:3900", + region: process.env.S3_REGION || "garage", + credentials: { + accessKeyId: process.env.S3_ACCESS_KEY_ID || "", + secretAccessKey: process.env.S3_SECRET_ACCESS_KEY || "", + }, + forcePathStyle: true, + }); +} + +export async function GET() { + const client = getClient(); + try { + const result = await client.send(new ListBucketsCommand({})); + const buckets = (result.Buckets || []).map((b) => b.Name).filter(Boolean); + return NextResponse.json({ buckets }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "Failed to list buckets"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} + +export async function PUT(request: NextRequest) { + const name = request.nextUrl.searchParams.get("name"); + if (!name) return NextResponse.json({ error: "name required" }, { status: 400 }); + + const client = getClient(); + try { + await client.send(new CreateBucketCommand({ Bucket: name })); + return NextResponse.json({ ok: true }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "Failed to create bucket"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} + +export async function DELETE(request: NextRequest) { + const name = request.nextUrl.searchParams.get("name"); + if (!name) return NextResponse.json({ error: "name required" }, { status: 400 }); + + const client = getClient(); + try { + await client.send(new DeleteBucketCommand({ Bucket: name })); + return NextResponse.json({ ok: true }); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : "Failed to delete bucket"; + return NextResponse.json({ error: msg }, { status: 500 }); + } +} diff --git a/apps/platform-dash/src/app/api/s3/route.ts b/apps/platform-dash/src/app/api/s3/route.ts index ad5a16b..7bced46 100644 --- a/apps/platform-dash/src/app/api/s3/route.ts +++ b/apps/platform-dash/src/app/api/s3/route.ts @@ -6,7 +6,7 @@ import { DeleteObjectCommand, } from "@aws-sdk/client-s3"; -const BUCKET = process.env.S3_BUCKET || "artifacts"; +const DEFAULT_BUCKET = process.env.S3_BUCKET || "artifacts"; function getClient() { return new S3Client({ @@ -20,22 +20,20 @@ function getClient() { }); } -// GET — list objects export async function GET(request: NextRequest) { const prefix = request.nextUrl.searchParams.get("prefix") || ""; + const bucket = request.nextUrl.searchParams.get("bucket") || DEFAULT_BUCKET; const client = getClient(); try { const result = await client.send( - new ListObjectsV2Command({ Bucket: BUCKET, Prefix: prefix || undefined }) + new ListObjectsV2Command({ Bucket: bucket, Prefix: prefix || undefined }) ); - const objects = (result.Contents || []).map((obj) => ({ key: obj.Key, size: obj.Size, lastModified: obj.LastModified?.toISOString(), })); - return NextResponse.json({ objects }); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "S3 error"; @@ -43,12 +41,10 @@ export async function GET(request: NextRequest) { } } -// PUT — upload object export async function PUT(request: NextRequest) { const key = request.nextUrl.searchParams.get("key"); - if (!key) { - return NextResponse.json({ error: "key required" }, { status: 400 }); - } + const bucket = request.nextUrl.searchParams.get("bucket") || DEFAULT_BUCKET; + if (!key) return NextResponse.json({ error: "key required" }, { status: 400 }); const body = await request.arrayBuffer(); const client = getClient(); @@ -56,7 +52,7 @@ export async function PUT(request: NextRequest) { try { await client.send( new PutObjectCommand({ - Bucket: BUCKET, + Bucket: bucket, Key: key, Body: Buffer.from(body), ContentType: request.headers.get("content-type") || "application/octet-stream", @@ -69,19 +65,15 @@ export async function PUT(request: NextRequest) { } } -// DELETE — remove object export async function DELETE(request: NextRequest) { const key = request.nextUrl.searchParams.get("key"); - if (!key) { - return NextResponse.json({ error: "key required" }, { status: 400 }); - } + const bucket = request.nextUrl.searchParams.get("bucket") || DEFAULT_BUCKET; + if (!key) return NextResponse.json({ error: "key required" }, { status: 400 }); const client = getClient(); try { - await client.send( - new DeleteObjectCommand({ Bucket: BUCKET, Key: key }) - ); + await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key })); return NextResponse.json({ ok: true }); } catch (e: unknown) { const msg = e instanceof Error ? e.message : "Delete failed"; diff --git a/apps/platform-dash/src/app/page.tsx b/apps/platform-dash/src/app/page.tsx index 295095f..2b2e2c8 100644 --- a/apps/platform-dash/src/app/page.tsx +++ b/apps/platform-dash/src/app/page.tsx @@ -3,20 +3,24 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { tokens, - Label, Mono, Btn, Panel, PanelHeader, - Divider, } from "@/components/design-system"; +// ─── TYPES ─────────────────────────────────────────────────────────────────── + interface S3Object { key: string; size: number; lastModified: string; } +type Tab = "objects" | "buckets"; + +// ─── HELPERS ───────────────────────────────────────────────────────────────── + function formatBytes(bytes: number): string { if (bytes === 0) return "0 B"; const k = 1024; @@ -33,27 +37,98 @@ function formatDate(iso: string): string { }); } -function pathParts(key: string): { dir: string; name: string } { - const i = key.lastIndexOf("/"); - return i === -1 - ? { dir: "", name: key } - : { dir: key.slice(0, i + 1), name: key.slice(i + 1) }; -} +// ─── MAIN ──────────────────────────────────────────────────────────────────── export default function PlatformDash() { + const [tab, setTab] = useState("objects"); + + return ( +
+
+

+ PLATFORM DASHBOARD +

+ + garage object store + +
+ + {/* Tabs */} +
+ {(["objects", "buckets"] as Tab[]).map((t) => ( + + ))} +
+ + {tab === "objects" ? : } +
+ ); +} + +// ─── OBJECT BROWSER ────────────────────────────────────────────────────────── + +function ObjectBrowser() { + const [bucket, setBucket] = useState("artifacts"); + const [buckets, setBuckets] = useState([]); const [objects, setObjects] = useState([]); const [prefix, setPrefix] = useState(""); const [loading, setLoading] = useState(true); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); const [dragOver, setDragOver] = useState(false); + const [showNewDir, setShowNewDir] = useState(false); + const [newDirName, setNewDirName] = useState(""); const fileRef = useRef(null); - const load = useCallback(async (pfx: string) => { + // Load bucket list + useEffect(() => { + fetch("/api/s3/buckets") + .then((r) => r.json()) + .then((data) => setBuckets(data.buckets || [])) + .catch(() => {}); + }, []); + + const load = useCallback(async (pfx: string, b?: string) => { setLoading(true); setError(null); + const targetBucket = b ?? bucket; try { - const res = await fetch(`/api/s3?prefix=${encodeURIComponent(pfx)}`); + const res = await fetch(`/api/s3?prefix=${encodeURIComponent(pfx)}&bucket=${encodeURIComponent(targetBucket)}`); if (!res.ok) throw new Error(await res.text()); const data = await res.json(); setObjects(data.objects || []); @@ -63,11 +138,11 @@ export default function PlatformDash() { } finally { setLoading(false); } - }, []); + }, [bucket]); useEffect(() => { load(""); }, [load]); - // Derive "folders" and files from current prefix + // Derive folders and files const folders = new Set(); const files: S3Object[] = []; for (const obj of objects) { @@ -89,7 +164,7 @@ export default function PlatformDash() { try { for (const file of Array.from(fileList)) { const key = prefix + file.name; - const res = await fetch(`/api/s3?key=${encodeURIComponent(key)}`, { + const res = await fetch(`/api/s3?key=${encodeURIComponent(key)}&bucket=${encodeURIComponent(bucket)}`, { method: "PUT", headers: { "content-type": file.type || "application/octet-stream" }, body: file, @@ -108,7 +183,7 @@ export default function PlatformDash() { if (!confirm(`Delete ${key}?`)) return; setError(null); try { - const res = await fetch(`/api/s3?key=${encodeURIComponent(key)}`, { + const res = await fetch(`/api/s3?key=${encodeURIComponent(key)}&bucket=${encodeURIComponent(bucket)}`, { method: "DELETE", }); if (!res.ok) throw new Error(await res.text()); @@ -118,6 +193,26 @@ export default function PlatformDash() { } } + async function handleCreateDir() { + const name = newDirName.trim().replace(/\/+$/, ""); + if (!name) return; + setError(null); + try { + const key = prefix + name + "/.keep"; + const res = await fetch(`/api/s3?key=${encodeURIComponent(key)}&bucket=${encodeURIComponent(bucket)}`, { + method: "PUT", + headers: { "content-type": "application/x-empty" }, + body: "", + }); + if (!res.ok) throw new Error(await res.text()); + setNewDirName(""); + setShowNewDir(false); + await load(prefix); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to create directory"); + } + } + function handleDrop(e: React.DragEvent) { e.preventDefault(); setDragOver(false); @@ -125,168 +220,289 @@ export default function PlatformDash() { } return ( -
-
-

- PLATFORM DASHBOARD -

- - garage object store · artifacts bucket - -
- - - -
- load(prefix)} variant="default">REFRESH - fileRef.current?.click()} - variant="primary" - disabled={uploading} + + +
+ {/* Bucket selector */} + {buckets.length > 1 && ( + handleUpload(e.target.files)} - /> -
-
+ {buckets.map((b) => )} + + )} + setShowNewDir(!showNewDir)} variant="default">NEW DIR + load(prefix)} variant="default">REFRESH + fileRef.current?.click()} variant="primary" disabled={uploading}> + {uploading ? "UPLOADING..." : "UPLOAD"} + + handleUpload(e.target.files)} /> +
+
- {/* Breadcrumbs */} + {/* New directory input */} + {showNewDir && (
- load("")} + NEW FOLDER: + setNewDirName(e.target.value)} + placeholder="folder-name" + onKeyDown={(e) => e.key === "Enter" && handleCreateDir()} style={{ - cursor: "pointer", - color: prefix ? tokens.color.accent : tokens.color.text0, - fontSize: tokens.size.sm, + flex: 1, height: 28, fontSize: tokens.size.sm, + background: tokens.color.bg0, border: `1px solid ${tokens.color.border0}`, + color: tokens.color.text0, fontFamily: tokens.font.mono, + padding: `0 ${tokens.space[2]}px`, outline: "none", }} - > - / - - {breadcrumbs.map((seg, i) => { - const path = breadcrumbs.slice(0, i + 1).join("/") + "/"; - const isLast = i === breadcrumbs.length - 1; - return ( - - / - !isLast && load(path)} - style={{ - cursor: isLast ? "default" : "pointer", - color: isLast ? tokens.color.text0 : tokens.color.accent, - fontSize: tokens.size.sm, - fontFamily: tokens.font.mono, - }} - > - {seg} - - - ); - })} + /> + CREATE + { setShowNewDir(false); setNewDirName(""); }} variant="default">CANCEL
+ )} - {error && ( -
+ {bucket} + load("")} + style={{ + cursor: "pointer", + color: prefix ? tokens.color.accent : tokens.color.text0, fontSize: tokens.size.sm, fontFamily: tokens.font.mono, - }}> - {error} -
- )} - - {/* Drop zone + listing */} -
{ e.preventDefault(); setDragOver(true); }} - onDragLeave={() => setDragOver(false)} - onDrop={handleDrop} - style={{ - minHeight: 200, - border: dragOver ? `2px dashed ${tokens.color.accent}` : "2px solid transparent", - transition: `border ${tokens.transition.fast}`, }} > - {loading ? ( -
- Loading... -
- ) : ( - <> - {/* Parent dir */} - {prefix && ( - { - const parts = prefix.slice(0, -1).split("/"); - parts.pop(); - load(parts.length ? parts.join("/") + "/" : ""); - }} - /> - )} + / + + {breadcrumbs.map((seg, i) => { + const path = breadcrumbs.slice(0, i + 1).join("/") + "/"; + const isLast = i === breadcrumbs.length - 1; + return ( + + / + !isLast && load(path)} + style={{ + cursor: isLast ? "default" : "pointer", + color: isLast ? tokens.color.text0 : tokens.color.accent, + fontSize: tokens.size.sm, + fontFamily: tokens.font.mono, + }} + > + {seg} + + + ); + })} +
- {/* Folders */} - {Array.from(folders).sort().map((f) => ( - load(f)} - /> - ))} - - {/* Files */} - {files.sort((a, b) => a.key.localeCompare(b.key)).map((obj) => ( - handleDelete(obj.key)} - /> - ))} - - {folders.size === 0 && files.length === 0 && !prefix && ( -
- - Bucket is empty. Drag files here or click UPLOAD. - -
- )} - - )} + {error && ( +
+ {error}
-
-
+ )} + + {/* Drop zone + listing */} +
{ e.preventDefault(); setDragOver(true); }} + onDragLeave={() => setDragOver(false)} + onDrop={handleDrop} + style={{ + minHeight: 200, + border: dragOver ? `2px dashed ${tokens.color.accent}` : "2px solid transparent", + transition: `border ${tokens.transition.fast}`, + }} + > + {loading ? ( +
+ Loading... +
+ ) : ( + <> + {prefix && ( + { + const parts = prefix.slice(0, -1).split("/"); + parts.pop(); + load(parts.length ? parts.join("/") + "/" : ""); + }} + /> + )} + + {Array.from(folders).sort().map((f) => ( + load(f)} /> + ))} + + {files.sort((a, b) => a.key.localeCompare(b.key)).map((obj) => ( + handleDelete(obj.key)} /> + ))} + + {folders.size === 0 && files.length === 0 && !prefix && ( +
+ + Bucket is empty. Drag files here or click UPLOAD. + +
+ )} + + )} +
+ ); } -function Row({ icon, name, onClick }: { icon: string; name: string; onClick: () => void }) { +// ─── BUCKET MANAGER ────────────────────────────────────────────────────────── + +function BucketManager() { + const [buckets, setBuckets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [newName, setNewName] = useState(""); + const [creating, setCreating] = useState(false); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch("/api/s3/buckets"); + if (!res.ok) throw new Error(await res.text()); + const data = await res.json(); + setBuckets(data.buckets || []); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to load buckets"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { load(); }, [load]); + + async function handleCreate() { + const name = newName.trim(); + if (!name) return; + setCreating(true); + setError(null); + try { + const res = await fetch(`/api/s3/buckets?name=${encodeURIComponent(name)}`, { method: "PUT" }); + if (!res.ok) throw new Error(await res.text()); + setNewName(""); + await load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to create bucket"); + } finally { + setCreating(false); + } + } + + async function handleDelete(name: string) { + if (!confirm(`Delete bucket "${name}"? This will fail if the bucket is not empty.`)) return; + setError(null); + try { + const res = await fetch(`/api/s3/buckets?name=${encodeURIComponent(name)}`, { method: "DELETE" }); + if (!res.ok) throw new Error(await res.text()); + await load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : "Failed to delete bucket"); + } + } + + return ( + + + REFRESH + + + {/* Create bucket */} +
+ setNewName(e.target.value)} + placeholder="new-bucket-name" + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + style={{ + flex: 1, height: 28, fontSize: tokens.size.sm, + background: tokens.color.bg0, border: `1px solid ${tokens.color.border0}`, + color: tokens.color.text0, fontFamily: tokens.font.mono, + padding: `0 ${tokens.space[2]}px`, outline: "none", + }} + /> + + {creating ? "CREATING..." : "CREATE BUCKET"} + +
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+ Loading... +
+ ) : buckets.length === 0 ? ( +
+ No buckets. +
+ ) : ( + buckets.map((b) => ( + handleDelete(b)} /> + )) + )} +
+ ); +} + +// ─── ROW COMPONENTS ────────────────────────────────────────────────────────── + +function FolderRow({ icon, name, onClick }: { icon: string; name: string; onClick: () => void }) { const [hover, setHover] = useState(false); return (
void }) { +function FileRow({ obj, onDelete }: { obj: S3Object; onDelete: () => void }) { const [hover, setHover] = useState(false); - const { name } = pathParts(obj.key); + const name = obj.key.split("/").pop() || obj.key; return (
{formatBytes(obj.size)} {formatDate(obj.lastModified)} {hover && ( -
+ <> - DL + DOWNLOAD { e.stopPropagation(); onDelete(); }} @@ -352,11 +570,51 @@ function FileRow({ obj, prefix, onDelete }: { obj: S3Object; prefix: string; onD fontSize: tokens.size.xs, fontFamily: tokens.font.mono, cursor: "pointer", + padding: `2px ${tokens.space[2]}px`, + border: `1px solid ${tokens.color.fail}`, + marginLeft: tokens.space[3], }} > - DEL + DELETE -
+ + )} +
+ ); +} + +function BucketRow({ name, onDelete }: { name: string; onDelete: () => void }) { + const [hover, setHover] = useState(false); + return ( +
setHover(true)} + onMouseLeave={() => setHover(false)} + style={{ + padding: `${tokens.space[2]}px ${tokens.space[3]}px`, + display: "flex", + alignItems: "center", + gap: tokens.space[2], + borderBottom: `1px solid ${tokens.color.border0}`, + background: hover ? tokens.color.bg2 : "transparent", + transition: `background ${tokens.transition.fast}`, + }} + > + + {name} + {hover && ( + { e.stopPropagation(); onDelete(); }} + style={{ + color: tokens.color.fail, + fontSize: tokens.size.xs, + fontFamily: tokens.font.mono, + cursor: "pointer", + padding: `2px ${tokens.space[2]}px`, + border: `1px solid ${tokens.color.fail}`, + }} + > + DELETE + )}
);