Skip to main content

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 from models/types.ts.
  • Views only know DTOs from models/types.ts and talk to /api/* via fetch (client components). No import from services/ or controllers/ in views/.
  • System access only through SystemAdapter.

API​

MethodPathControllerDescription
GET/api/stateDiskController.stateStateResponse (connected + known drives, auto mode countdowns, update status)
GET/api/disksDiskController.listDiskListResponse (archive): search q over manufacturer, model, serial, label, notes, firmware; filters verdict, present; sorting
GET/api/disks/[id]DiskController.detailDiskDetailResponse
PATCH/api/disks/[id]DiskController.updatelabel/notes (UpdateDiskRequest)
DELETE/api/disks/[id]DiskController.removedelete record (only drives that are not connected)
POST/api/disks/[id]/refreshDiskController.refreshre-read SMART
POST/api/disks/[id]/acknowledgeDiskController.acknowledgeaccept the current counter value of a finding as baseline (AcknowledgeRequest) -> DiskDetailResponse
DELETE/api/disks/[id]/acknowledgeDiskController.unacknowledgeremove baseline (AcknowledgeRequest)
DELETE/api/disks/[id]/autoDiskControllercancel the auto mode countdown
POST/api/disks/[id]/jobsJobController.startStartJobRequest -> Job
DELETE/api/disks/[id]/jobsJobController.abortabort the running job
GET/api/settingsSettingsController.getsettings, password masked
PUT/api/settingsSettingsController.updatesave settings (incl. autoUpdate)
POST/api/settings/test-mailSettingsController.testMailsend a test mail
GET/api/healthSettingsController.health{ ok, mock, problems[], activeJobs, autoPending } (selfCheck, running jobs, pending auto countdowns)
POST/api/updateUpdateController.startStartUpdateRequest -> 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)​

JobTypeStepsdestructive
smart-shortsmart-shortno
smart-longsmart-longno
read-scanread-scan (badblocks -b 4096 -sv)no
write-verifywrite-verify (badblocks -b 4096 -wsv [-t random])yes
burn-insmart-short -> write-verify -> smart-longyes
planoptions.plan (1..8 steps from PLAN_STEPS, each at most once), name in options.planNameyes, if a step from DESTRUCTIVE_STEPS is included
wipe-quickwipe-quickyes
wipe-securewipe-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 === false and confirm == serial number. Exception: wipe jobs (WIPE_JOBS) are also allowed when inUseSignatureOnly applies (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 running are set to interrupted.
  • 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: always wipe-quick first, then depending on options.wipeMethod:
wipeMethodStepPrecondition (device.wipe)
zero, randomwipe-overwrite (wipePasses 1..7, for random wipeFinalZero defaults to true)–
ata-erase, ata-enhanced-erasewipe-ata-eraseataSecurity.supported (enhanced: enhancedSupported), not frozen, not enabled
nvme-format / nvme-sanitizewipe-nvmenvmeFormat / nvmeSanitize
discardwipe-discarddiscard
  • wipeVerify appends wipe-verify (read the drive completely, everything zeros). Not allowed for random without wipeFinalZero.
  • 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]/acknowledge with { code } (only ACKNOWLEDGEABLE_CODES; the finding must currently exist, level warning/info, with a numeric value; 400/404 otherwise). { value, at } is stored in DiskRecord.acknowledged[code], then monitor.reevaluate(id) (no smartctl call). For drives that are not connected, lastSmart is re-evaluated. DELETE removes the baseline.
  • SmartEvaluator.evaluate(smart, settings, acknowledged): counter findings carry value. Warning findings with an accepted baseline become info when value <= baseline (acknowledged set, no influence on the verdict). If the value rises, it stays a warning "… (since baseline N on DD.MM.YYYY: +X)" with acknowledged attached so the UI can offer to accept again. bad findings are never downgraded.
  • Mails: info findings trigger no smart-problem mail; a rise above the baseline triggers one again.

Environment variables​

VariableDefaultMeaning
PORT3000HTTP port
DISK_TESTER_DATA_DIR./datapersistence (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_SPEED1acceleration factor for simulated tests
DISK_TESTER_UPDATE_URLhttps://disk-tester.znbw.debase URL of the update interface
DISK_TESTER_UPDATE_CHECKon0 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 isSystem or inUse.
  • settings.json has 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.