feat(storage): inline preview for json/csv/log/text files

Add a /preview endpoint that range-reads the first chunk of an object and
returns it as text. Browser gets an eye action (list + gallery) opening a
modal that pretty-prints JSON, renders CSV/TSV as a table, and shows logs/
text/yaml/xml verbatim, with a truncation note and download link.
This commit is contained in:
mo
2026-06-27 23:08:59 +00:00
parent a408ed4423
commit 1432a8429a
2 changed files with 130 additions and 9 deletions
+33
View File
@@ -257,6 +257,39 @@ async def download_object(bucket: str, key: str = Query(...), inline: bool = Que
return JSONResponse({"ok": False, "error": str(exc)}, status_code=404)
@router.get("/buckets/{bucket}/preview")
async def preview_object(bucket: str, key: str = Query(...), max_bytes: int = Query(131072, le=1048576)):
"""Return the first chunk of an object as text for inline preview."""
try:
_track("preview")
s3 = _client()
head = None
try:
head = s3.head_object(Bucket=bucket, Key=key)
except Exception:
pass
total = int(head.get("ContentLength", 0)) if head else 0
obj = s3.get_object(Bucket=bucket, Key=key, Range=f"bytes=0-{max_bytes - 1}")
raw = obj["Body"].read()
truncated = (total > len(raw)) or (len(raw) >= max_bytes)
text = raw.decode("utf-8", errors="replace")
return {
"ok": True,
"bucket": bucket,
"key": key,
"ext": _ext(key),
"size": total or len(raw),
"size_human": _human_size(total or len(raw)),
"bytes_read": len(raw),
"truncated": truncated,
"content": text,
}
except ClientError as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=404)
except Exception as exc:
return JSONResponse({"ok": False, "error": str(exc)}, status_code=502)
# ── Analytics ────────────────────────────────────────────────────────────────
+97 -9
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
Activity, BarChart3, Boxes, ChevronLeft, ChevronRight, Clock, Database, Download, ExternalLink,
Files, FileText, Folder, Gauge, GalleryThumbnails, HardDrive, LayoutGrid, List, Loader2,
Eye, Files, FileText, Folder, Gauge, GalleryThumbnails, HardDrive, LayoutGrid, List, Loader2,
RefreshCw, Search, TrendingUp, X,
} from 'lucide-react'
import { cn } from '../../lib/utils'
@@ -352,10 +352,82 @@ export function StorageView() {
const TYPE_CHIPS = ['json', 'jpeg', 'png', 'csv', 'log', 'parquet', 'avro', 'pdf', 'txt']
const IMG_EXT = new Set(['jpeg', 'jpg', 'png', 'gif', 'webp', 'bmp', 'svg'])
const TEXT_EXT = new Set(['json', 'ndjson', 'csv', 'tsv', 'log', 'txt', 'yaml', 'yml', 'xml', 'md', 'sql', 'conf', 'properties', 'ini', 'avsc', '(none)'])
const isImage = (o: S3Item) => IMG_EXT.has((o.ext || '').toLowerCase())
const isText = (o: S3Item) => TEXT_EXT.has((o.ext || '').toLowerCase())
const objUrl = (bucket: string, key: string, inline = false) =>
`/api/storage/s3/buckets/${encodeURIComponent(bucket)}/download?key=${encodeURIComponent(key)}${inline ? '&inline=true' : ''}`
type PreviewData = { key: string; ext: string; size_human: string; bytes_read: number; truncated: boolean; content: string }
function TextPreview({ bucket, item, onClose }: { bucket: string; item: S3Item; onClose: () => void }) {
const [data, setData] = useState<PreviewData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
let alive = true
setLoading(true); setError(null); setData(null)
fetch(`/api/storage/s3/buckets/${encodeURIComponent(bucket)}/preview?key=${encodeURIComponent(item.key || '')}`)
.then((r) => r.json())
.then((j) => { if (!alive) return; if (j.ok) setData(j); else setError(j.error || 'Preview failed') })
.catch(() => { if (alive) setError('Preview unavailable') })
.finally(() => { if (alive) setLoading(false) })
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', h)
return () => { alive = false; window.removeEventListener('keydown', h) }
}, [bucket, item.key, onClose])
const ext = (data?.ext || item.ext || '').toLowerCase()
let body: React.ReactNode = null
if (data) {
if (ext === 'json' || ext === 'avsc') {
let pretty = data.content
try { if (!data.truncated) pretty = JSON.stringify(JSON.parse(data.content), null, 2) } catch { /* keep raw (ndjson/truncated) */ }
body = <pre className="whitespace-pre-wrap break-words">{pretty}</pre>
} else if (ext === 'csv' || ext === 'tsv') {
const sep = ext === 'tsv' ? '\t' : ','
const rows = data.content.split(/\r?\n/).filter((l) => l.length).slice(0, 500).map((l) => l.split(sep))
body = (
<table className="w-full border-collapse text-[10px]">
<tbody>
{rows.map((cells, ri) => (
<tr key={ri} className={ri === 0 ? 'bg-surface-overlay font-semibold' : 'border-b border-border/40'}>
{cells.map((c, ci) => <td key={ci} className="border border-border/40 px-1.5 py-0.5 font-mono">{c}</td>)}
</tr>
))}
</tbody>
</table>
)
} else {
body = <pre className="whitespace-pre-wrap break-words">{data.content}</pre>
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6" onClick={onClose}>
<div className="flex max-h-[85vh] w-full max-w-4xl flex-col overflow-hidden rounded-lg border border-border bg-surface-raised shadow-2xl" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<div className="min-w-0">
<div className="truncate font-mono text-[11px] text-foreground" title={item.key}>{item.key}</div>
<div className="text-[9px] text-foreground-faint">
{data ? <>{data.size_human} · showing first {fmtNum(data.bytes_read)} bytes{data.truncated ? ' (truncated)' : ''}</> : 'Loading…'}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{item.key && <a href={objUrl(bucket, item.key)} className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-[10px] text-docker hover:bg-surface-overlay"><Download className="h-3 w-3" /> Download</a>}
<button type="button" onClick={onClose} className="rounded-md border border-border p-1 text-foreground-muted hover:bg-surface-overlay"><X className="h-4 w-4" /></button>
</div>
</div>
<div className="scrollbar-thin min-h-0 flex-1 overflow-auto p-3 text-[10px] text-foreground-muted">
{loading && <p className="flex items-center gap-2"><Loader2 className="h-4 w-4 animate-spin" /> Loading preview</p>}
{error && <p className="text-danger">{error}</p>}
{body}
</div>
</div>
</div>
)
}
function Lightbox({ bucket, items, index, onClose, onNav }: { bucket: string; items: S3Item[]; index: number; onClose: () => void; onNav: (i: number) => void }) {
const o = items[index]
useEffect(() => {
@@ -412,6 +484,7 @@ function BucketBrowser() {
const [view, setView] = useState<'list' | 'grid'>('list')
const [userPickedView, setUserPickedView] = useState(false)
const [lightbox, setLightbox] = useState<number | null>(null)
const [preview, setPreview] = useState<S3Item | null>(null)
const flat = recursive || !!q || !!ext
const images = useMemo(() => objects.filter(isImage), [objects])
@@ -559,13 +632,17 @@ function BucketBrowser() {
const imgIdx = img ? images.findIndex((x) => x.key === o.key) : -1
return (
<div key={o.key} className="group overflow-hidden rounded-lg border border-border bg-surface-raised">
<button type="button" onClick={() => img ? setLightbox(imgIdx) : undefined}
className="flex aspect-square w-full items-center justify-center overflow-hidden bg-surface-overlay">
<button type="button" onClick={() => img ? setLightbox(imgIdx) : (isText(o) ? setPreview(o) : undefined)}
className="flex aspect-square w-full items-center justify-center overflow-hidden bg-surface-overlay"
title={img ? 'Preview image' : isText(o) ? 'Preview file' : undefined}>
{img && o.key && bucket ? (
<img src={objUrl(bucket, o.key, true)} alt={o.name || o.key} loading="lazy"
className="h-full w-full object-cover transition-transform group-hover:scale-105" />
) : (
<FileText className="h-7 w-7 text-foreground-faint" />
<div className="flex flex-col items-center gap-1 text-foreground-faint">
<FileText className="h-7 w-7" />
<span className="font-mono text-[8px]">{o.ext}</span>
</div>
)}
</button>
<div className="flex items-center justify-between gap-1 px-1.5 py-1">
@@ -618,11 +695,19 @@ function BucketBrowser() {
<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">
{o.key && bucket && (
<a href={`/api/storage/s3/buckets/${encodeURIComponent(bucket)}/download?key=${encodeURIComponent(o.key)}`} className="inline-flex items-center gap-0.5 text-docker hover:underline">
<Download className="h-3 w-3" />
</a>
)}
<div className="flex items-center gap-2">
{o.key && bucket && (isImage(o) || isText(o)) && (
<button type="button" title="Preview" className="text-foreground-muted hover:text-docker"
onClick={() => isImage(o) ? setLightbox(images.findIndex((x) => x.key === o.key)) : setPreview(o)}>
<Eye className="h-3 w-3" />
</button>
)}
{o.key && bucket && (
<a href={objUrl(bucket, o.key)} className="inline-flex items-center gap-0.5 text-docker hover:underline">
<Download className="h-3 w-3" />
</a>
)}
</div>
</td>
</tr>
))}
@@ -646,6 +731,9 @@ function BucketBrowser() {
{lightbox !== null && bucket && images.length > 0 && (
<Lightbox bucket={bucket} items={images} index={Math.min(lightbox, images.length - 1)} onClose={() => setLightbox(null)} onNav={setLightbox} />
)}
{preview && bucket && (
<TextPreview bucket={bucket} item={preview} onClose={() => setPreview(null)} />
)}
</div>
)
}