Add Command Center v2: DQ/RAG integration, S3 browser, Jupyter, GPU matrix.
Mirror mo/atc-GPU layout with config/, docs/, scripts/ for Gitea deploy.
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ChevronRight, Database, Download, ExternalLink, Folder, HardDrive, Loader2, RefreshCw } 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 }
|
||||
|
||||
export function StorageView() {
|
||||
const [health, setHealth] = useState<{ ok: boolean; endpoint?: string; bucket_names?: string[]; error?: string } | null>(null)
|
||||
const [buckets, setBuckets] = useState<Bucket[]>([])
|
||||
const [bucket, setBucket] = useState<string | null>(null)
|
||||
const [prefix, setPrefix] = useState('')
|
||||
const [folders, setFolders] = useState<S3Item[]>([])
|
||||
const [objects, setObjects] = useState<S3Item[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const loadBuckets = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [h, b] = await Promise.all([
|
||||
fetch('/api/storage/s3/health'),
|
||||
fetch('/api/storage/s3/buckets'),
|
||||
])
|
||||
if (h.ok) setHealth(await h.json())
|
||||
if (b.ok) {
|
||||
const j = await b.json()
|
||||
setBuckets(j.buckets || [])
|
||||
if (!bucket && j.buckets?.length) setBucket(j.buckets[0].name)
|
||||
} else {
|
||||
setError('Failed to load buckets')
|
||||
}
|
||||
} catch {
|
||||
setError('S3 API unavailable')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [bucket])
|
||||
|
||||
const loadObjects = useCallback(async (b: string, p: string) => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await fetch(`/api/storage/s3/buckets/${encodeURIComponent(b)}/objects?prefix=${encodeURIComponent(p)}`)
|
||||
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)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadBuckets()
|
||||
}, [loadBuckets])
|
||||
|
||||
useEffect(() => {
|
||||
if (bucket) loadObjects(bucket, prefix)
|
||||
}, [bucket, prefix, loadObjects])
|
||||
|
||||
const crumbs = prefix ? prefix.split('/').filter(Boolean) : []
|
||||
|
||||
return (
|
||||
<div className="panel flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<header className="flex shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
|
||||
<div>
|
||||
<h2 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<HardDrive className="h-4 w-4 text-docker" />
|
||||
ObjectScale S3 Storage
|
||||
</h2>
|
||||
<p className="text-[10px] text-foreground-muted">
|
||||
Dell ECS · {health?.endpoint || '10.0.20.111:9020'} · live bucket browser
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<a href="/jupyter/" target="_blank" rel="noreferrer" className={cn('inline-flex items-center gap-1 rounded-md px-3 py-1.5 text-[11px] font-medium', subTabActive)}>
|
||||
<ExternalLink className="h-3 w-3" /> Open Jupyter
|
||||
</a>
|
||||
<button type="button" onClick={() => { loadBuckets(); if (bucket) loadObjects(bucket, prefix) }} className={cn('rounded-md px-3 py-1.5 text-[11px]', subTabIdle)}>
|
||||
<RefreshCw className={cn('inline h-3 w-3', loading && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col lg:flex-row">
|
||||
<aside className="shrink-0 border-b border-border p-3 lg:w-52 lg:border-b-0 lg:border-r">
|
||||
<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('') }}
|
||||
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" />
|
||||
<span className="truncate font-medium">{b.name}</span>
|
||||
</button>
|
||||
))}
|
||||
{buckets.length === 0 && !loading && (
|
||||
<p className="text-[9px] text-foreground-faint">No buckets or access denied.</p>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col p-3">
|
||||
{bucket && (
|
||||
<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) => (
|
||||
<span key={i} className="inline-flex items-center gap-1">
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-docker"
|
||||
onClick={() => setPrefix(crumbs.slice(0, i + 1).join('/') + '/')}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
|
||||
{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" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{folders.map((f) => (
|
||||
<tr key={f.prefix} className="border-b border-border/50 hover:bg-surface-overlay/50">
|
||||
<td className="py-1.5 pr-2">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 font-medium text-docker hover:underline"
|
||||
onClick={() => setPrefix(f.prefix || '')}
|
||||
>
|
||||
<Folder className="h-3.5 w-3.5" /> {f.name}/
|
||||
</button>
|
||||
</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="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>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user