From 1432a8429af90aeda400c24b739e5f55523950ae Mon Sep 17 00:00:00 2001 From: mo Date: Sat, 27 Jun 2026 23:08:59 +0000 Subject: [PATCH] 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. --- api/storage_s3.py | 33 +++++++ ui/src/components/features/StorageView.tsx | 106 +++++++++++++++++++-- 2 files changed, 130 insertions(+), 9 deletions(-) diff --git a/api/storage_s3.py b/api/storage_s3.py index 90d21cd..edc2ac9 100644 --- a/api/storage_s3.py +++ b/api/storage_s3.py @@ -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 ──────────────────────────────────────────────────────────────── diff --git a/ui/src/components/features/StorageView.tsx b/ui/src/components/features/StorageView.tsx index f58b49a..04dfe6c 100644 --- a/ui/src/components/features/StorageView.tsx +++ b/ui/src/components/features/StorageView.tsx @@ -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(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 =
{pretty}
+ } 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 = ( + + + {rows.map((cells, ri) => ( + + {cells.map((c, ci) => )} + + ))} + +
{c}
+ ) + } else { + body =
{data.content}
+ } + } + + return ( +
+
e.stopPropagation()}> +
+
+
{item.key}
+
+ {data ? <>{data.size_human} · showing first {fmtNum(data.bytes_read)} bytes{data.truncated ? ' (truncated)' : ''} : 'Loading…'} +
+
+
+ {item.key && Download} + +
+
+
+ {loading &&

Loading preview…

} + {error &&

{error}

} + {body} +
+
+
+ ) +} + 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(null) + const [preview, setPreview] = useState(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 (
-
@@ -618,11 +695,19 @@ function BucketBrowser() { {o.size_human} {o.modified?.slice(0, 19) || '—'} - {o.key && bucket && ( - - - - )} +
+ {o.key && bucket && (isImage(o) || isText(o)) && ( + + )} + {o.key && bucket && ( + + + + )} +
))} @@ -646,6 +731,9 @@ function BucketBrowser() { {lightbox !== null && bucket && images.length > 0 && ( setLightbox(null)} onNav={setLightbox} /> )} + {preview && bucket && ( + setPreview(null)} /> + )}
) }