fix(dashboard): zwei Placebos raus, Restzeit rein - und Loeschen sagt die Zahl
Commander: "Der Server Status muss dringend ueberarbeitet werden, das Dashboard soll ja quasi alles auf einen Blick zeigen." Berechtigt - die Karte hiess "Echte Live-Daten" und enthielt drei Angaben, von denen zwei erfunden waren: 1. "Auslastung: 0 % (Aktiv)" war NICHT die CPU-Last, sondern der Fortschritt des Jobs - bzw. eine feste 15 bzw. 5, wenn keiner lief. Rippy misst nirgends CPU-Last, also wird sie auch nicht behauptet. 2. Die Verlaufskurve daneben war Math.random(). Reine Dekoration, die wie eine Messung aussah. 3. "N Worker Online" zaehlte die registrierten Eintraege aus /system/info - auch Leichen alter Container-Rebuilds. Die echte Erreichbarkeit steht in /capabilities (Celery-Ping) und wird jetzt von dort geholt. Statt dessen: laufende Phase im Klartext mit RESTZEIT, jeder erreichbare Worker mit Kernen/Vektorbefehlen/extern, freier Platz mit Warnung, wenn er nicht mehr fuer eine Disc reicht. Und wenn kein Worker antwortet, steht das rot da statt "1 Worker Online". Restzeit auch im aktiven Rip-Banner und in der Job-Tabelle. Solange die Datenlage duenn ist, steht dort "Restzeit wird gemessen" - keine erfundene Zahl. Job entfernen fragt jetzt vorher nach den Rohdaten und nennt die GB, die daneben liegen bleiben und danach nicht mehr erreichbar sind - auf Wunsch loescht es sie mit. Der Fall aus v3.14, bei dem 75 GB unsichtbar verwaisten. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { AlertTriangle, CheckCircle2, HelpCircle } from 'lucide-react'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
@@ -8,6 +9,9 @@ interface ConfirmDialogProps {
|
||||
onConfirm: () => void
|
||||
title: string
|
||||
message: string
|
||||
// Zusatz unter der Meldung — z. B. ein Kasten mit der Größe der Rohdaten,
|
||||
// die beim Entfernen liegen bleiben, samt Wahl „mitlöschen".
|
||||
extra?: ReactNode
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
type?: 'warning' | 'info' | 'success'
|
||||
@@ -19,6 +23,7 @@ export default function ConfirmDialog({
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
extra,
|
||||
confirmText = 'Bestätigen',
|
||||
cancelText = 'Abbrechen',
|
||||
type = 'info',
|
||||
@@ -57,6 +62,7 @@ export default function ConfirmDialog({
|
||||
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||
{message}
|
||||
</p>
|
||||
{extra}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ interface Job {
|
||||
title?: string
|
||||
can_retry?: boolean
|
||||
meta?: JobMeta | null
|
||||
// Restzeit von der API (eta.py). '' = noch keine Aussage möglich — dann wird
|
||||
// bewusst nichts angezeigt statt einer erfundenen Zahl.
|
||||
eta_sekunden?: number
|
||||
eta_text?: string
|
||||
}
|
||||
|
||||
interface SystemInfo {
|
||||
@@ -39,6 +43,16 @@ interface SystemInfo {
|
||||
workers: { name: string; encoders: string[] }[]
|
||||
}
|
||||
|
||||
// Ein Worker aus GET /capabilities — inklusive der ECHTEN Erreichbarkeit.
|
||||
// /system/info liefert nur die registrierten Einträge; die Kachel „N Worker
|
||||
// online" zählte damit auch Leichen aus alten Rebuilds mit (Befund 26.07.2026).
|
||||
interface WorkerLive {
|
||||
name: string
|
||||
encoders: string[]
|
||||
online?: boolean
|
||||
info?: { cpu_modell?: string, cpu_kerne?: string, cpu_simd?: string, extern?: string }
|
||||
}
|
||||
|
||||
function posterUrl(meta?: JobMeta | null): string | null {
|
||||
const p = meta?.poster_path
|
||||
if (!p) return null
|
||||
@@ -60,14 +74,47 @@ export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
||||
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
|
||||
// Job, dessen Entfernen gerade bestätigt werden soll — plus was daneben liegt.
|
||||
const [loeschKandidat, setLoeschKandidat] = useState<Job | null>(null)
|
||||
const [rohdaten, setRohdaten] = useState<{ gb: number, dateien: number, pfade: string[] } | null>(null)
|
||||
const [rohdatenMitloeschen, setRohdatenMitloeschen] = useState(false)
|
||||
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
|
||||
const [sparklinePoints, setSparklinePoints] = useState<number[]>([20, 35, 15, 45, 30, 60, 40, 75, 50, 65])
|
||||
const [workersLive, setWorkersLive] = useState<WorkerLive[]>([])
|
||||
const { toast } = useToast()
|
||||
|
||||
const jobLoeschen = async (job: Job) => {
|
||||
/*
|
||||
* Entfernen fragt jetzt ZUERST, was daneben liegen bleibt.
|
||||
*
|
||||
* Grund (Vorfall 25.07.2026, v3.14): „Job aus der Liste entfernen" löscht
|
||||
* bewusst keine Dateien. Das ist richtig — aber Job und Rohdaten sind nur
|
||||
* über die Job-ID verbunden, der Rohschnitt ist danach also UNERREICHBAR:
|
||||
* nichts zeigt mehr darauf, „Neu komprimieren" ist unmöglich, und im UI ist
|
||||
* nichts davon zu sehen. Damals verwaisten so 75 GB unsichtbar auf der
|
||||
* Platte, gefunden erst per SSH.
|
||||
*/
|
||||
const loeschenVorbereiten = async (job: Job) => {
|
||||
setLoeschKandidat(job)
|
||||
setRohdaten(null)
|
||||
setRohdatenMitloeschen(false)
|
||||
try {
|
||||
await api.delete(`/jobs/${job.id}`)
|
||||
toast('success', `„${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`)
|
||||
const r = await api.get(`/jobs/${job.id}/rohdaten`)
|
||||
setRohdaten(r.data)
|
||||
} catch {
|
||||
setRohdaten(null) // dann eben ohne Zahl fragen, statt gar nicht
|
||||
}
|
||||
}
|
||||
|
||||
const jobLoeschen = async () => {
|
||||
const job = loeschKandidat
|
||||
if (!job) return
|
||||
setLoeschKandidat(null)
|
||||
try {
|
||||
const r = await api.delete(`/jobs/${job.id}`, {
|
||||
params: rohdatenMitloeschen ? { rohdaten: true } : {},
|
||||
})
|
||||
const befreit = r.data?.rohdaten_geloescht_gb
|
||||
toast('success', `„${job.title || job.id.slice(0, 8)}" aus der Liste entfernt`
|
||||
+ (befreit ? ` — ${befreit} GB Rohdaten gelöscht` : ''))
|
||||
setJobs(await fetchJobs())
|
||||
} catch (e: any) {
|
||||
toast('error', e?.response?.data?.detail || 'Entfernen fehlgeschlagen')
|
||||
@@ -88,16 +135,18 @@ export default function Dashboard() {
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [jobsData, sysData] = await Promise.all([
|
||||
const [jobsData, sysData, capsData] = await Promise.all([
|
||||
fetchJobs(),
|
||||
api.get('/system/info').then(r => r.data).catch(() => null),
|
||||
// Für die ECHTE Online-Zahl: /capabilities kennt den Celery-Ping,
|
||||
// /system/info nur die Registrierung. Der Endpunkt ist seit v3.15
|
||||
// schnell (0,003 s, Ping läuft im Hintergrund) — er darf hier also
|
||||
// im 4-Sekunden-Takt mitlaufen.
|
||||
api.get('/capabilities').then(r => r.data?.workers || []).catch(() => []),
|
||||
])
|
||||
setJobs(jobsData)
|
||||
if (sysData) setSystemInfo(sysData)
|
||||
|
||||
const isBusy = jobsData.some((j: Job) => j.status === 'processing' || j.status === 'transcoding')
|
||||
const newPoint = isBusy ? Math.floor(Math.random() * 40) + 60 : Math.floor(Math.random() * 25) + 10
|
||||
setSparklinePoints(prev => [...prev.slice(1), newPoint])
|
||||
setWorkersLive(capsData)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -113,18 +162,37 @@ export default function Dashboard() {
|
||||
const completedJobs = jobs.filter(j => j.status === 'completed')
|
||||
const failedJobs = jobs.filter(j => j.status === 'failed')
|
||||
|
||||
/*
|
||||
* Was diese Karte anzeigt — und was sie NICHT mehr anzeigt.
|
||||
*
|
||||
* Commander 26.07.2026: „Der Server Status muss dringend überarbeitet werden,
|
||||
* das Dashboard soll ja quasi alles auf einen Blick zeigen." Berechtigt: Die
|
||||
* Karte hieß „Echte Live-Daten" und enthielt drei Angaben, von denen zwei
|
||||
* erfunden waren.
|
||||
*
|
||||
* 1. „Auslastung: 0 % (Aktiv)" war NICHT die CPU-Last, sondern der
|
||||
* Fortschritt des Jobs — bzw. eine feste 15/5, wenn keiner lief. Rippy
|
||||
* misst nirgends CPU-Last, also wird sie auch nicht behauptet.
|
||||
* 2. Die Verlaufskurve daneben war `Math.random()`. Reine Dekoration, die
|
||||
* wie eine Messung aussah.
|
||||
* 3. „N Worker Online" zählte die registrierten Einträge aus
|
||||
* /system/info — auch Leichen alter Container-Rebuilds. Die echte
|
||||
* Erreichbarkeit steht in /capabilities (Celery-Ping).
|
||||
*
|
||||
* Statt dessen stehen jetzt Angaben, die es wirklich gibt: laufende Phase mit
|
||||
* Restzeit, erreichbare Worker mit ihrer Rechenleistung, freier Platz.
|
||||
*/
|
||||
const mainPlatz = systemInfo?.plaetze?.[0]
|
||||
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
|
||||
const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
|
||||
const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100)))
|
||||
const cpuPercent = aktiverJob ? aktiverJob.progress : (jobs.length > 0 ? 15 : 5)
|
||||
const workerCount = systemInfo?.workers?.length || 1
|
||||
|
||||
const sparklinePath = sparklinePoints.map((val, idx) => {
|
||||
const x = (idx / (sparklinePoints.length - 1)) * 100
|
||||
const y = 50 - (val / 100) * 40
|
||||
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
|
||||
}).join(' ')
|
||||
const workerOnline = workersLive.filter(w => w.online)
|
||||
const encoderWorker = workerOnline.filter(w => (w.encoders || []).length > 0)
|
||||
const hardwareWorker = workerOnline.filter(w => (w.encoders || []).some(e => !e.startsWith('cpu')))
|
||||
// Phase im Klartext — „processing" heißt rippen, „transcoding" komprimieren.
|
||||
const phaseText = !aktiverJob ? 'Bereit — keine Disc in Arbeit'
|
||||
: aktiverJob.status === 'transcoding' ? 'Kompression läuft (HandBrake)'
|
||||
: 'Rip läuft (MakeMKV, verlustfrei)'
|
||||
|
||||
const filteredJobs = jobs.filter(j => {
|
||||
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
|
||||
@@ -271,11 +339,17 @@ export default function Dashboard() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full sm:w-auto justify-end">
|
||||
<div className="w-36 space-y-1 text-right">
|
||||
<div className="w-40 space-y-1 text-right">
|
||||
<div className="text-xs font-mono text-amber-400 font-bold">{aktiverJob.progress}%</div>
|
||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div className="h-full bg-amber-500 rounded-full transition-all duration-300" style={{ width: `${aktiverJob.progress}%` }} />
|
||||
</div>
|
||||
{/* Die Angabe, deren Fehlen den 50-Stunden-Lauf am 25.07.2026
|
||||
unsichtbar machte. Ohne belastbare Messreihe steht hier
|
||||
ehrlich, dass noch gemessen wird (siehe api/eta.py). */}
|
||||
<div className="text-[11px] font-mono text-slate-300">
|
||||
{aktiverJob.eta_text || 'Restzeit wird gemessen …'}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="amber" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
|
||||
Details
|
||||
@@ -360,6 +434,9 @@ export default function Dashboard() {
|
||||
style={{ width: `${job.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{job.eta_text && (
|
||||
<div className="text-[10px] font-mono text-slate-500">{job.eta_text}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
|
||||
@@ -393,9 +470,9 @@ export default function Dashboard() {
|
||||
|
||||
{(job.status === 'completed' || job.status === 'failed') && (
|
||||
<button
|
||||
onClick={() => jobLoeschen(job)}
|
||||
onClick={() => loeschenVorbereiten(job)}
|
||||
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||
title="Löschen"
|
||||
title="Aus der Liste entfernen"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
@@ -421,57 +498,110 @@ export default function Dashboard() {
|
||||
<CardHeader className="border-b border-slate-800">
|
||||
<CardTitle className="text-slate-200 flex items-center gap-2">
|
||||
<Server size={18} className="text-amber-400" />
|
||||
Server Status (Echte Live-Daten)
|
||||
Server-Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5 flex-1 flex items-center">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 w-full">
|
||||
<div className="space-y-4 flex-1 w-full">
|
||||
<CardContent className="p-5 flex-1 flex flex-col justify-center">
|
||||
<div className="space-y-4 w-full">
|
||||
|
||||
{/* 1. Was tut Rippy JETZT — mit Restzeit statt Phantasie-Last */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||
<span className="text-slate-400 flex items-center gap-1.5"><Cpu size={14} className="text-amber-400" /> Auslastung:</span>
|
||||
<span className="text-amber-400 font-bold">{aktiverJob ? `${cpuPercent}% (Aktiv)` : 'Standby (0%)'}</span>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1 gap-2">
|
||||
<span className="text-slate-400 flex items-center gap-1.5 flex-shrink-0">
|
||||
<Activity size={14} className="text-amber-400" /> Gerade:
|
||||
</span>
|
||||
<span className={`text-right ${aktiverJob ? 'text-amber-400 font-bold' : 'text-slate-300'}`}>
|
||||
{phaseText}
|
||||
</span>
|
||||
</div>
|
||||
{aktiverJob ? (
|
||||
<>
|
||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-500 rounded-full transition-all duration-500"
|
||||
style={{ width: `${aktiverJob ? Math.max(15, cpuPercent) : 5}%` }}
|
||||
style={{ width: `${Math.max(2, aktiverJob.progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[11px] font-mono mt-1">
|
||||
<span className="text-slate-300 truncate pr-2">
|
||||
{aktiverJob.title || aktiverJob.id.slice(0, 8)} · {aktiverJob.progress} %
|
||||
</span>
|
||||
{/* Leer heißt: noch keine belastbare Schätzung. Dann wird
|
||||
das auch so gesagt — nicht geraten (siehe eta.py). */}
|
||||
<span className="text-amber-400 flex-shrink-0">
|
||||
{aktiverJob.eta_text || 'Restzeit wird noch gemessen'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[11px] font-mono text-slate-500">
|
||||
{queueJobs.length > 0
|
||||
? `${queueJobs.length} Job(s) warten in der Schlange`
|
||||
: 'Disc einlegen — Rippy erkennt sie selbst'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 2. Worker — die ECHTE Erreichbarkeit, plus was sie können */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||
<span className="text-slate-400 flex items-center gap-1.5"><Activity size={14} className="text-amber-400" /> Worker Status:</span>
|
||||
<span className="text-slate-200">{workerCount} Worker Online</span>
|
||||
<span className="text-slate-400 flex items-center gap-1.5">
|
||||
<Cpu size={14} className="text-amber-400" /> Worker erreichbar:
|
||||
</span>
|
||||
<span className={workerOnline.length > 0 ? 'text-slate-200' : 'text-rose-400 font-bold'}>
|
||||
{workerOnline.length} von {workersLive.length}
|
||||
{hardwareWorker.length > 0 ? ' · Hardware-Encoder dabei' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div className="h-full bg-emerald-500 rounded-full w-full" />
|
||||
{workerOnline.length === 0 ? (
|
||||
<p className="text-[11px] font-mono text-rose-400">
|
||||
Kein Worker antwortet — ohne ihn läuft kein Rip.
|
||||
Prüfen: <span className="text-slate-300">docker compose ps</span>
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{workerOnline.map(w => (
|
||||
<p key={w.name} className="text-[11px] font-mono text-slate-400 truncate">
|
||||
<span className="text-emerald-400">●</span> {w.name}
|
||||
{w.info?.cpu_kerne ? ` · ${w.info.cpu_kerne} Kerne` : ''}
|
||||
{w.info?.cpu_simd && w.info.cpu_simd !== 'unbekannt' ? ` · ${w.info.cpu_simd}` : ''}
|
||||
{w.info?.extern === 'ja' ? ' · extern' : ''}
|
||||
{(w.encoders || []).length === 0 ? ' · kann nicht komprimieren' : ''}
|
||||
</p>
|
||||
))}
|
||||
{encoderWorker.length === 0 && (
|
||||
<p className="text-[11px] font-mono text-amber-400">
|
||||
Keiner dieser Worker hat HandBrake — es wird nur gerippt, nicht komprimiert.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 3. Platz — war schon immer echt */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||
<span className="text-slate-400 flex items-center gap-1.5"><HardDrive size={14} className="text-amber-400" /> Speichermounts:</span>
|
||||
<span className="text-slate-200">{freiGb > 0 ? `${freiGb} GB frei` : 'Aktiv'}</span>
|
||||
<span className="text-slate-400 flex items-center gap-1.5">
|
||||
<HardDrive size={14} className="text-amber-400" /> {mainPlatz?.name || 'Ablage'}:
|
||||
</span>
|
||||
<span className={freiGb > 0 && freiGb < 60 ? 'text-amber-400 font-bold' : 'text-slate-200'}>
|
||||
{freiGb > 0 ? `${freiGb} von ${gesamtGb} GB frei` : 'unbekannt'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-amber-500/80 rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.max(10, belegtPercent)}%` }}
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
belegtPercent > 90 ? 'bg-rose-500' : 'bg-amber-500/80'
|
||||
}`}
|
||||
style={{ width: `${Math.max(2, belegtPercent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full sm:w-40 h-24 flex-shrink-0 flex items-end justify-center pt-2">
|
||||
<svg viewBox="0 0 100 50" className="w-full h-full stroke-amber-400 fill-amber-500/10 transition-all duration-500">
|
||||
<path
|
||||
d={`${sparklinePath} L 100 50 L 0 50 Z`}
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
{freiGb > 0 && freiGb < 60 && (
|
||||
<p className="text-[11px] font-mono text-amber-400 mt-1">
|
||||
Eine Blu-ray braucht roh ~40 GB, eine 4K-UHD bis 100 GB —
|
||||
das reicht nicht mehr für jede Disc.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -548,6 +678,39 @@ export default function Dashboard() {
|
||||
confirmText="Aufräumen"
|
||||
type="warning"
|
||||
/>
|
||||
|
||||
{/* Entfernen eines EINZELNEN Jobs — mit der Zahl, die vorher fehlte. */}
|
||||
<ConfirmDialog
|
||||
isOpen={loeschKandidat !== null}
|
||||
onClose={() => setLoeschKandidat(null)}
|
||||
onConfirm={jobLoeschen}
|
||||
title={`„${loeschKandidat?.title || loeschKandidat?.id.slice(0, 8) || ''}" entfernen?`}
|
||||
message="Der Eintrag verschwindet aus der Liste. Die fertig abgelegten Dateien bleiben, wo sie sind."
|
||||
confirmText={rohdatenMitloeschen ? 'Entfernen und löschen' : 'Entfernen'}
|
||||
type="warning"
|
||||
extra={rohdaten && rohdaten.gb > 0 ? (
|
||||
<div className="mt-3 text-xs p-3 rounded-lg bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300 space-y-2">
|
||||
<p>
|
||||
<strong>Daneben liegen {rohdaten.gb} GB Rohdaten</strong>
|
||||
{rohdaten.dateien > 0 ? ` (${rohdaten.dateien} Datei${rohdaten.dateien === 1 ? '' : 'en'})` : ''}.
|
||||
Ohne den Eintrag zeigt nichts mehr darauf — sie sind dann über Rippy
|
||||
nicht mehr erreichbar und „Neu komprimieren" ist unmöglich.
|
||||
</p>
|
||||
{rohdaten.pfade.map(p => (
|
||||
<p key={p} className="font-mono break-all opacity-80">{p}</p>
|
||||
))}
|
||||
<label className="flex items-start gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rohdatenMitloeschen}
|
||||
onChange={e => setRohdatenMitloeschen(e.target.checked)}
|
||||
className="w-4 h-4 mt-0.5 accent-rose-500 rounded"
|
||||
/>
|
||||
<span>Rohdaten jetzt mitlöschen und {rohdaten.gb} GB freigeben</span>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user