feat(storage): breadth-first analytics + recursive browse/search
Analytics now walks every top-level prefix with its own budget instead of a flat alphabetical scan, so a single huge prefix (kafka/ CDC json) no longer hides the rest — composition now correctly reflects images, logs, csv, parquet and avro, and reports true total size. Browser gains recursive (whole-subtree) listing, free-text name search, file-type filter chips, type column and Load-more pagination so every object across all folders is discoverable.
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Activity, BarChart3, Boxes, ChevronRight, Clock, Database, Download, ExternalLink,
|
||||
Files, Folder, Gauge, HardDrive, LayoutGrid, Loader2, RefreshCw, TrendingUp,
|
||||
Files, Folder, Gauge, HardDrive, LayoutGrid, Loader2, RefreshCw, Search, TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '../../lib/utils'
|
||||
import { subTabActive, subTabIdle } from '../../lib/tabActive'
|
||||
|
||||
type Bucket = { name: string; created?: string; has_objects?: boolean }
|
||||
type S3Item = { type: string; name?: string; prefix?: string; key?: string; size_human?: string; modified?: string }
|
||||
type S3Item = { type: string; name?: string; prefix?: string; key?: string; ext?: string; size_human?: string; modified?: string }
|
||||
|
||||
type GrowthPt = { date: string; objects: number; bytes: number; cum_objects: number; cum_bytes: number }
|
||||
type Analytics = {
|
||||
@@ -349,6 +349,8 @@ export function StorageView() {
|
||||
|
||||
/* ── Browser (the original explorer, now a tab) ──────────────────────────── */
|
||||
|
||||
const TYPE_CHIPS = ['json', 'jpeg', 'png', 'csv', 'log', 'parquet', 'avro', 'pdf', 'txt']
|
||||
|
||||
function BucketBrowser() {
|
||||
const [buckets, setBuckets] = useState<Bucket[]>([])
|
||||
const [bucket, setBucket] = useState<string | null>(null)
|
||||
@@ -356,7 +358,17 @@ function BucketBrowser() {
|
||||
const [folders, setFolders] = useState<S3Item[]>([])
|
||||
const [objects, setObjects] = useState<S3Item[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
// search / filter
|
||||
const [qInput, setQInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [ext, setExt] = useState('')
|
||||
const [recursive, setRecursive] = useState(false)
|
||||
const [nextToken, setNextToken] = useState<string | null>(null)
|
||||
const [scanned, setScanned] = useState(0)
|
||||
|
||||
const flat = recursive || !!q || !!ext
|
||||
|
||||
const loadBuckets = useCallback(async () => {
|
||||
setLoading(true); setError(null)
|
||||
@@ -370,18 +382,31 @@ function BucketBrowser() {
|
||||
} catch { setError('S3 API unavailable') } finally { setLoading(false) }
|
||||
}, [bucket])
|
||||
|
||||
const loadObjects = useCallback(async (b: string, p: string) => {
|
||||
setLoading(true); setError(null)
|
||||
const loadObjects = useCallback(async (b: string, p: string, opts: { q: string; ext: string; recursive: boolean }, token?: string) => {
|
||||
const append = !!token
|
||||
append ? setLoadingMore(true) : setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await fetch(`/api/storage/s3/buckets/${encodeURIComponent(b)}/objects?prefix=${encodeURIComponent(p)}`)
|
||||
const params = new URLSearchParams({ prefix: p, max_keys: '300' })
|
||||
if (opts.recursive) params.set('recursive', 'true')
|
||||
if (opts.q) params.set('q', opts.q)
|
||||
if (opts.ext) params.set('ext', opts.ext)
|
||||
if (token) params.set('token', token)
|
||||
const r = await fetch(`/api/storage/s3/buckets/${encodeURIComponent(b)}/objects?${params}`)
|
||||
const j = await r.json()
|
||||
if (!r.ok || !j.ok) { setError(j.error || 'List failed'); return }
|
||||
setFolders(j.folders || []); setObjects(j.objects || [])
|
||||
} catch { setError('Failed to list objects') } finally { setLoading(false) }
|
||||
setFolders(j.flat ? [] : (j.folders || []))
|
||||
setObjects((prev) => (append ? [...prev, ...(j.objects || [])] : (j.objects || [])))
|
||||
setNextToken(j.next_token || null)
|
||||
setScanned((prev) => (append ? prev + (j.scanned || 0) : (j.scanned || 0)))
|
||||
} catch { setError('Failed to list objects') } finally { append ? setLoadingMore(false) : setLoading(false) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadBuckets() }, [loadBuckets])
|
||||
useEffect(() => { if (bucket) loadObjects(bucket, prefix) }, [bucket, prefix, loadObjects])
|
||||
useEffect(() => { if (bucket) loadObjects(bucket, prefix, { q, ext, recursive }) }, [bucket, prefix, q, ext, recursive, loadObjects])
|
||||
|
||||
const runSearch = () => setQ(qInput.trim())
|
||||
const clearSearch = () => { setQInput(''); setQ(''); setExt(''); setRecursive(false) }
|
||||
|
||||
const crumbs = prefix ? prefix.split('/').filter(Boolean) : []
|
||||
|
||||
@@ -391,7 +416,7 @@ function BucketBrowser() {
|
||||
<h3 className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-foreground-faint">Buckets</h3>
|
||||
<div className="space-y-1">
|
||||
{buckets.map((b) => (
|
||||
<button key={b.name} type="button" onClick={() => { setBucket(b.name); setPrefix('') }}
|
||||
<button key={b.name} type="button" onClick={() => { setBucket(b.name); setPrefix(''); clearSearch() }}
|
||||
className={cn('flex w-full items-center gap-2 rounded border px-2 py-1.5 text-left text-[10px]',
|
||||
bucket === b.name ? 'border-docker/40 bg-docker/10' : 'border-border hover:bg-surface-overlay')}>
|
||||
<Database className="h-3 w-3 shrink-0 text-docker" />
|
||||
@@ -403,7 +428,36 @@ function BucketBrowser() {
|
||||
</aside>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col p-3">
|
||||
{bucket && (
|
||||
{/* search + filters */}
|
||||
<div className="mb-2 flex flex-wrap items-center gap-1.5">
|
||||
<div className="flex items-center gap-1 rounded-md border border-border bg-surface-raised px-2 py-1">
|
||||
<Search className="h-3 w-3 text-foreground-faint" />
|
||||
<input
|
||||
value={qInput}
|
||||
onChange={(e) => setQInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') runSearch() }}
|
||||
placeholder="Search all objects by name…"
|
||||
className="w-44 bg-transparent text-[10px] text-foreground outline-none placeholder:text-foreground-faint"
|
||||
/>
|
||||
</div>
|
||||
<button type="button" onClick={runSearch} className={cn('rounded-md px-2 py-1 text-[10px]', subTabActive)}>Search</button>
|
||||
<button type="button" onClick={() => setRecursive((v) => !v)}
|
||||
className={cn('rounded-md px-2 py-1 text-[10px]', recursive ? subTabActive : subTabIdle)} title="List every object under this path, including subfolders">
|
||||
Recursive
|
||||
</button>
|
||||
<span className="mx-1 h-4 w-px bg-border" />
|
||||
{TYPE_CHIPS.map((t) => (
|
||||
<button key={t} type="button" onClick={() => setExt(ext === t ? '' : t)}
|
||||
className={cn('rounded-md px-1.5 py-1 font-mono text-[10px]', ext === t ? subTabActive : subTabIdle)}>
|
||||
.{t}
|
||||
</button>
|
||||
))}
|
||||
{(q || ext || recursive) && (
|
||||
<button type="button" onClick={clearSearch} className={cn('rounded-md px-2 py-1 text-[10px]', subTabIdle)}>Clear</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{bucket && !flat && (
|
||||
<nav className="mb-2 flex flex-wrap items-center gap-1 text-[10px] text-foreground-muted">
|
||||
<button type="button" className="hover:text-docker" onClick={() => setPrefix('')}>{bucket}</button>
|
||||
{crumbs.map((c, i) => (
|
||||
@@ -414,13 +468,20 @@ function BucketBrowser() {
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
{flat && (
|
||||
<p className="mb-2 text-[10px] text-foreground-faint">
|
||||
{loading ? 'Searching…' : <>Found <span className="text-foreground">{objects.length}</span> object{objects.length === 1 ? '' : 's'}
|
||||
{q && <> matching “<span className="text-foreground">{q}</span>”</>}{ext && <> of type <span className="font-mono text-foreground">.{ext}</span></>}
|
||||
{prefix && <> under <span className="font-mono">{prefix}</span></>} · scanned {fmtNum(scanned)}{nextToken ? '+' : ''} keys</>}
|
||||
</p>
|
||||
)}
|
||||
{loading && <p className="flex items-center gap-2 text-[11px] text-foreground-muted"><Loader2 className="h-4 w-4 animate-spin" /> Loading…</p>}
|
||||
{error && <p className="mb-2 text-[11px] text-danger">{error}</p>}
|
||||
<div className="scrollbar-thin min-h-0 flex-1 overflow-y-auto">
|
||||
<table className="w-full text-left text-[11px]">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-[9px] uppercase text-foreground-faint">
|
||||
<th className="py-1.5 pr-2">Name</th><th className="py-1.5 pr-2">Size</th><th className="py-1.5 pr-2">Modified</th><th className="py-1.5" />
|
||||
<th className="py-1.5 pr-2">{flat ? 'Key' : 'Name'}</th><th className="py-1.5 pr-2">Type</th><th className="py-1.5 pr-2">Size</th><th className="py-1.5 pr-2">Modified</th><th className="py-1.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -431,12 +492,14 @@ function BucketBrowser() {
|
||||
<Folder className="h-3.5 w-3.5" /> {f.name}/
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-1.5 pr-2 text-foreground-faint">folder</td>
|
||||
<td className="py-1.5 pr-2 text-foreground-faint">—</td><td className="py-1.5 pr-2 text-foreground-faint">—</td><td />
|
||||
</tr>
|
||||
))}
|
||||
{objects.map((o) => (
|
||||
<tr key={o.key} className="border-b border-border/50 hover:bg-surface-overlay/50">
|
||||
<td className="max-w-[240px] truncate py-1.5 pr-2 font-mono text-[10px]">{o.name || o.key}</td>
|
||||
<td className="max-w-[360px] truncate py-1.5 pr-2 font-mono text-[10px]" title={o.key}>{flat ? o.key : (o.name || o.key)}</td>
|
||||
<td className="py-1.5 pr-2"><span className="rounded bg-surface-overlay px-1 py-0.5 font-mono text-[9px] text-foreground-muted">{o.ext || '—'}</span></td>
|
||||
<td className="py-1.5 pr-2 text-foreground-muted">{o.size_human}</td>
|
||||
<td className="py-1.5 pr-2 text-foreground-faint">{o.modified?.slice(0, 19) || '—'}</td>
|
||||
<td className="py-1.5">
|
||||
@@ -450,7 +513,17 @@ function BucketBrowser() {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{!loading && folders.length === 0 && objects.length === 0 && bucket && <p className="py-8 text-center text-sm text-foreground-muted">This prefix is empty.</p>}
|
||||
{!loading && folders.length === 0 && objects.length === 0 && bucket && (
|
||||
<p className="py-8 text-center text-sm text-foreground-muted">{flat ? 'No matching objects found.' : 'This prefix is empty.'}</p>
|
||||
)}
|
||||
{nextToken && bucket && (
|
||||
<div className="py-3 text-center">
|
||||
<button type="button" disabled={loadingMore} onClick={() => loadObjects(bucket, prefix, { q, ext, recursive }, nextToken)}
|
||||
className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
|
||||
{loadingMore ? <Loader2 className="inline h-3 w-3 animate-spin" /> : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user