import { useState, useEffect } from 'react' import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, AlertTriangle, RefreshCw, Wrench, Folder, File, ArrowUp, FolderPlus } from 'lucide-react' import { api } from '../lib/api' import { useToast } from '../context/ToastContext' import { Card, CardHeader, CardTitle, CardContent } from './ui/Card' import { Button } from './ui/Button' import { Input, Select } from './ui/Input' interface Mount { name: string type: string source: string mounted: boolean reachable?: boolean has_credentials: boolean } interface Target { name: string path: string is_mount: boolean free_gb: number | null } export default function StorageMounts() { const [mounts, setMounts] = useState([]) const [targets, setTargets] = useState([]) const [name, setName] = useState('') const [typ, setTyp] = useState<'nfs' | 'cifs'>('cifs') const [source, setSource] = useState('') const [host, setHost] = useState('') const [shares, setShares] = useState([]) const [selectedShare, setSelectedShare] = useState('') const [sharesBusy, setSharesBusy] = useState(false) const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [busy, setBusy] = useState(false) const [feedback, setFeedback] = useState(null) const [browsePath, setBrowsePath] = useState('/app/media') const [browseParent, setBrowseParent] = useState(null) const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([]) const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([]) const [neuerOrdner, setNeuerOrdner] = useState('') const [browserOffen, setBrowserOffen] = useState(false) const { toast } = useToast() const browsen = async (pfad: string) => { try { const r = await api.get('/browse', { params: { path: pfad } }) setBrowsePath(r.data.path) setBrowseParent(r.data.parent) setBrowseDirs(r.data.dirs) setBrowseFiles(r.data.files || []) } catch { setBrowseDirs([]) setBrowseFiles([]) } } const ordnerAnlegen = async () => { if (!neuerOrdner) return try { await api.post('/browse/mkdir', { path: browsePath, name: neuerOrdner }) setNeuerOrdner('') browsen(browsePath) laden() } catch (e: any) { setFeedback(`✗ ${e?.response?.data?.detail || 'Ordner anlegen fehlgeschlagen'}`) } } const laden = async (manuell = false) => { try { const [m, t] = await Promise.allSettled([ api.get('/storage-mounts'), api.get('/storage-targets'), ]) if (m.status === 'fulfilled') setMounts(m.value.data || []) if (t.status === 'fulfilled') setTargets(t.value.data || []) if (manuell) toast('success', 'Speicherziele aktualisiert') } catch { if (manuell) toast('error', 'Speicherziele konnten nicht geladen werden') } } useEffect(() => { laden(); browsen('/app/media') }, []) const freigabenAuflisten = async () => { if (!host) return setSharesBusy(true) setFeedback(null) try { const r = await api.get('/storage-mounts/shares', { params: { host, ...(username ? { username, password } : {}) }, }) setShares(r.data.shares) if (r.data.shares.length === 0) { setFeedback('✗ Keine Freigaben gefunden — Rechner erreichbar und Freigaben aktiv?') } else { setSelectedShare(r.data.shares[0]) } } catch (e: any) { setFeedback(`✗ ${e?.response?.data?.detail || 'Freigaben-Abfrage fehlgeschlagen'}`) setShares([]) } finally { setSharesBusy(false) } } const hinzufuegen = async () => { setBusy(true) setFeedback(null) const quelle = typ === 'cifs' && host && selectedShare ? `//${host}/${selectedShare}` : source const zielName = name try { const r = await api.post('/storage-mounts', { name, type: typ, source: quelle, ...(username ? { username, password } : {}), }) if (r.data.writable) { setFeedback(`✓ „${zielName}" eingehängt — Schreibtest OK, ab sofort als Rip-Ziel wählbar`) toast('success', `„${zielName}" eingehängt — Schreibtest OK`) } else { setFeedback(`⚠️ „${zielName}" eingehängt, aber NUR LESBAR — Freigabe-Rechte prüfen!`) toast('error', `„${zielName}" ist nur lesbar — Freigabe-Rechte prüfen`) } setName(''); setSource(''); setHost(''); setShares([]); setSelectedShare(''); setUsername(''); setPassword('') laden() } catch (e: any) { const detail = e?.response?.data?.detail || 'Einhängen fehlgeschlagen' setFeedback(`✗ ${detail}`) toast('error', `„${zielName}": ${detail}`) } finally { setBusy(false) } } const entfernen = async (mountName: string) => { setBusy(true) try { await api.delete(`/storage-mounts/${mountName}`) toast('success', `„${mountName}" ausgehängt und entfernt`) laden() } catch (e: any) { const detail = e?.response?.data?.detail || 'Entfernen fehlgeschlagen' setFeedback(`✗ ${detail}`) toast('error', detail) } finally { setBusy(false) } } const reparieren = async (mountName: string) => { setBusy(true) try { const r = await api.post(`/storage-mounts/${mountName}/repair`) toast(r.data.writable ? 'success' : 'error', r.data.writable ? `„${mountName}" neu verbunden` : `„${mountName}" verbunden, aber nur lesbar`) laden() } catch (e: any) { toast('error', e?.response?.data?.detail || 'Reparatur fehlgeschlagen — NAS erreichbar?') } finally { setBusy(false) } } return (
{/* Vorhandene Ziele */} Verfügbare Ziele {targets.length === 0 ? (

Noch keine Unterordner in /app/media — sie entstehen beim ersten Rip oder Mount.

) : (
{targets.map(t => (
{t.name} {t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
{mounts.some(m => m.name === t.name) && ( )}
))}
)}
{/* Netzwerk-Mounts mit Status — zeigt AUCH tote/getrennte Mounts, damit man sie reparieren oder entfernen kann (Bug 24.07.: ein toter NAS-Mount verschwand aus „Verfügbare Ziele" und ließ sich nicht neu anlegen). */} {mounts.length > 0 && ( Netzwerk-Mounts
{mounts.map(m => { const aktiv = m.mounted && m.reachable !== false const tot = m.mounted && m.reachable === false return (
{aktiv ? : tot ? : }
{m.name} {m.source}

{aktiv ? 'aktiv & erreichbar' : tot ? 'verbunden, aber NICHT erreichbar (NAS aus/weg?)' : 'getrennt'}

{!aktiv && ( )}
) })}
)} {/* Ordner-Verwaltung */} {browserOffen && (
{browsePath} setNeuerOrdner(e.target.value)} placeholder="neuer-ordner" className="w-36 px-2.5 py-1 text-xs border rounded-lg bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-amber-500/50" />
{browseDirs.length === 0 && browseFiles.length === 0 ? (

Ordner ist leer

) : ( <> {browseDirs.map(d => ( ))} {browseFiles.map(f => (
{f.name} {f.size_mb != null && ( {f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`} )}
))} )}
)}
{/* Neues Netzwerk-Ziel */} Netzwerk-Ziel hinzufügen
setName(e.target.value.toLowerCase())} placeholder="nas-filme" /> {typ === 'nfs' ? (
setSource(e.target.value)} placeholder="192.168.1.10:/volume1/filme" />
) : ( <>
setHost(e.target.value)} placeholder="z. B. 192.168.1.20 oder MEIN-NAS" />
setUsername(e.target.value)} autoComplete="off" placeholder="Freigabe-Benutzer" /> setPassword(e.target.value)} autoComplete="new-password" />

Windows-PCs und die meisten NAS verlangen Benutzer + Passwort — ohne Angaben versucht Rippy einen Gast-Zugriff.

{shares.length > 0 && (
)} )}
{feedback && (

{feedback.startsWith('✓') ? : } {feedback}

)}
) }