feat: Download-Knopf fuer fertige Rips — Dateien direkt im Browser statt scp
Ampel / ampel (push) Successful in 29s

- GET /jobs/{id}/files: Dateiliste aus job.output_path (Name + Groesse)
- GET /jobs/{id}/files/{name}: FileResponse-Stream; Validierung strikt —
  output_path muss unter /app/media liegen, nackter Dateiname (kein
  Slash/.., kein Dotfile), realpath-Check gegen Symlink-Ausbrueche.
  Mit Test (test_dateiname_validierung_blockt_pfad_tricks).
- UI: 'Download'-Knopf in der Aktion-Spalte bei fertigen Jobs; die
  Dateiliste mit Groessen + Download-Links lebt im Job-Detail-Popup
  (ein Dropdown wuerde im overflow-x-auto-Tabellencontainer clippen).
- nginx: proxy_buffering off + proxy_read_timeout 3600s waren fuer SSE
  schon gesetzt — grosse Downloads brauchen keine Aenderung.

Wunsch aus der Uebernahme-Session (Commander-Sammelliste 24.07.).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-24 09:01:38 +02:00
parent 5d9be4d046
commit ab75134931
6 changed files with 149 additions and 4 deletions
+44 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { X, Film, Loader2, FolderOpen, AlertCircle } from 'lucide-react'
import { X, Film, Loader2, FolderOpen, AlertCircle, Download } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
@@ -48,19 +48,34 @@ const STATUS_LABEL: Record<string, string> = {
failed: 'Fehler',
}
interface JobDatei {
name: string
size_mb: number | null
}
export default function JobDetailModal({ jobId, onClose }: { jobId: string | null, onClose: () => void }) {
const [detail, setDetail] = useState<JobDetail | null>(null)
const [dateien, setDateien] = useState<JobDatei[] | null>(null)
const [laedt, setLaedt] = useState(false)
const { theme } = useDarkMode()
useEffect(() => {
if (!jobId) {
setDetail(null)
setDateien(null)
return
}
setLaedt(true)
api.get(`/jobs/${jobId}/detail`)
.then(r => setDetail(r.data))
.then(r => {
setDetail(r.data)
// Fertige Jobs: Dateiliste für die Download-Knöpfe nachladen
if (r.data?.status === 'completed') {
api.get(`/jobs/${jobId}/files`)
.then(f => setDateien(f.data.files))
.catch(() => setDateien(null))
}
})
.catch(() => setDetail(null))
.finally(() => setLaedt(false))
}, [jobId])
@@ -159,6 +174,33 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
)}
</div>
{/* Download der fertigen Dateien — vorher kam man nur per scp dran */}
{detail.status === 'completed' && dateien && dateien.length > 0 && (
<div className={`rounded-lg p-4 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
<p className={`text-sm font-medium mb-2 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>
<Download size={15} /> Dateien herunterladen
</p>
<div className="space-y-1">
{dateien.map(f => (
<a
key={f.name}
href={`/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`}
download
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${theme === 'dark' ? 'text-indigo-300 hover:bg-slate-800' : 'text-indigo-700 hover:bg-slate-100'}`}
>
<Download size={14} className="flex-shrink-0" />
<span className="truncate min-w-0">{f.name}</span>
{f.size_mb != null && (
<span className={`ml-auto text-xs flex-shrink-0 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
</span>
)}
</a>
))}
</div>
</div>
)}
{detail.error && (
<div className={`rounded-lg p-4 flex items-start gap-2.5 text-sm ${theme === 'dark' ? 'bg-rose-900/30 text-rose-300' : 'bg-rose-50 text-rose-700'}`}>
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />