Initial scaffold: LLM Trainer Dashboard
Full-stack app with FastAPI backend (SSH/paramiko, pipeline streaming, GPU stats, xterm.js terminal, Ollama model manager) and React + Tailwind frontend (8 panels: Connection, Documents, Pipeline, QA Pairs, Training, Terminal, Models, Config). Docker Compose included. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
187
frontend/src/components/DocumentManager.jsx
Normal file
187
frontend/src/components/DocumentManager.jsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import axios from 'axios'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, Trash2, Eye, RefreshCw, FileText, X } from 'lucide-react'
|
||||
|
||||
const STAGES = ['input', 'parsed', 'generated', 'curated', 'final']
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export default function DocumentManager({ connected }) {
|
||||
const [stage, setStage] = useState('input')
|
||||
const [files, setFiles] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [preview, setPreview] = useState(null)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const fetchFiles = useCallback(async () => {
|
||||
if (!connected) return
|
||||
setLoading(true); setError('')
|
||||
try {
|
||||
const { data } = await axios.get(`/api/files/${stage}`)
|
||||
setFiles(data.files)
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [stage, connected])
|
||||
|
||||
useEffect(() => { fetchFiles() }, [fetchFiles])
|
||||
|
||||
const onDrop = useCallback(async accepted => {
|
||||
if (!accepted.length || !connected) return
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append('file', accepted[0])
|
||||
try {
|
||||
await axios.post('/api/upload', formData)
|
||||
fetchFiles()
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || err.message)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}, [connected, fetchFiles])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
disabled: !connected || stage !== 'input',
|
||||
multiple: false,
|
||||
})
|
||||
|
||||
const deleteFile = async name => {
|
||||
if (!window.confirm(`Delete "${name}"?`)) return
|
||||
await axios.delete(`/api/files/${stage}/${name}`)
|
||||
fetchFiles()
|
||||
}
|
||||
|
||||
const previewFile = async name => {
|
||||
const { data } = await axios.get(`/api/files/${stage}/${name}/preview`)
|
||||
setPreview({ name, content: data.content })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* Stage tabs */}
|
||||
<div className="flex gap-1 bg-[#161b27] rounded-xl p-1 w-fit">
|
||||
{STAGES.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStage(s)}
|
||||
className={`px-4 py-1.5 rounded-lg text-xs font-medium transition-colors capitalize
|
||||
${stage === s
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-slate-400 hover:text-slate-200'}`}
|
||||
>
|
||||
{s}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Upload drop zone (input stage only) */}
|
||||
{stage === 'input' && (
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center transition-colors cursor-pointer
|
||||
${isDragActive ? 'border-blue-500 bg-blue-900/10' : 'border-slate-600/50 hover:border-slate-500'}
|
||||
${!connected ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
<Upload size={24} className="mx-auto mb-2 text-slate-500" />
|
||||
{uploading
|
||||
? <p className="text-sm text-blue-400">Uploading…</p>
|
||||
: <p className="text-sm text-slate-400">
|
||||
{isDragActive ? 'Drop file here' : 'Drag & drop or click to upload to /input'}
|
||||
</p>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File table */}
|
||||
<div className="rounded-xl border border-slate-700/50 bg-[#161b27] overflow-hidden">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700/50">
|
||||
<span className="text-xs text-slate-400">{files.length} file(s)</span>
|
||||
<button
|
||||
onClick={fetchFiles}
|
||||
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
<RefreshCw size={12}/> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="px-4 py-2 text-xs text-red-400">{error}</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="px-4 py-6 text-xs text-slate-500 text-center">Loading…</p>
|
||||
) : files.length === 0 ? (
|
||||
<p className="px-4 py-6 text-xs text-slate-500 text-center">No files in /{stage}</p>
|
||||
) : (
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-slate-500 border-b border-slate-700/40">
|
||||
<th className="text-left px-4 py-2">Name</th>
|
||||
<th className="text-right px-4 py-2">Size</th>
|
||||
<th className="text-right px-4 py-2">Modified</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map(f => (
|
||||
<tr key={f.name} className="border-b border-slate-700/20 hover:bg-slate-700/10">
|
||||
<td className="px-4 py-2 flex items-center gap-2">
|
||||
<FileText size={13} className="text-slate-500 flex-shrink-0"/>
|
||||
<span className="truncate max-w-xs text-slate-200">{f.name}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-right text-slate-400">{formatBytes(f.size)}</td>
|
||||
<td className="px-4 py-2 text-right text-slate-500">{f.modified}</td>
|
||||
<td className="px-4 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => previewFile(f.name)}
|
||||
className="p-1 rounded hover:bg-blue-600/20 text-slate-500 hover:text-blue-400 transition-colors"
|
||||
>
|
||||
<Eye size={13}/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteFile(f.name)}
|
||||
className="p-1 rounded hover:bg-red-600/20 text-slate-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
<Trash2 size={13}/>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview modal */}
|
||||
{preview && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-6">
|
||||
<div className="bg-[#161b27] border border-slate-700 rounded-xl w-full max-w-3xl max-h-[80vh] flex flex-col">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-700">
|
||||
<span className="text-sm font-medium text-slate-200">{preview.name}</span>
|
||||
<button onClick={() => setPreview(null)} className="text-slate-500 hover:text-slate-200">
|
||||
<X size={16}/>
|
||||
</button>
|
||||
</div>
|
||||
<pre className="flex-1 overflow-auto p-4 text-xs text-slate-300 leading-relaxed whitespace-pre-wrap">
|
||||
{preview.content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user