Architecture
Next.js 16 (App Router) as a single process, runs as root natively on Debian (systemd service disk-tester, installed under /appdata/disk-tester).
MVC layers
src/
app/ Routing only. Pages and route.ts are thin and only call controllers or views.
page.tsx -> views/DashboardView
disks/page.tsx -> archive view (views/)
disks/[id]/page.tsx -> views/DiskDetailView
settings/page.tsx -> views/SettingsView
api/**/route.ts -> controllers/*
models/ M: domain types + persistence
types.ts Contract for all layers (domain types + API DTOs)
JsonStore.ts atomic JSON writing (tmp + rename), DATA_DIR
JsonDiskRepository.ts data/disks.json (DiskRecord per drive, job history, daily pruning by retention)
JsonSettingsRepository.ts data/settings.json (chmod 600): recipients, alert switches, intervals, language, retention, auto update, test plans, auto mode
smtpConfig.ts SMTP access from environment variables (never persisted)
appInfo.ts app name, version, author
plans.ts BUILTIN_PLANS (built-in test plans)
manufacturer.ts derive the manufacturer from model/vendor
services/ domain logic and system access (part of the model layer)
contracts.ts interfaces between the layers
runtime.ts composition root, singleton on globalThis
system/ LinuxSystemAdapter (lsblk, smartctl, badblocks), MockSystemAdapter
DefaultSmartEvaluator SMART -> verdict (ok/warning/bad) + findings
MailAlertService nodemailer, alerts according to settings
PollingDiskMonitor hotplug via lsblk polling, SMART refresh, temperature alerts
DefaultAutoRunner auto mode: countdown after plugging in, starting the plan
HttpUpdateChecker update check against /api/latest every 6 h (30 min after a failure), starting updates, auto update
DefaultJobRunner test jobs (steps), abort, SMART before/after comparison
controllers/ C: validation, mapping domain -> DTO, HTTP errors
views/ V: React client components (polling /api/state)
i18n/ translation files (de, en) and t/msg/render
Rules:
app/contains no logic.- Controllers only know
getRuntime()and types frommodels/types.ts. - Views only know DTOs from
models/types.tsand talk to/api/*viafetch(client components). No import fromservices/orcontrollers/inviews/. - System access only through
SystemAdapter.
API
| Method | Path | Controller | Description |
|---|---|---|---|
| GET | /api/state | DiskController.state | StateResponse (connected + known drives, auto mode countdowns, update status) |
| GET | /api/disks | DiskController.list | DiskListResponse (archive): search q over manufacturer, model, serial, label, notes, firmware; filters verdict, present; sorting |
| GET | /api/disks/[id] | DiskController.detail | DiskDetailResponse |
| PATCH | /api/disks/[id] | DiskController.update | label/notes (UpdateDiskRequest) |
| DELETE | /api/disks/[id] | DiskController.remove | delete record (only drives that are not connected) |
| POST | /api/disks/[id]/refresh | DiskController.refresh | re-read SMART |
| POST | /api/disks/[id]/acknowledge | DiskController.acknowledge | accept the current counter value of a finding as baseline (AcknowledgeRequest) -> DiskDetailResponse |
| DELETE | /api/disks/[id]/acknowledge | DiskController.unacknowledge | remove baseline (AcknowledgeRequest) |
| DELETE | /api/disks/[id]/auto | DiskController | cancel the auto mode countdown |
| POST | /api/disks/[id]/jobs | JobController.start | StartJobRequest -> Job |
| DELETE | /api/disks/[id]/jobs | JobController.abort | abort the running job |
| GET | /api/settings | SettingsController.get | settings, password masked |
| PUT | /api/settings | SettingsController.update | save settings (incl. autoUpdate) |
| POST | /api/settings/test-mail | SettingsController.testMail | send a test mail |
| GET | /api/health | SettingsController.health | { ok, mock, problems[], activeJobs, autoPending } (selfCheck, running jobs, pending auto countdowns) |
| POST | /api/update | UpdateController.start | StartUpdateRequest -> 202 { ok: true }; 409 if the version is not the latest, the installation cannot update itself, an update is running, jobs are running or a countdown is pending |
Errors: JSON { error, key, params } with 400/404/409/500. error is translated into the request's language (cookie dt_lang, otherwise Accept-Language); key and params let the interface translate on its own. Texts in responses (findings, phases, errors) are Msg objects that the view renders in the viewer's language; mails and job logs use Settings.language.
Tests (jobs)
| JobType | Steps | destructive |
|---|---|---|
| smart-short | smart-short | no |
| smart-long | smart-long | no |
| read-scan | read-scan (badblocks -b 4096 -sv) | no |
| write-verify | write-verify (badblocks -b 4096 -wsv [-t random]) | yes |
| burn-in | smart-short -> write-verify -> smart-long | yes |
| plan | options.plan (1..8 steps from PLAN_STEPS, each at most once), name in options.planName | yes, if a step from DESTRUCTIVE_STEPS is included |
| wipe-quick | wipe-quick | yes |
| wipe-secure | wipe-quick -> method step -> (wipe-verify) | yes |
Built-in plans (BUILTIN_PLANS, builtin: true): quick-check, full-read, burn-in, burn-in-quick, wipe-and-check; user plans live in Settings.plans (CRUD via PUT /api/settings).
Safety rules (enforced in the JobRunner, not only in the UI):
- Destructive jobs only if
device.inUse === falseandconfirm== serial number. Exception: wipe jobs (WIPE_JOBS) are also allowed wheninUseSignatureOnlyapplies (only old RAID/LVM/ZFS/LUKS signatures, nothing mounted/active). - At most one job per drive.
- Compare SMART counters (5, 197, 198, 187, 199, NVMe media errors) before and after every job; deterioration = finding.
- On server start, jobs stored as
runningare set tointerrupted. - After each finished step, the step's phase is cleared.
- Jobs are pruned by
Settings.retentionDays(30..3650, default 365), daily and on start; there is no upper limit per drive.
System drives and usage
inUseReasons are structured reasons (kind, device, detail). isSystem marks drives that carry /, /boot, /boot/efi or the active swap of the running system. The interface shows "System" only for isSystem, otherwise "In use:
Auto mode
For a newly added drive (not during the first scan) the AutoRunner creates an AutoStart with a countdown (Settings.auto.countdownSec) if auto mode is enabled, the plan exists, the drive is neither isSystem nor inUse, no job is running, with onlyUntested no job has been finished yet, and destructive plans only with allowDestructive. For destructive plans an "auto-scheduled" mail is sent. When the countdown ends, everything is checked again (drive present, same identity) and the plan is started via jobs.start(..., "auto") (Job.auto = true). Removing the drive or DELETE /api/disks/[id]/auto cancels it.
Update check
HttpUpdateChecker queries <DISK_TESTER_UPDATE_URL>/api/latest every 6 h and compares it via SemVer with the version from package.json. The result is in StateResponse.update (UpdateStatus); the footer links notesUrl and names /appdata/disk-tester/app/deploy/update.sh. After a failed request the check is repeated after 30 min.
Update from the UI. UpdateStatus.installable is true when not in mock mode, the working directory lies under <base>/releases/<version> and systemd-run exists. POST /api/update calls UpdateChecker.install(version): it checks version == latest, installable, no running update, no running jobs and no pending auto countdowns (otherwise JobError 409), then starts systemd-run --unit=disk-tester-update --collect --property=Type=oneshot <app>/deploy/update.sh <version> detached. The update therefore runs outside the service and survives its restart. installing is true while the unit disk-tester-update is active or update-status.json has state running (younger than 30 min). lastRun comes from <DISK_TESTER_DATA_DIR>/update-status.json, written by update.sh. The UI then polls /api/health until the server answers with the new version and reloads.
Auto update. With Settings.autoUpdate, the checker installs the newest version after a successful check (and again every 5 min in case jobs were running) as soon as no job runs and no countdown is pending. A version whose attempt failed (remembered in memory, and after a restart via lastRun with state failed/rolled-back) is not retried automatically, only after a manual click.
deploy/update.sh refuses to restart the service while /api/health reports activeJobs > 0 or autoPending > 0 (unless --force) and never runs twice at once (flock on <base>/update.lock).
See the update contract.
Internationalization
Texts come exclusively from src/i18n/messages/<de|en>/*.ts with the same keys in both languages. The server produces Msg (msg(key, params)); the interface and API errors render in the request's language, mails and job logs with t(settings.language, …). The interface language is stored in the cookie dt_lang.
Erase mode (wipe)
wipe-quick: wipefs on all partitions + disk, zero the first/last 32 MiB, re-read the partition table (seconds).wipe-secure: alwayswipe-quickfirst, then depending onoptions.wipeMethod:
| wipeMethod | Step | Precondition (device.wipe) |
|---|---|---|
| zero, random | wipe-overwrite (wipePasses 1..7, for random wipeFinalZero defaults to true) | – |
| ata-erase, ata-enhanced-erase | wipe-ata-erase | ataSecurity.supported (enhanced: enhancedSupported), not frozen, not enabled |
| nvme-format / nvme-sanitize | wipe-nvme | nvmeFormat / nvmeSanitize |
| discard | wipe-discard | discard |
wipeVerifyappendswipe-verify(read the drive completely, everything zeros). Not allowed forrandomwithoutwipeFinalZero.- The JobRunner validates everything (400 with a translated reason), normalizes the options and rescans lsblk after the job (
monitor.scan()), then SMART. The SMART before/after comparison stays active. - Job mails state the method/passes and the result of the verification.
Accepted counters (acknowledged baselines)
Counters such as 199 UDMA_CRC cannot be reset in the firmware. The user can accept the current value as a baseline ("Accept as baseline"):
POST /api/disks/[id]/acknowledgewith{ code }(onlyACKNOWLEDGEABLE_CODES; the finding must currently exist, level warning/info, with a numericvalue; 400/404 otherwise).{ value, at }is stored inDiskRecord.acknowledged[code], thenmonitor.reevaluate(id)(no smartctl call). For drives that are not connected,lastSmartis re-evaluated.DELETEremoves the baseline.SmartEvaluator.evaluate(smart, settings, acknowledged): counter findings carryvalue. Warning findings with an accepted baseline becomeinfowhenvalue <= baseline(acknowledgedset, no influence on the verdict). If the value rises, it stays a warning "… (since baseline N on DD.MM.YYYY: +X)" withacknowledgedattached so the UI can offer to accept again.badfindings are never downgraded.- Mails: info findings trigger no
smart-problemmail; a rise above the baseline triggers one again.
Environment variables
| Variable | Default | Meaning |
|---|---|---|
PORT | 3000 | HTTP port |
DISK_TESTER_DATA_DIR | ./data | persistence (systemd: /appdata/disk-tester/data) |
DISK_TESTER_AUTH | – | HTTP Basic Auth user:password (src/proxy.ts) |
DISK_TESTER_ALLOW_NO_AUTH | – | 1 = deliberately no access protection, suppresses the warning in the health check |
DISK_TESTER_MOCK | – | 1 = simulated drives (development without Linux/root) |
DISK_TESTER_MOCK_SPEED | 1 | acceleration factor for simulated tests |
DISK_TESTER_UPDATE_URL | https://disk-tester.znbw.de | base URL of the update interface |
DISK_TESTER_UPDATE_CHECK | on | 0 disables the update check |
SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASSWORD, SMTP_FROM | – | mail server (models/smtpConfig.ts), re-read on every access |
SMTP_TO | – | recipients copied into settings.json on first start |
NEXT_OUTPUT_STANDALONE | – | 1 = Next.js standalone output; only set by the release CI for the native release package |
NODE_ENV | – | production in operation |
Updates and release packages are described in the update contract.
Mock mode
With DISK_TESTER_MOCK=1 (or npm run dev:mock), the MockSystemAdapter replaces the LinuxSystemAdapter. It simulates six drives with different health states and erase capabilities, including a USB drive that is cyclically plugged and unplugged (90 s each), one with failing tests and one with old RAID signatures (erasable only). Jobs run on a timer; DISK_TESTER_MOCK_SPEED accelerates them. The interface shows "Mock mode" in the header.
The mock goes through the same controllers, JobRunner rules and views as real hardware; only system access is replaced.
Security rules
- All system access goes through
SystemAdapter; external programs are always started with argument arrays, never through a shell. - Destructive actions are checked by the JobRunner (confirmation by serial number, not in use) and, independently, by the executing layer (
badblocks.ts,wipe.ts) right before writing: same drive, not in use, no foreign process on the device. - "In use" is detected conservatively: mounts, active swap, active md/dm holders (
/sys/block/*/holders), mounted btrfs multi-device, system drive, exclusive-open check (ZFS, Ceph, VM). Doubt ends in "in use". - Drive identity (model + serial number) is only unambiguous if no second device reports the same identity; otherwise destructive tests are blocked.
- Several NVMe namespaces on one controller: Format/Sanitize is not offered (it would erase all namespaces); if that cannot be determined, it is refused.
- The temporary ATA password is removed after errors and abort (see Erasing).
- Auto mode never touches drives that are
isSystemorinUse. settings.jsonhas mode 600 and the data directory 700; the SMTP password comes exclusively from the environment and is never delivered to the interface.- Writes to the JSON files are atomic (tmp + fsync + rename) and flushed when the service stops.