Aus dem gescheiterten Test-Rip des Commanders (Job 95afdc89) und seiner
UI-Durchsicht. Der Reihe nach.
## 1. Der externe Encoder wurde SEHR WOHL gesehen (Diagnose-Korrektur)
Der Commander schloss aus der Meldung, das System sehe seinen PC nicht. Live
gemessen sagt das Gegenteil:
meta.transcode_node = tobisnicerpc@TobisNicerPC
VM-Worker-Log = nur rip_disc, KEIN transcode_files
22:19:41.271 Kompression eingereiht
22:19:41.453 "Keine Roh-MKVs gefunden" <- 182 ms spaeter
Das Routing hat funktioniert. Sein PC nahm die Aufgabe an und lehnte sie sofort
ab, weil er /app/media/rippy/<job> nicht finden kann - ein Pfad, der nur INNEN
im Container existiert. Der Rip war vollstaendig (79,6 GB auf der NAS).
## 2. Der Hardware-Encoder-Bug war meiner
leite_backends_ab() verlangte zusaetzlich ein Geraet: /dev/dri fuer
VAAPI/QSV/VCE bzw. nvidia-smi fuer NVENC. Unter WINDOWS gibt es beides nicht -
der PC des Commanders (RX 9070 XT) meldete deshalb nur CPU-Encoder, obwohl
HandBrakes Windows-Build vce_*/nvenc_*/qsv_* beherrscht. Der Bug traf genau den
Anwendungsfall, fuer den externe Worker gedacht sind.
Die Pruefung war ausserdem ueberfluessig: HandBrake probiert Hardware beim
Start selbst an und listet nur Nutzbares - auf der VM belegt durch
"qsv: not available on this system" bei gleichzeitig fehlendem qsv_* in --help.
Jetzt ist HandBrakes Liste die einzige Auskunft.
Dazu nach FAMILIE unterschieden (nvenc/qsv/vce/vaapi) statt alles in "vaapi" zu
werfen - eine AMD-Karte lief unter dem Intel-Namen. Hardware-AV1 bekommt eine
eigene Kennung, weil es die beste Kombination aus Tempo und Groesse ist und
sonst unsichtbar bliebe.
## 3. Vektorbefehle unter Windows - gemessen, nicht "unbekannt"
Es gibt dort kein /proc/cpuinfo, also meldete der Windows-Worker
"Vektorbefehle unbekannt" - gerade auf der Maschine, die encodieren soll.
Jetzt ueber IsProcessorFeaturePresent (kernel32, winnt.h dokumentiert) plus
CPU-Name aus der Registry. Auf dem Commander-PC gegengeprueft:
vorher: AMD64 Family 26 Model 68 Stepping 0 / Vektorbefehle unbekannt
jetzt: AMD Ryzen 7 9700X 8-Core Processor / avx512f / 16 Kerne
Damit verschwindet die AVX2-Warnung fuer diesen Worker von selbst - genau das
hatte der Commander gefordert.
## 4. Ehrliche Fehlermeldung statt "Keine Roh-MKVs gefunden"
Neues _erreichbarkeit_pruefen() unterscheidet jetzt drei Faelle statt einem:
Pfad nicht erreichbar UND kein Mapping gesetzt (nennt RIPPY_PATH_MAP samt
Beispiel), Mapping gesetzt aber greift nicht, oder Pfad einfach nicht da. Und
es prueft BEIDE Pfade - im Fall des Commanders lag die Quelle auf der
erreichbaren NAS, das ZIEL aber auf der VM-Platte ohne Samba; das waere erst
beim Schreiben nach Stunden Rechenzeit aufgefallen.
Jede Meldung sagt jetzt ausdruecklich, dass die Rohdaten NICHT verloren sind
und "Neu komprimieren" genuegt.
## 5. Persoenliche Werte raus
Vier Platzhalter trugen die Umgebung des Commanders (192.168.178.20,
TOBIS-PC, seine Jellyfin- und Rippy-IP). In einem Repo, das weitergegeben
wird, hat das nichts verloren.
Nebenbei dreimal in die eigene Falle getappt: deutsche Anfuehrungszeichen in
DOPPELT gequoteten Python-Strings beenden den String vorzeitig. Der Bestand
nutzt dafuer einfach gequotete f-Strings - jetzt auch meine.
NOCH OFFEN und wichtig: RIPPY_PATH_MAP wird von niemandem GESETZT (Installer,
UI, Compose). Ein Windows-Worker kann damit grundsaetzlich nicht transcodieren.
Das ist der naechste Schritt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
445 lines
18 KiB
TypeScript
445 lines
18 KiB
TypeScript
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<Mount[]>([])
|
|
const [targets, setTargets] = useState<Target[]>([])
|
|
const [name, setName] = useState('')
|
|
const [typ, setTyp] = useState<'nfs' | 'cifs'>('cifs')
|
|
const [source, setSource] = useState('')
|
|
const [host, setHost] = useState('')
|
|
const [shares, setShares] = useState<string[]>([])
|
|
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<string | null>(null)
|
|
const [browsePath, setBrowsePath] = useState('/app/media')
|
|
const [browseParent, setBrowseParent] = useState<string | null>(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 (
|
|
<div className="space-y-6">
|
|
{/* Vorhandene Ziele */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<HardDrive size={20} className="text-amber-500" />
|
|
<span>Verfügbare Ziele</span>
|
|
</CardTitle>
|
|
<Button variant="ghost" size="sm" onClick={() => laden(true)} title="Ziele neu laden">
|
|
<RefreshCw size={16} />
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{targets.length === 0 ? (
|
|
<p className="text-sm text-slate-500 dark:text-slate-400">Noch keine Unterordner in /app/media — sie entstehen beim ersten Rip oder Mount.</p>
|
|
) : (
|
|
<div className="space-y-2.5">
|
|
{targets.map(t => (
|
|
<div key={t.path} className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
|
|
<div className="flex items-center gap-3">
|
|
<HardDrive size={18} className={t.is_mount ? 'text-amber-500' : 'text-slate-400 dark:text-slate-500'} />
|
|
<div>
|
|
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{t.name}</span>
|
|
<span className="text-xs ml-2 text-slate-500 dark:text-slate-400">
|
|
{t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{mounts.some(m => m.name === t.name) && (
|
|
<button onClick={() => entfernen(t.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors">
|
|
<Trash2 size={16} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* 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 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<HardDrive size={20} className="text-amber-500" />
|
|
<span>Netzwerk-Mounts</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-2.5">
|
|
{mounts.map(m => {
|
|
const aktiv = m.mounted && m.reachable !== false
|
|
const tot = m.mounted && m.reachable === false
|
|
return (
|
|
<div key={m.name} className="flex items-center justify-between gap-3 p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
{aktiv
|
|
? <CheckCircle size={18} className="text-emerald-500 flex-shrink-0" />
|
|
: tot
|
|
? <AlertTriangle size={18} className="text-amber-500 flex-shrink-0" />
|
|
: <AlertCircle size={18} className="text-slate-400 flex-shrink-0" />}
|
|
<div className="min-w-0">
|
|
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{m.name}</span>
|
|
<span className="text-xs ml-2 font-mono text-slate-500 dark:text-slate-400">{m.source}</span>
|
|
<p className={`text-xs ${aktiv ? 'text-emerald-600 dark:text-emerald-400' : tot ? 'text-amber-600 dark:text-amber-400' : 'text-slate-500 dark:text-slate-400'}`}>
|
|
{aktiv ? 'aktiv & erreichbar' : tot ? 'verbunden, aber NICHT erreichbar (NAS aus/weg?)' : 'getrennt'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
{!aktiv && (
|
|
<Button variant="secondary" size="sm" onClick={() => reparieren(m.name)} disabled={busy} title="Neu verbinden (gespeicherte Zugangsdaten)">
|
|
<Wrench size={14} /> Reparieren
|
|
</Button>
|
|
)}
|
|
<button onClick={() => entfernen(m.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors" title="Entfernen">
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Ordner-Verwaltung */}
|
|
<Card>
|
|
<button
|
|
onClick={() => setBrowserOffen(!browserOffen)}
|
|
className="w-full flex items-center justify-between p-6 text-left transition-colors hover:bg-slate-50/50 dark:hover:bg-slate-800/30"
|
|
>
|
|
<div>
|
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
|
Ordner-Verwaltung (optional)
|
|
</h3>
|
|
<p className="text-xs mt-0.5 text-slate-500 dark:text-slate-400">
|
|
Unterordner in der Ablage anschauen und neue anlegen — z. B. „anime" oder „doku".
|
|
</p>
|
|
</div>
|
|
<span className="text-xs flex-shrink-0 ml-3 text-amber-600 dark:text-amber-400 font-medium">
|
|
{browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
|
|
</span>
|
|
</button>
|
|
|
|
{browserOffen && (
|
|
<div className="border-t border-slate-200/80 dark:border-slate-800/80">
|
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50 dark:bg-slate-950/90 border-b border-slate-200/80 dark:border-slate-800">
|
|
<button
|
|
onClick={() => browseParent && browsen(browseParent)}
|
|
disabled={!browseParent}
|
|
className="p-1.5 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400 transition-colors"
|
|
title="Ordner hoch"
|
|
>
|
|
<ArrowUp size={16} />
|
|
</button>
|
|
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
|
|
<input
|
|
value={neuerOrdner}
|
|
onChange={e => 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"
|
|
/>
|
|
<button
|
|
onClick={ordnerAnlegen}
|
|
disabled={!neuerOrdner}
|
|
className="p-1.5 rounded-lg disabled:opacity-30 text-amber-500 hover:bg-amber-500/10 transition-colors"
|
|
title="Ordner anlegen"
|
|
>
|
|
<FolderPlus size={16} />
|
|
</button>
|
|
</div>
|
|
<div className="max-h-44 overflow-y-auto p-2">
|
|
{browseDirs.length === 0 && browseFiles.length === 0 ? (
|
|
<p className="text-xs p-3 text-slate-400 dark:text-slate-500">Ordner ist leer</p>
|
|
) : (
|
|
<>
|
|
{browseDirs.map(d => (
|
|
<button
|
|
key={d.path}
|
|
onClick={() => browsen(d.path)}
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded-lg text-left transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
|
>
|
|
<Folder size={15} className="text-slate-400 dark:text-slate-500" />
|
|
{d.name}
|
|
</button>
|
|
))}
|
|
{browseFiles.map(f => (
|
|
<div
|
|
key={f.name}
|
|
className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-400 dark:text-slate-500"
|
|
>
|
|
<File size={14} className="flex-shrink-0" />
|
|
<span className="truncate">{f.name}</span>
|
|
{f.size_mb != null && (
|
|
<span className="ml-auto text-xs flex-shrink-0 font-mono">
|
|
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
|
|
</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Neues Netzwerk-Ziel */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Plus size={20} className="text-amber-500" />
|
|
<span>Netzwerk-Ziel hinzufügen</span>
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<Input
|
|
label="Name (klein, ohne Leerzeichen)"
|
|
value={name}
|
|
onChange={e => setName(e.target.value.toLowerCase())}
|
|
placeholder="nas-filme"
|
|
/>
|
|
<Select
|
|
label="Typ"
|
|
value={typ}
|
|
onChange={e => setTyp(e.target.value as 'nfs' | 'cifs')}
|
|
>
|
|
<option value="nfs">NFS (NAS/Linux)</option>
|
|
<option value="cifs">SMB/CIFS (Windows-Freigabe, NAS)</option>
|
|
</Select>
|
|
|
|
{typ === 'nfs' ? (
|
|
<div className="md:col-span-2">
|
|
<Input
|
|
label="Quelle (host:/export/pfad)"
|
|
value={source}
|
|
onChange={e => setSource(e.target.value)}
|
|
placeholder="192.168.1.10:/volume1/filme"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="md:col-span-2">
|
|
<Input
|
|
label="Rechner (dein PC oder NAS — Name oder IP)"
|
|
value={host}
|
|
onChange={e => setHost(e.target.value)}
|
|
placeholder="z. B. 192.168.1.20 oder MEIN-NAS"
|
|
/>
|
|
</div>
|
|
<Input
|
|
label="Benutzer"
|
|
value={username}
|
|
onChange={e => setUsername(e.target.value)}
|
|
autoComplete="off"
|
|
placeholder="Freigabe-Benutzer"
|
|
/>
|
|
<Input
|
|
label="Passwort"
|
|
type="password"
|
|
value={password}
|
|
onChange={e => setPassword(e.target.value)}
|
|
autoComplete="new-password"
|
|
/>
|
|
<p className="md:col-span-2 text-xs text-slate-500 dark:text-slate-400">
|
|
Windows-PCs und die meisten NAS verlangen Benutzer + Passwort — ohne Angaben versucht Rippy einen Gast-Zugriff.
|
|
</p>
|
|
<div className="md:col-span-2">
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={freigabenAuflisten}
|
|
disabled={!host || sharesBusy}
|
|
>
|
|
{sharesBusy ? 'Suche…' : 'Freigaben auflisten'}
|
|
</Button>
|
|
</div>
|
|
{shares.length > 0 && (
|
|
<div className="md:col-span-2">
|
|
<Select
|
|
label="Freigabe wählen"
|
|
value={selectedShare}
|
|
onChange={e => setSelectedShare(e.target.value)}
|
|
>
|
|
{shares.map(s => <option key={s} value={s}>{s}</option>)}
|
|
</Select>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<Button
|
|
variant="amber"
|
|
onClick={hinzufuegen}
|
|
disabled={busy || !name || (typ === 'nfs' ? !source : !(host && selectedShare))}
|
|
>
|
|
{busy ? 'Hänge ein…' : 'Einhängen & speichern'}
|
|
</Button>
|
|
|
|
{feedback && (
|
|
<p className={`text-sm flex items-center gap-2 font-medium ${feedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
|
|
{feedback.startsWith('✓') ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
|
|
{feedback}
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|