Architektur
Next.js 16 (App Router) als ein Prozess, läuft als root nativ auf Debian (systemd-Dienst disk-tester, Ablage unter /appdata/disk-tester).
MVC-Schichten
src/
app/ Routing only. Pages und route.ts sind dünn und rufen nur Controller bzw. Views auf.
page.tsx -> views/DashboardView
disks/page.tsx -> Archiv-Ansicht (views/)
disks/[id]/page.tsx -> views/DiskDetailView
settings/page.tsx -> views/SettingsView
api/**/route.ts -> controllers/*
models/ M: Domain-Typen + Persistenz
types.ts Vertrag für alle Schichten (Domain-Typen + API-DTOs)
JsonStore.ts atomares JSON-Schreiben (tmp + rename), DATA_DIR
JsonDiskRepository.ts data/disks.json (DiskRecord pro Platte, Job-Historie, tägliches Aufräumen nach Aufbewahrung)
JsonSettingsRepository.ts data/settings.json (chmod 600): Empfänger, Alarm-Schalter, Intervalle, Sprache, Aufbewahrung, Auto-Update, Testpläne, Automatik
smtpConfig.ts SMTP-Zugang aus den Umgebungsvariablen (nie persistiert)
appInfo.ts App-Name, Version, Autor
plans.ts BUILTIN_PLANS (vorgegebene Testpläne)
manufacturer.ts Hersteller aus Modell/Vendor ableiten
services/ Domain-Logik und Systemzugriff (Teil des Model-Layers)
contracts.ts Interfaces zwischen den Schichten
runtime.ts Composition Root, Singleton auf globalThis
system/ LinuxSystemAdapter (lsblk, smartctl, badblocks), MockSystemAdapter
DefaultSmartEvaluator SMART -> Verdict (ok/warning/bad) + Findings
MailAlertService nodemailer, Alerts gemäß Settings
PollingDiskMonitor Hotplug via lsblk-Polling, SMART-Refresh, Temperatur-Alerts
DefaultAutoRunner Automatik: Countdown nach dem Einstecken, Start des Plans
HttpUpdateChecker Update-Prüfung gegen /api/latest alle 6 h (nach Fehler 30 min), Update starten, Auto-Update
DefaultJobRunner Test-Jobs (Schritte), Abbruch, SMART-Vergleich vorher/nachher
controllers/ C: Validierung, Mapping Domain -> DTO, HTTP-Fehler
views/ V: React Client Components (Polling auf /api/state)
i18n/ Übersetzungsdateien (de, en) und t/msg/render
Regeln:
app/enthält keine Logik.- Controller kennen nur
getRuntime()und Typen ausmodels/types.ts. - Views kennen nur DTOs aus
models/types.tsund sprechen perfetchmit/api/*(Client Components). Kein Import ausservices/odercontrollers/inviews/. - Systemzugriff ausschließlich über
SystemAdapter.
API
| Methode | Pfad | Controller | Beschreibung |
|---|---|---|---|
| GET | /api/state | DiskController.state | StateResponse (eingesteckte + bekannte Platten, Auto-Countdowns, Update-Status) |
| GET | /api/disks | DiskController.list | DiskListResponse (Archiv): Suche q über Hersteller, Modell, Seriennummer, Bezeichnung, Notizen, Firmware; Filter verdict, present; Sortierung |
| GET | /api/disks/[id] | DiskController.detail | DiskDetailResponse |
| PATCH | /api/disks/[id] | DiskController.update | Label/Notizen (UpdateDiskRequest) |
| DELETE | /api/disks/[id] | DiskController.remove | Record löschen (nur nicht eingesteckte Platten) |
| POST | /api/disks/[id]/refresh | DiskController.refresh | SMART neu lesen |
| POST | /api/disks/[id]/acknowledge | DiskController.acknowledge | Aktuellen Zählerwert eines Befunds als Basis übernehmen (AcknowledgeRequest) -> DiskDetailResponse |
| DELETE | /api/disks/[id]/acknowledge | DiskController.unacknowledge | Basis entfernen (AcknowledgeRequest) |
| DELETE | /api/disks/[id]/auto | DiskController.cancelAuto | Automatik-Countdown abbrechen (idempotent) -> { ok, cancelled } |
| POST | /api/disks/[id]/jobs | JobController.start | StartJobRequest -> Job |
| DELETE | /api/disks/[id]/jobs | JobController.abort | laufenden Job abbrechen |
| GET | /api/settings | SettingsController.get | Settings, Passwort maskiert |
| PUT | /api/settings | SettingsController.update | Settings speichern (inkl. language, retentionDays, autoUpdate, plans, auto; Pläne und Automatik werden streng validiert, 400 bei Fehlern) |
| POST | /api/settings/test-mail | SettingsController.testMail | Testmail senden |
| GET | /api/health | SettingsController.health | { ok, mock, problems[], activeJobs, autoPending } (selfCheck, laufende Jobs, offene Automatik-Countdowns) |
| POST | /api/update | UpdateController.start | StartUpdateRequest -> 202 { ok: true }; 409, wenn die Version nicht die aktuelle ist, die Installation sich nicht selbst aktualisieren kann, ein Update läuft, Jobs laufen oder ein Countdown ansteht |
Fehler: JSON { error, key, params } mit 400/404/409/500. error ist in der Sprache der Anfrage übersetzt (Cookie dt_lang, sonst Accept-Language), key und params erlauben der Oberfläche eine eigene Übersetzung. Ohne Cookie und Header gilt Settings.language. Texte in Antworten (Befunde, Phasen, Fehler) sind Msg-Objekte, die die Ansicht in der Sprache des Betrachters darstellt; Mails und Job-Protokolle nutzen Settings.language.
Tests (Jobs)
| JobType | Steps | destruktiv |
|---|---|---|
| smart-short | smart-short | nein |
| smart-long | smart-long | nein |
| read-scan | read-scan (badblocks -b 4096 -sv) | nein |
| write-verify | write-verify (badblocks -b 4096 -wsv [-t random]) | ja |
| burn-in | smart-short -> write-verify -> smart-long | ja |
| plan | options.plan (1..8 Schritte aus PLAN_STEPS, jeder höchstens einmal), Name in options.planName | ja, wenn ein Schritt aus DESTRUCTIVE_STEPS enthalten ist |
| wipe-quick | wipe-quick | ja |
| wipe-secure | wipe-quick -> Methoden-Schritt -> (wipe-verify) | ja |
Vorgegebene Pläne (BUILTIN_PLANS, builtin: true): quick-check, full-read, burn-in, burn-in-quick, wipe-and-check; eigene Pläne liegen in Settings.plans (CRUD über PUT /api/settings, Name 1 bis 60 Zeichen, eindeutige ID). Optionen: quickWrite (write-verify), wipeMethod zero|random, wipePasses, wipeFinalZero (wipe-overwrite). wipe-verify setzt einen vorherigen wipe-overwrite voraus. Der erste fehlgeschlagene Schritt beendet den Job.
Sicherheitsregeln (im JobRunner erzwungen, nicht nur in der UI):
- System-Platten (
isSystem) werden nie destruktiv getestet. - Destruktive Jobs und Pläne nur, wenn
device.inUse === falseundconfirm== Seriennummer. Ausnahme: Wipe-Jobs (WIPE_JOBS) sind auch erlaubt, wenninUseSignatureOnlygilt (nur alte RAID/LVM/ZFS/LUKS-Signaturen, nichts gemountet/aktiv); Pläne haben diese Ausnahme nicht. Beiorigin: "auto"entfällt nur die Bestätigung. - Pro Platte maximal ein Job.
- Vor und nach jedem Job SMART-Counter (5, 197, 198, 187, 199, NVMe media errors) vergleichen; Verschlechterung = Finding.
- Beim Serverstart werden als
runninggespeicherte Jobs aufinterruptedgesetzt. - Nach jedem beendeten Schritt wird die Phase des Schritts zurückgesetzt.
- Jobs werden nach
Settings.retentionDays(30..3650, Standard 365) aufgeräumt, täglich und beim Start; es gibt keine Obergrenze pro Platte. Platten ohne Jobs, derenlastSeenvor dem Stichtag liegt, werden entfernt.
System-Platten und Benutzung
inUseReasons sind strukturierte Gründe (kind, device, detail). isSystem markiert Platten, die /, /boot, /boot/efi oder das aktive Swap des laufenden Systems tragen. Die Oberfläche zeigt „System“ nur bei isSystem, sonst „In Benutzung:
Automatik
Bei einer neu hinzugekommenen Platte (nicht beim ersten Scan) legt der AutoRunner einen AutoStart mit Countdown (Settings.auto.countdownSec) an, wenn die Automatik aktiv ist, der Plan existiert, die Platte weder isSystem noch inUse ist, kein Job läuft, bei onlyUntested noch kein Job abgeschlossen wurde und zerstörende Pläne nur mit allowDestructive. Bei zerstörenden Plänen geht eine Mail „automatisch geplant“ raus. Nach Ablauf wird alles erneut geprüft (Platte vorhanden, gleiche Identität) und per jobs.start(..., "auto") gestartet (Job.auto = true). Entfernen der Platte oder DELETE /api/disks/[id]/auto bricht ab.
Update-Prüfung
HttpUpdateChecker fragt alle 6 h <DISK_TESTER_UPDATE_URL>/api/latest ab (erste Abfrage kurz nach dem Start) und vergleicht per SemVer mit der Version aus package.json. Fehler werden ignoriert, notesUrl nur als http(s)-URL übernommen. Im Mock-Modus läuft keine Prüfung. Das Ergebnis steht in StateResponse.update (UpdateStatus); die Fußzeile verlinkt notesUrl und nennt /appdata/disk-tester/app/deploy/update.sh. Nach einem fehlgeschlagenen Abruf wird nach 30 min erneut geprüft.
Update aus der Oberfläche. UpdateStatus.installable ist wahr, wenn nicht Mock, das Arbeitsverzeichnis unter <base>/releases/<version> liegt und systemd-run vorhanden ist. POST /api/update ruft UpdateChecker.install(version) auf: Es prüft Version == latest, installable, kein laufendes Update, keine laufenden Jobs, keine offenen Automatik-Countdowns (sonst JobError 409) und startet dann losgelöst systemd-run --unit=disk-tester-update --collect --property=Type=oneshot <app>/deploy/update.sh <version>. Das Update läuft damit außerhalb des Dienstes und überlebt dessen Neustart. installing ist wahr, solange die Unit disk-tester-update aktiv ist oder update-status.json den Zustand running hat (jünger als 30 min). lastRun stammt aus <DISK_TESTER_DATA_DIR>/update-status.json, das update.sh schreibt. Die Oberfläche fragt danach /api/health ab, bis der Server mit der neuen Version antwortet, und lädt neu.
Auto-Update. Mit Settings.autoUpdate installiert der Checker nach einer erfolgreichen Prüfung (und alle 5 min erneut, falls zuvor Jobs liefen) die neueste Version, sobald weder Jobs laufen noch Countdowns anstehen. Eine Version, für die ein Versuch fehlschlug (im Speicher gemerkt, nach einem Neustart über lastRun mit Zustand failed/rolled-back), wird nicht automatisch erneut versucht, nur nach einem manuellen Klick.
deploy/update.sh verweigert den Neustart, solange /api/health activeJobs > 0 oder autoPending > 0 meldet (außer mit --force), und läuft nie doppelt (flock auf <base>/update.lock).
Siehe Update-Vertrag.
Mehrsprachigkeit
Die Texte des Servers (error.*, log.*, mail.*, plan.builtin.*) stehen in messages/<lang>/server.ts. Vor der Mehrsprachigkeit gespeicherte Befunde werden beim Laden als finding.legacy übernommen. Texte stammen ausschließlich aus src/i18n/messages/<de|en>/*.ts mit gleichen Schlüsseln in beiden Sprachen. Der Server erzeugt Msg (msg(key, params)); Oberfläche und API-Fehler rendern in der Sprache der Anfrage, Mails und Job-Protokolle mit t(settings.language, …). Die Sprache der Oberfläche steht im Cookie dt_lang.
Lösch-Modus (Wipe)
wipe-quick: wipefs auf allen Partitionen + Disk, erste/letzte 32 MiB nullen, Partitionstabelle neu einlesen (Sekunden).wipe-secure: immer zuerstwipe-quick, dann jeoptions.wipeMethod:
| wipeMethod | Schritt | Voraussetzung (device.wipe) |
|---|---|---|
| zero, random | wipe-overwrite (wipePasses 1..7, bei random wipeFinalZero default true) | – |
| ata-erase, ata-enhanced-erase | wipe-ata-erase | ataSecurity.supported (enhanced: enhancedSupported), nicht frozen, nicht enabled |
| nvme-format / nvme-sanitize | wipe-nvme | nvmeFormat / nvmeSanitize |
| discard | wipe-discard | discard |
wipeVerifyhängtwipe-verifyan (Platte vollständig lesen, alles Nullen). Nicht erlaubt beirandomohnewipeFinalZero.- Der JobRunner validiert alles (400 mit übersetzter Begründung), normalisiert die Optionen und scannt nach dem Job lsblk neu (
monitor.scan()), danach SMART. Der SMART-Vorher/Nachher-Vergleich bleibt aktiv. - Job-Mails nennen Methode/Durchläufe und das Ergebnis der Verifizierung.
Als Basis übernommene Zähler (Acknowledged Baselines)
Zähler wie 199 UDMA_CRC lassen sich in der Firmware nicht zurücksetzen. Der Nutzer kann den aktuellen Wert als Basis übernehmen („Als Basis übernehmen“):
POST /api/disks/[id]/acknowledgemit{ code }(nurACKNOWLEDGEABLE_CODES; der Befund muss aktuell existieren, Level warning/info, mit numerischemvalue; 400/404 sonst). Gespeichert wird{ value, at }inDiskRecord.acknowledged[code], danachmonitor.reevaluate(id)(kein smartctl-Aufruf). Bei nicht eingesteckten Platten wirdlastSmartneu ausgewertet.DELETEentfernt die Basis.SmartEvaluator.evaluate(smart, settings, acknowledged): Zähler-Befunde tragenvalue. Warning-Befunde mit übernommener Basis werden beivalue <= Basiszuinfo(acknowledgedgesetzt, ohne Einfluss auf das Verdict). Steigt der Wert, bleibt es eine Warnung „… (seit Basis N am TT.MM.JJJJ: +X)“ mit angehängtemacknowledged, damit die UI neu übernehmen anbieten kann.bad-Befunde werden nie herabgestuft.- Mails: Info-Befunde lösen keine
smart-problem-Mail aus; ein Anstieg über die Basis löst wieder eine aus.
Umgebungsvariablen
| Variable | Default | Bedeutung |
|---|---|---|
PORT | 3000 | HTTP-Port |
DISK_TESTER_DATA_DIR | ./data | Persistenz (systemd: /appdata/disk-tester/data) |
DISK_TESTER_AUTH | – | HTTP Basic Auth benutzer:passwort (src/proxy.ts) |
DISK_TESTER_ALLOW_NO_AUTH | – | 1 = bewusst ohne Zugriffsschutz, unterdrückt die Warnung im Health-Check |
DISK_TESTER_MOCK | – | 1 = simulierte Platten (Entwicklung ohne Linux/root) |
DISK_TESTER_MOCK_SPEED | 1 | Beschleunigungsfaktor für simulierte Tests |
DISK_TESTER_UPDATE_URL | https://disk-tester.znbw.de | Basis-URL der Update-Schnittstelle |
DISK_TESTER_UPDATE_CHECK | an | 0 schaltet die Update-Prüfung ab |
SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASSWORD, SMTP_FROM | – | Mailserver (models/smtpConfig.ts), wird bei jedem Zugriff neu gelesen |
SMTP_TO | – | Empfänger, die beim ersten Start in settings.json übernommen werden |
NEXT_OUTPUT_STANDALONE | – | 1 = Next.js-Standalone-Output; wird nur vom Release-CI für das native Release-Paket gesetzt |
NODE_ENV | – | production im Betrieb |
Updates und Release-Pakete sind im Update-Vertrag beschrieben.
Mock-Modus
Mit DISK_TESTER_MOCK=1 (oder npm run dev:mock) ersetzt der MockSystemAdapter den LinuxSystemAdapter. Er simuliert sechs Platten mit unterschiedlichem Gesundheitszustand und Lösch-Fähigkeiten, darunter eine USB-Platte, die zyklisch ein- und ausgesteckt wird (je 90 s), eine mit fehlschlagenden Tests und eine mit alten RAID-Signaturen (nur löschbar). Jobs laufen zeitgesteuert; DISK_TESTER_MOCK_SPEED beschleunigt sie. Die Oberfläche zeigt „Mock-Modus“ in der Kopfzeile.
Der Mock durchläuft dieselben Controller, JobRunner-Regeln und Views wie die echte Hardware, es wird nur der Systemzugriff ersetzt.
Sicherheitsregeln
- Jeder Systemzugriff läuft über
SystemAdapter; externe Programme werden immer mit Argument-Arrays gestartet, nie über eine Shell. - Destruktive Aktionen prüft der JobRunner (Bestätigung per Seriennummer, nicht in Benutzung) und die ausführende Schicht (
badblocks.ts,wipe.ts) unabhängig davon unmittelbar vor dem Schreiben noch einmal: gleiche Platte, nicht in Benutzung, kein fremder Prozess auf dem Gerät. - „In Benutzung“ wird konservativ erkannt: Mounts, aktives Swap, aktive md/dm-Halter (
/sys/block/*/holders), gemountetes btrfs-Multi-Device, System-Laufwerk, exklusive Geöffnet-Prüfung (ZFS, Ceph, VM). Zweifel enden in „in Benutzung“. - Plattenidentität (Modell + Seriennummer) ist nur eindeutig, wenn kein zweites Gerät dieselbe Identität meldet; sonst sind destruktive Tests gesperrt.
- Mehrere NVMe-Namespaces auf einem Controller: Format/Sanitize wird nicht angeboten (würde alle Namespaces löschen); ist das nicht feststellbar, wird verweigert.
- Das temporäre ATA-Passwort wird nach Fehlern und Abbruch entfernt (siehe Löschen).
- Die Automatik fasst nie Platten an, die
isSystemoderinUsesind. settings.jsonhat Rechte 600 und das Datenverzeichnis 700; das SMTP-Passwort kommt ausschließlich aus der Umgebung und wird nie an die Oberfläche geliefert.- Schreibzugriffe auf die JSON-Dateien erfolgen atomar (tmp + fsync + rename) und werden beim Beenden des Dienstes geschrieben.