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:
@@ -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)
|
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 ────────────────────────────────────────────────────────────────
|
# ── Analytics ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Activity, BarChart3, Boxes, ChevronLeft, ChevronRight, Clock, Database, Download, ExternalLink,
|
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,
|
RefreshCw, Search, TrendingUp, X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { cn } from '../../lib/utils'
|
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 TYPE_CHIPS = ['json', 'jpeg', 'png', 'csv', 'log', 'parquet', 'avro', 'pdf', 'txt']
|
||||||
const IMG_EXT = new Set(['jpeg', 'jpg', 'png', 'gif', 'webp', 'bmp', 'svg'])
|
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 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) =>
|
const objUrl = (bucket: string, key: string, inline = false) =>
|
||||||
`/api/storage/s3/buckets/${encodeURIComponent(bucket)}/download?key=${encodeURIComponent(key)}${inline ? '&inline=true' : ''}`
|
`/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 }) {
|
function Lightbox({ bucket, items, index, onClose, onNav }: { bucket: string; items: S3Item[]; index: number; onClose: () => void; onNav: (i: number) => void }) {
|
||||||
const o = items[index]
|
const o = items[index]
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -412,6 +484,7 @@ function BucketBrowser() {
|
|||||||
const [view, setView] = useState<'list' | 'grid'>('list')
|
const [view, setView] = useState<'list' | 'grid'>('list')
|
||||||
const [userPickedView, setUserPickedView] = useState(false)
|
const [userPickedView, setUserPickedView] = useState(false)
|
||||||
const [lightbox, setLightbox] = useState<number | null>(null)
|
const [lightbox, setLightbox] = useState<number | null>(null)
|
||||||
|
const [preview, setPreview] = useState<S3Item | null>(null)
|
||||||
|
|
||||||
const flat = recursive || !!q || !!ext
|
const flat = recursive || !!q || !!ext
|
||||||
const images = useMemo(() => objects.filter(isImage), [objects])
|
const images = useMemo(() => objects.filter(isImage), [objects])
|
||||||
@@ -559,13 +632,17 @@ function BucketBrowser() {
|
|||||||
const imgIdx = img ? images.findIndex((x) => x.key === o.key) : -1
|
const imgIdx = img ? images.findIndex((x) => x.key === o.key) : -1
|
||||||
return (
|
return (
|
||||||
<div key={o.key} className="group overflow-hidden rounded-lg border border-border bg-surface-raised">
|
<div key={o.key} className="group overflow-hidden rounded-lg border border-border bg-surface-raised">
|
||||||
<button type="button" onClick={() => img ? setLightbox(imgIdx) : undefined}
|
<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">
|
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 && o.key && bucket ? (
|
||||||
<img src={objUrl(bucket, o.key, true)} alt={o.name || o.key} loading="lazy"
|
<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" />
|
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>
|
</button>
|
||||||
<div className="flex items-center justify-between gap-1 px-1.5 py-1">
|
<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-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 pr-2 text-foreground-faint">{o.modified?.slice(0, 19) || '—'}</td>
|
||||||
<td className="py-1.5">
|
<td className="py-1.5">
|
||||||
{o.key && bucket && (
|
<div className="flex items-center gap-2">
|
||||||
<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">
|
{o.key && bucket && (isImage(o) || isText(o)) && (
|
||||||
<Download className="h-3 w-3" />
|
<button type="button" title="Preview" className="text-foreground-muted hover:text-docker"
|
||||||
</a>
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -646,6 +731,9 @@ function BucketBrowser() {
|
|||||||
{lightbox !== null && bucket && images.length > 0 && (
|
{lightbox !== null && bucket && images.length > 0 && (
|
||||||
<Lightbox bucket={bucket} items={images} index={Math.min(lightbox, images.length - 1)} onClose={() => setLightbox(null)} onNav={setLightbox} />
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user